mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-01 19:05:31 +02:00
110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
import { timingSafeEqual } from "node:crypto";
|
|
import {
|
|
type CanActivate,
|
|
type ExecutionContext,
|
|
Injectable,
|
|
Logger,
|
|
UnauthorizedException,
|
|
} from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import type { Request } from "express";
|
|
import * as jwt from "jsonwebtoken";
|
|
import type { UserContext } from "./user-context.interface.js";
|
|
|
|
/** Constant-time string compare; false on length mismatch (no early return). */
|
|
function safeEqual(a: string, b: string): boolean {
|
|
const ab = Buffer.from(a);
|
|
const bb = Buffer.from(b);
|
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
}
|
|
|
|
@Injectable()
|
|
export class AuthGuard implements CanActivate {
|
|
private readonly logger = new Logger(AuthGuard.name);
|
|
private jwtPublicKey: string | null = null;
|
|
|
|
constructor(private configService: ConfigService) {
|
|
const publicKey = this.configService.get<string>("SYNC_JWT_PUBLIC_KEY");
|
|
if (publicKey) {
|
|
this.jwtPublicKey = publicKey.replace(/\\n/g, "\n");
|
|
this.logger.log("JWT public key configured — cloud auth enabled");
|
|
}
|
|
}
|
|
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const request = context.switchToHttp().getRequest<Request>();
|
|
const authHeader = request.headers.authorization;
|
|
|
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
throw new UnauthorizedException(
|
|
"Missing or invalid authorization header",
|
|
);
|
|
}
|
|
|
|
const token = authHeader.substring(7);
|
|
|
|
// Try SYNC_TOKEN first (self-hosted mode)
|
|
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
|
if (expectedToken && safeEqual(token, expectedToken)) {
|
|
(request as unknown as Record<string, unknown>).user = {
|
|
mode: "self-hosted",
|
|
prefix: "",
|
|
teamPrefix: null,
|
|
profileLimit: 0,
|
|
teamProfileLimit: 0,
|
|
} satisfies UserContext;
|
|
return true;
|
|
}
|
|
|
|
// Try JWT verification (cloud mode)
|
|
if (this.jwtPublicKey) {
|
|
try {
|
|
const decoded = jwt.verify(token, this.jwtPublicKey, {
|
|
algorithms: ["RS256"],
|
|
}) as jwt.JwtPayload;
|
|
|
|
// Validate the scope claims' SHAPE before trusting them as S3 key
|
|
// prefixes. An empty/over-broad prefix would make validateKeyAccess
|
|
// (`key.startsWith(prefix)`) authorize the entire bucket, so a signer
|
|
// bug or permissive claim must not silently widen scope.
|
|
const prefix = decoded.prefix || `users/${decoded.sub}/`;
|
|
if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) {
|
|
throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`);
|
|
}
|
|
const teamPrefix =
|
|
decoded.teamPrefix === undefined || decoded.teamPrefix === null
|
|
? null
|
|
: decoded.teamPrefix;
|
|
if (
|
|
teamPrefix !== null &&
|
|
!/^teams\/[^/]+\/$/.test(String(teamPrefix))
|
|
) {
|
|
throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`);
|
|
}
|
|
|
|
(request as unknown as Record<string, unknown>).user = {
|
|
mode: "cloud",
|
|
prefix,
|
|
teamPrefix,
|
|
profileLimit: decoded.profileLimit || 0,
|
|
teamProfileLimit: decoded.teamProfileLimit || 0,
|
|
} satisfies UserContext;
|
|
return true;
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`JWT verification failed: ${err instanceof Error ? err.message : err}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// If SYNC_TOKEN is configured but didn't match, or JWT failed
|
|
if (!expectedToken && !this.jwtPublicKey) {
|
|
throw new UnauthorizedException(
|
|
"No auth method configured on server (set SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY)",
|
|
);
|
|
}
|
|
|
|
throw new UnauthorizedException("Invalid sync token or JWT");
|
|
}
|
|
}
|