import { CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common"; import { v4 as uuid } from "uuid"; import type { Request } from "express"; import { sha256hex } from "../../lib/crypto.js"; import { verifyToken, TokenError } from "../../lib/jwt.js"; import { loadEnv } from "../../config/env.js"; import { ApiError } from "../errors.js"; import { PrismaService } from "../prisma.service.js"; import { RedisService } from "../redis.service.js"; import { isJtiRevoked } from "../../modules/oauth/token-keys.js"; import type { AuthContext } from "../types.js"; /** * Global authentication guard. * * Accepts: * - `Authorization: Bearer ` (HS256 access token) * - `Authorization: Bearer fk_` (workspace API key — sha256 lookup) * * Public paths (whitelist) pass through with no auth. Everything else * requires a valid credential → `req.auth` is set; failures are * 401 { code: "UNAUTHORIZED" | "INVALID_TOKEN" | "TOKEN_EXPIRED" }. * * `/internal/*` paths (TASK-0092 slice 2) pass through THIS guard too — * they authenticate with the service token in `InternalServiceGuard` * (controller-level) instead of a user JWT / API key. */ @Injectable() export class AuthGuard implements CanActivate { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService ) {} async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); if (!req.id) req.id = uuid(); if (this.isPublic(req)) return true; const header = req.headers.authorization; if (!header || !header.startsWith("Bearer ")) { throw ApiError.unauthorized("Missing bearer token.", [{ reason: "token_missing" }]); } const token = header.slice(7).trim(); if (!token) throw ApiError.unauthorized("Missing bearer token.", [{ reason: "token_missing" }]); req.auth = token.startsWith("fk_") ? await this.resolveApiKey(token, req) : await this.resolveJwt(token, req); return true; } private isPublic(req: Request): boolean { const path = req.path; // The global prefix makes the public health path `/api/v1/health` at // request time — `req.path` is post-prefix, so BOTH spellings are // whitelisted (the bare `/health` line predates the global prefix). if (path.startsWith("/health")) return true; if (path.startsWith("/api/v1/health")) return true; // Service-token-authenticated internal surface (TASK-0092 slice 2): // `InternalServiceGuard` (controller-level) authenticates it — the JWT // / API-key path must not 401 it first. if (path.startsWith("/internal/")) return true; if (path.startsWith("/api/v1/auth/login")) return true; if (path.startsWith("/api/v1/auth/register")) return true; if (path.startsWith("/api/v1/auth/refresh")) return true; // TASK-0133 enforcement #1: the 2FA step completes a PASSWORD login — // the user is not authenticated yet (the pending step + TOTP code are // the credentials), exactly like `/auth/login`. if (path.startsWith("/api/v1/auth/2fa/")) return true; if (path.startsWith("/api/v1/auth/reset-password")) return true; // TASK-0131: the login page's ANONYMOUS SSO discovery (minimal-info — // org slug + boolean, no provider details). Public by design (the // email is the input; an unknown email returns the same empty shape). if (path.startsWith("/api/v1/auth/sso/options")) return true; // TASK-0131: the SAML/OIDC protocol endpoints are mounted OUTSIDE the // api/v1 prefix (main.ts excludes them — browser bindings, not JSON // API). Their authentication is the assertion/ID-token signature + the // state/nonce/PKCE checks inside the services; no user JWT. if (path.startsWith("/saml/")) return true; if (path.startsWith("/sso/")) return true; // TASK-0132: the SCIM protocol surface (`/scim/v2/*`) is a third-party // IdP binding — bearer-token auth via `ScimBearerGuard` (controller // level), no user JWT. The SCIM ADMIN routes (`/api/v1/orgs/:orgId/ // scim/*`) are NOT whitelisted here (they stay user-JWT + owner-gated). if (path.startsWith("/scim/")) return true; // public shared content + public forms + oauth token endpoint if (/^\/api\/v1\/share\/[^/]+$/.test(path)) return true; if (/^\/api\/v1\/f\/[^/]+\/?$/.test(path)) return true; // Phase 10 (TASK-0107/0108): the public form SURFACE — GET payload, // POST submit, and the anonymous form-attributed file sessions. The // submit is ATTRIBUTED only when a valid user JWT is present (the // controller's OptionalAuthGuard decodes it) — the path itself is // never auth-gated (anonymous submits are the point; never a 401). if (path.startsWith("/api/v1/public/forms/")) return true; // Phase 10 (TASK-0109): the test-only debug events endpoint. It // 404s unless NODE_ENV=test AND requires the X-Debug-Token — the // whitelist here only means "no user JWT required" (a production // build still 404s it; see debug.controller.ts). if (path === "/api/v1/debug/events") return true; if (path.startsWith("/api/v1/oauth/")) return true; // Phase 11 (TASK-0110): the INCOMING webhook endpoint is public by // design — the HMAC-SHA256 signature (X-FS-Signature) IS the auth. // Only POST /webhooks/incoming/:webhookId lives here; no user JWT is // required (the signature carries workspace identity via the stored // trigger). if (path.startsWith("/api/v1/webhooks/incoming/")) return true; // TASK-0127: the interactive API docs + spec download are public // content (like /health). They leak no data — the spec is a committed // file in the repo — and key holders are the primary audience. if (path.startsWith("/api/v1/docs")) return true; if (path === "/docs" || path.startsWith("/docs/")) return true; return false; } private async resolveJwt(token: string, req: Request): Promise { let claims; try { claims = verifyToken(token, loadEnv().JWT_SECRET); } catch (err) { if (err instanceof TokenError) { throw new ApiError(err.code, 401, err.message, [ { reason: err.code === "TOKEN_EXPIRED" ? "token_expired" : "token_malformed" } ]); } throw err; } if (!claims.sub) throw ApiError.unauthorized("Token has no subject.", [{ reason: "token_malformed" }]); // TASK-0129: the consent CSRF token (kind "oauth_consent") is a // single-purpose form nonce — it must NEVER authenticate an API call // even though it carries a valid `sub` (the user id). if (claims.kind === "oauth_consent") { throw ApiError.unauthorized("Token is not an access token.", [{ reason: "token_malformed" }]); } // TASK-0132 (prerequisite fix): a SUSPENDED user must not ride an // in-flight access token. Phase 1's status check already blocks // LOGIN and REFRESH; without this, a token minted before suspension // (or a SCIM `replace active:false`) stays valid until its exp (≤15 // m). Point query on the PK — the same cost class as the API-key // path's lookup. Placed AFTER the cheap consent rejection (above) so a // consent token keeps its 401; applies to BOTH user JWTs and OAuth // access tokens (they carry the same user identity). if (await this.isUserSuspended(claims.sub)) { this.auditSuspendedBlock(claims.sub, req); throw ApiError.forbidden("Account suspended.", [{ reason: "account_suspended" }]); } // TASK-0129: an OAuth access token (claims.type === "oauth") keeps // kind "user" (it carries the user's identity) but is gated by its SCOPE, // not the user's membership — `lib/authorize.ts` branches on the presence // of `oauthScopes`. The target workspace comes from X-Factory-Workspace // (the OAuth JWT carries no workspace claim); `authorize.ts` validates // the user is a member of it (401 if not). The JTI denylist check lets a // revoked access token be rejected immediately (before its 15-min exp). if (claims.type === "oauth") { if (await isJtiRevoked(this.redis.getRedis(), typeof claims.jti === "string" ? claims.jti : undefined)) { throw ApiError.unauthorized("Access token revoked.", [{ reason: "token_revoked" }]); } return { kind: "user", userId: claims.sub, oauthScopes: Array.isArray(claims.scope) ? (claims.scope as string[]) : (typeof claims.scope === "string" ? (claims.scope as string).split(" ").filter(Boolean) : []), oauthClientId: typeof claims.client_id === "string" ? claims.client_id : undefined, workspaceId: (req.headers["x-factory-workspace"] as string | undefined) }; } const headerWs = req.headers["x-factory-workspace"] as string | undefined; return { kind: "user", userId: claims.sub, workspaces: Array.isArray(claims.workspaces) ? (claims.workspaces as string[]) : undefined, sessionId: typeof claims.sid === "string" ? claims.sid : undefined, workspaceId: headerWs ?? (claims.workspaces?.length === 1 ? claims.workspaces[0] : undefined) }; } private async resolveApiKey(rawKey: string, req: Request): Promise { const keyHash = sha256hex(rawKey); const row = await this.prisma.apiKey.findUnique({ where: { keyHash } }); if (!row) throw ApiError.unauthorized("Unknown API key.", [{ reason: "api_key_invalid" }]); if (row.revokedAt) { await this.recordAuthFailure(row.id, "api_key_revoked"); throw ApiError.unauthorized("API key revoked.", [{ reason: "api_key_revoked" }]); } if (row.expiresAt && row.expiresAt.getTime() < Date.now()) { await this.recordAuthFailure(row.id, "api_key_expired"); throw ApiError.unauthorized("API key expired.", [{ reason: "api_key_expired" }]); } const headerWs = req.headers["x-factory-workspace"] as string | undefined; if (headerWs && headerWs !== row.workspaceId) { throw ApiError.forbidden("API key does not belong to the requested workspace.", [ { reason: "workspace_mismatch" } ]); } const scopeList: string[] = [...row.scopes]; if (scopeList.includes("ADMIN")) scopeList.push("WRITE", "READ"); if (scopeList.includes("WRITE")) scopeList.push("READ"); // TASK-0127: resolve the key's creator so API-key requests can be // attributed to a real user row (createdBy-style columns, audit) while // acting as the WORKSPACE — scope decisions never consult this id // (see the API-key branch of `assertCan`/`assertRole`). const creator = await this.prisma.user .findUnique({ where: { id: row.createdById }, select: { id: true } }) .catch(() => null); // Best-effort last-used bump (fire and forget), TASK-0127 debounced: // only write when the last bump is > 60 s old (avoids write amplification // on high-frequency CI keys; "recent enough" for the list UI's // "last used" relative time). if (!row.lastUsedAt || Date.now() - row.lastUsedAt.getTime() > 60_000) { void this.prisma .apiKey.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }) .catch(() => undefined); } return { kind: "api", workspaceId: row.workspaceId, scopes: scopeList, apiKeyId: row.id, userId: creator?.id, rateLimit: row.rateLimit }; } /** * TASK-0127: audit `api_key.auth_failure` for a known-but-dead key * (revoked/expired), throttled per key to 1/hour (Redis SET NX EX) so a * leaked key cannot spam the audit log. Unknown keys (key-guess * brute-forcing) are not audited per key — the per-key/IP rate limit and * the single hash-index probe already cover that surface (docs §7 * Security: timing must not leak existence). Fail-open: never break auth. */ /** * TASK-0132 (prerequisite fix): is the user for this token currently * SUSPENDED? A point query on the PK. A not-found user (a token for a * since-deleted account) is treated as "not suspended" so the normal 401 * (bad token) still governs — we only short-circuit the suspended case. */ private async isUserSuspended(sub: string): Promise { try { const row = await this.prisma.user.findUnique({ where: { id: sub }, select: { status: true } }); return row?.status === "SUSPENDED"; } catch { return false; // a transient DB error must not turn a valid token into a 500 } } /** * Audit the in-flight-session suspension block (TASK-0132). Best-effort, * like `recordAuthFailure` — a failed audit must never break auth. No * workspace is in scope here (the suspended user's own), so the row is * user-scoped (workspaceId null). */ private auditSuspendedBlock(userId: string, req: Request): void { void this.prisma.auditLog .create({ data: { actorType: "USER", actorId: userId, resourceType: "user", resourceId: userId, action: "auth.suspended_blocked", ipAddress: req.ip ?? null, userAgent: (req.headers["user-agent"] as string | undefined) ?? null, metadata: { path: req.path } as object } }) .catch(() => { /* audit must never break auth */ }); } private async recordAuthFailure(apiKeyId: string, reason: string): Promise { let passed = false; try { passed = (await this.redis.getRedis().set(`apikey:authfail:${apiKeyId}`, "1", "EX", 3600, "NX")) !== null; } catch { passed = true; // fail-open: audit the failure even if throttling is down } if (!passed) return; try { const row = await this.prisma.apiKey.findUnique({ where: { id: apiKeyId }, select: { workspaceId: true } }); if (!row) return; await this.prisma.auditLog.create({ data: { actorType: "API_KEY", actorId: `api:${apiKeyId}`, workspaceId: row.workspaceId, resourceType: "api_key", resourceId: apiKeyId, action: "api_key.auth_failure", metadata: { reason } as object } }); } catch { /* audit must never break auth */ } } }