feat(api): allow API key authentication for domain endpoints

Switch /domains controller from `isAuthenticated` (cookie-only) to
`requireAuth` (cookie OR API key), matching the pattern used by other
project-scoped API endpoints (/v1/send, /contacts, etc.).

API keys are project-scoped credentials with full access; for write
operations the projectId in the request must equal the API key's
projectId. JWT (dashboard) auth retains role-based checks
(requireAdminAccess for POST/DELETE).

Also: improve UX when a domain is already linked to the same project
by returning a clear error instead of the generic "linked to another
project" message.

Refactor DomainService.checkDomainOwnership to make `userId` optional
(needed for API key path) while preserving its existing return shape
and adding `projectId` to the result.
This commit is contained in:
ReylanLugo
2026-05-09 22:25:17 -04:00
parent 15bdcf6e49
commit 3f30a48c40
2 changed files with 59 additions and 23 deletions
+41 -16
View File
@@ -4,7 +4,7 @@ import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {NotAllowed, NotFound} from '../exceptions/index.js'; import {NotAllowed, NotFound} from '../exceptions/index.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js'; import {Keys} from '../services/keys.js';
import {MembershipService} from '../services/MembershipService.js'; import {MembershipService} from '../services/MembershipService.js';
@@ -17,14 +17,19 @@ export class Domains {
* Get all domains for a project * Get all domains for a project
*/ */
@Get('project/:projectId') @Get('project/:projectId')
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) { public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
const {projectId} = DomainSchemas.projectId.parse(req.params); const {projectId} = DomainSchemas.projectId.parse(req.params);
// Verify user has access to this project if (auth.type === 'apiKey') {
await MembershipService.requireAccess(auth.userId!, projectId); if (auth.projectId !== projectId) {
throw new NotAllowed('You do not have access to this project');
}
} else {
await MembershipService.requireAccess(auth.userId!, projectId);
}
const domains = await DomainService.getProjectDomains(projectId); const domains = await DomainService.getProjectDomains(projectId);
@@ -35,19 +40,23 @@ export class Domains {
* Add a new domain to a project * Add a new domain to a project
*/ */
@Post('') @Post('')
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) { public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
const {projectId, domain} = DomainSchemas.create.parse(req.body); const {projectId: requestedProjectId, domain} = DomainSchemas.create.parse(req.body);
const projectId = auth.type === 'apiKey' ? auth.projectId : requestedProjectId;
if (!auth.userId) { if (auth.type === 'apiKey') {
if (requestedProjectId !== auth.projectId) {
throw new NotAllowed('You do not have access to this project');
}
} else if (!auth.userId) {
throw new NotFound('User authentication required'); throw new NotFound('User authentication required');
} else {
await MembershipService.requireAdminAccess(auth.userId, projectId);
} }
// Verify user has admin access to this project
await MembershipService.requireAdminAccess(auth.userId!, projectId);
// Block domain changes on disabled projects // Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(projectId); const isDisabled = await SecurityService.isProjectDisabled(projectId);
if (isDisabled) { if (isDisabled) {
@@ -68,6 +77,12 @@ export class Domains {
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId); const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
if (ownershipCheck.exists) { if (ownershipCheck.exists) {
if (ownershipCheck.projectId === projectId) {
return res.status(400).json({
error: 'This domain is already linked to this project.',
});
}
// If domain exists and user is a member of that project, allow it // If domain exists and user is a member of that project, allow it
if (ownershipCheck.isMember) { if (ownershipCheck.isMember) {
return res.status(400).json({ return res.status(400).json({
@@ -99,7 +114,7 @@ export class Domains {
* Check verification status for a domain * Check verification status for a domain
*/ */
@Get(':id/verify') @Get(':id/verify')
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) { public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
@@ -111,8 +126,13 @@ export class Domains {
throw new NotFound('Domain not found'); throw new NotFound('Domain not found');
} }
// Verify user has access to the project this domain belongs to if (auth.type === 'apiKey') {
await MembershipService.requireAccess(auth.userId!, domain.projectId); if (auth.projectId !== domain.projectId) {
throw new NotAllowed('You do not have access to this project');
}
} else {
await MembershipService.requireAccess(auth.userId!, domain.projectId);
}
const verificationStatus = await DomainService.checkVerification(id); const verificationStatus = await DomainService.checkVerification(id);
@@ -127,7 +147,7 @@ export class Domains {
* Remove a domain from a project * Remove a domain from a project
*/ */
@Delete(':id') @Delete(':id')
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) { public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
@@ -139,8 +159,13 @@ export class Domains {
throw new NotFound('Domain not found'); throw new NotFound('Domain not found');
} }
// Verify user has admin access to the project this domain belongs to if (auth.type === 'apiKey') {
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId); if (auth.projectId !== domain.projectId) {
throw new NotAllowed('You do not have access to this project');
}
} else {
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
}
// Block domain changes on disabled projects // Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId); const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
+18 -7
View File
@@ -438,15 +438,14 @@ export class DomainService {
* @param userId User ID to check membership * @param userId User ID to check membership
* @returns Object with exists flag and membership info * @returns Object with exists flag and membership info
*/ */
public static async checkDomainOwnership(domain: string, userId: string) { public static async checkDomainOwnership(domain: string, userId?: string) {
const existingDomain = await prisma.domain.findFirst({ const existingDomain = await prisma.domain.findFirst({
where: {domain}, where: {domain},
include: { include: {
project: { project: {
include: { select: {
members: { id: true,
where: {userId}, name: true,
},
}, },
}, },
}, },
@@ -456,8 +455,20 @@ export class DomainService {
return {exists: false}; return {exists: false};
} }
// Check if user is a member of the project that owns this domain let isMember = false;
const isMember = existingDomain.project.members.length > 0;
if (userId) {
const membership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: existingDomain.project.id,
},
},
});
isMember = membership !== null;
}
return { return {
exists: true, exists: true,