122 lines
3.5 KiB
TypeScript
122 lines
3.5 KiB
TypeScript
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 '@mosaic/auth';
|
|
import type { Db } from '@mosaic/db';
|
|
import { eq, adminTokens, users as usersTable } from '@mosaic/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<boolean> {
|
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
|
|
|
// 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<boolean> {
|
|
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<boolean> {
|
|
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;
|
|
}
|
|
}
|