import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable, UnauthorizedException, } from '@nestjs/common'; import { createHash } from 'node:crypto'; import { fromNodeHeaders } from 'better-auth/node'; import type { Auth } from '@mosaicstack/auth'; import type { Db } from '@mosaicstack/db'; import { eq, adminTokens, users as usersTable } from '@mosaicstack/db'; import type { FastifyRequest } from 'fastify'; import { AUTH } from '../auth/auth.tokens.js'; import { DB } from '../database/database.module.js'; interface UserWithRole { id: string; role?: string; } type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown }; @Injectable() export class AdminGuard implements CanActivate { constructor( @Inject(AUTH) private readonly auth: Auth, @Inject(DB) private readonly db: Db, ) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); // Try bearer token auth first const authHeader = request.raw.headers['authorization']; if (authHeader?.startsWith('Bearer ')) { return this.validateBearerToken(request, authHeader.slice(7)); } // Fall back to BetterAuth session return this.validateSession(request); } private async validateBearerToken(request: FastifyRequest, plaintext: string): Promise { const tokenHash = createHash('sha256').update(plaintext).digest('hex'); const [row] = await this.db .select({ tokenId: adminTokens.id, userId: adminTokens.userId, scope: adminTokens.scope, expiresAt: adminTokens.expiresAt, userName: usersTable.name, userEmail: usersTable.email, userRole: usersTable.role, }) .from(adminTokens) .innerJoin(usersTable, eq(adminTokens.userId, usersTable.id)) .where(eq(adminTokens.tokenHash, tokenHash)) .limit(1); if (!row) { throw new UnauthorizedException('Invalid API token'); } if (row.expiresAt && row.expiresAt < new Date()) { throw new UnauthorizedException('API token expired'); } if (row.userRole !== 'admin') { throw new ForbiddenException('Admin access required'); } // Update last-used timestamp (fire-and-forget) this.db .update(adminTokens) .set({ lastUsedAt: new Date() }) .where(eq(adminTokens.id, row.tokenId)) .then(() => {}) .catch(() => {}); const req = request as AuthenticatedRequest; req.user = { id: row.userId, name: row.userName, email: row.userEmail, role: row.userRole }; req.session = { id: `token:${row.tokenId}`, userId: row.userId }; return true; } private async validateSession(request: FastifyRequest): Promise { const headers = fromNodeHeaders(request.raw.headers); const result = await this.auth.api.getSession({ headers }); if (!result) { throw new UnauthorizedException('Invalid or expired session'); } const user = result.user as UserWithRole; let userRole = user.role; if (!userRole) { const [dbUser] = await this.db .select({ role: usersTable.role }) .from(usersTable) .where(eq(usersTable.id, user.id)) .limit(1); userRole = dbUser?.role ?? 'member'; (user as UserWithRole).role = userRole; } if (userRole !== 'admin') { throw new ForbiddenException('Admin access required'); } const req = request as AuthenticatedRequest; req.user = result.user; req.session = result.session; return true; } }