import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { generateId } from "@factory/shared"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { assertRole } from "../../lib/authorize.js"; import { loadEnv } from "../../config/env.js"; import { signToken, verifyToken, parseTtl, TokenError } from "../../lib/jwt.js"; import { sha256hex } from "../../lib/crypto.js"; import { OAUTH_SCOPES, parseScopeList, serializeScopeList, pkceChallenge } from "./scopes.js"; import { codeKey, rtKey, familyKey, jtiKey, isJtiRevoked, clientCodesKey, clientRtSetKey } from "./token-keys.js"; import { v4 as uuid } from "uuid"; /** * TASK-0129 — OAuth2 authorization-code + PKCE for third-party apps. * * Registered apps live in the `Integration` model (`provider: "oauth"`, * `config.oauthApp`) — one app per row. The client secret is stored SHA-256 * hashed (never plaintext); public clients have no secret and MUST use PKCE. * * Authorization codes and rotating refresh tokens live in REDIS only (no DB * tables, per the task's "Redis only" decision): * - code: oauth:code: (SETEX 600, GETDEL on consume — single-use) * - rt: oauth:rt: ({familyId, clientId, userId, scopes, revoked}) * - family: oauth:family: ("revoked") — the reuse-kill switch * - jti: oauth:jti: (access-token denylist) * * Refresh rotation follows security-architecture.md §1.2 EXACTLY as the user * login flow does: a live token rotates (old → revoked tombstone, new issued * in the same family); presenting an ALREADY-ROTATED token is a reuse attack * → the whole family is revoked + audited. OAuth families are per-(user, * client) so a reuse-kill can never collateral-revoke the user's login * sessions (those are the DB refresh-token family — separate). * * Scope enforcement lives in `lib/authorize.ts` (`oauthScopeCap` branch): * `*:read` → VIEW, any `*:write` → EDIT. There is no FULL_ACCESS scope — * owner/admin-only routes stay out of reach for third-party apps (C-13.7). */ /** The registered-app shape stored in `Integration.config.oauthApp`. */ export interface OAuthAppRow { clientId: string; /** SHA-256 hex of the client secret. ABSENT for public clients (PKCE). */ clientSecretHash?: string; name: string; logoUrl?: string; redirectUris: string[]; scopes: string[]; grantTypes: string[]; createdAt: string; } /** A loaded app + its owning workspace (for token issuance). */ export interface LoadedApp { clientId: string; workspaceId: string; name: string; logoUrl?: string; redirectUris: string[]; scopes: string[]; clientSecretHash?: string; } /** The result of `GET /oauth/authorize` — the controller renders/redirects. */ export interface AuthorizeDecision { type: "login" | "consent" | "error"; /** For type "error": the HTTP status (RFC 6749: 400 invalid_request, 401 unauthorized_client, 403 access_denied). */ status?: number; /** For type "error": the RFC 6749 error code. */ error?: string; description?: string; /** For type "login": the /login?next=… URL to redirect to. */ nextUrl?: string; /** * For type "consent": a short-lived SIGNED consent token (CSRF nonce). * The consent form echoes it in a hidden field; `POST /oauth/authorize` * verifies it before issuing a code. It binds the RE-RESOLVED user (sub) * to the exact authorize request (client, redirect_uri, scopes, PKCE * challenge) for 10 minutes — a form posted from another origin (or a * stale page) cannot mint a code, because the server re-validates every * parameter against the signed payload, never the form. */ consentToken?: string; /** For type "consent": the data the consent screen needs. */ userId?: string; app?: { name: string; logoUrl?: string; clientId: string }; requestedScopes?: string[]; redirectUri?: string; state?: string; codeChallenge?: string; codeChallengeMethod?: string; } /** The signed consent token's claim kind (distinct from access/session). */ const CONSENT_KIND = "oauth_consent"; /** Consent-token lifetime: the consent screen is a 10-minute window. */ const CONSENT_TTL_SECONDS = 600; /** The authorization-code window from env (`OAUTH_CODE_TTL`, default "10m"). */ const codeTtlSeconds = (): number => parseTtl(loadEnv().OAUTH_CODE_TTL); @Injectable() export class OauthService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService, @Inject(AuditService) private readonly audit: AuditService ) {} private redisClient() { return this.redis.getRedis(); } private refreshTtlSeconds(): number { return parseTtl(loadEnv().REFRESH_TOKEN_TTL); } // ============================================================ // App registration (Integration provider="oauth") // ============================================================ private async requireAdmin(workspaceId: string, userId: string) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); } async listApps(workspaceId: string, userId: string) { await this.requireAdmin(workspaceId, userId); const rows = await this.prisma.integration.findMany({ where: { workspaceId, provider: "oauth" }, orderBy: { createdAt: "asc" } }); return { data: rows.map((r) => this.publicAppView(r)) }; } async createApp( workspaceId: string, userId: string, input: { name: string; logoUrl?: string; redirectUris: string[]; scopes: string[]; public?: boolean }, req: Request ) { await this.requireAdmin(workspaceId, userId); for (const uri of input.redirectUris) { const u = new URL(uri); // throws on malformed if (u.protocol !== "http:" && u.protocol !== "https:") throw ApiError.validation("redirectUris must be http(s).", [{ field: "redirectUris", issue: "protocol" }]); } const scopes = input.scopes.filter((s) => (OAUTH_SCOPES as readonly string[]).includes(s)); if (scopes.length === 0) throw ApiError.validation("App must offer at least one valid scope.", [{ field: "scopes", issue: "no valid scopes" }]); const isPublic = input.public ?? false; const clientId = generateId("cli"); let clientSecret: string | undefined; let clientSecretHash: string | undefined; if (!isPublic) { clientSecret = `cli_sec_${randomBytes(24).toString("hex")}`; clientSecretHash = sha256hex(clientSecret); } const app: OAuthAppRow = { clientId, ...(clientSecretHash ? { clientSecretHash } : {}), name: input.name, ...(input.logoUrl ? { logoUrl: input.logoUrl } : {}), redirectUris: input.redirectUris, scopes, grantTypes: ["authorization_code", "refresh_token"], createdAt: new Date().toISOString() }; const row = await this.prisma.integration.create({ data: { workspaceId, name: input.name, provider: "oauth", config: { oauthApp: app } as object, status: "active", createdById: userId } }); await this.audit.audit(req, req?.auth, { action: "oauth.app_created", resourceType: "oauth_app", resourceId: clientId, workspaceId, metadata: { name: input.name, public: isPublic, scopes } }); return { id: row.id, clientId, name: input.name, ...(clientSecret ? { clientSecret } : {}), public: isPublic, redirectUris: input.redirectUris, scopes, createdAt: row.createdAt }; } async updateApp( workspaceId: string, userId: string, clientId: string, patch: { name?: string; logoUrl?: string | null; redirectUris?: string[]; scopes?: string[] }, req: Request ) { await this.requireAdmin(workspaceId, userId); const row = await this.findAppRow(workspaceId, clientId); if (!row) throw ApiError.notFound("App not found."); const app = (row.config as unknown as { oauthApp: OAuthAppRow }).oauthApp; if (patch.name !== undefined) app.name = patch.name; if (patch.logoUrl !== undefined) { if (patch.logoUrl === null) delete app.logoUrl; else app.logoUrl = patch.logoUrl; } if (patch.redirectUris !== undefined) { for (const uri of patch.redirectUris) { const u = new URL(uri); if (u.protocol !== "http:" && u.protocol !== "https:") throw ApiError.validation("redirectUris must be http(s).", [{ field: "redirectUris", issue: "protocol" }]); } app.redirectUris = patch.redirectUris; } if (patch.scopes !== undefined) { const scopes = patch.scopes.filter((s) => (OAUTH_SCOPES as readonly string[]).includes(s)); if (scopes.length === 0) throw ApiError.validation("App must offer at least one valid scope.", [{ field: "scopes", issue: "no valid scopes" }]); app.scopes = scopes; } const updated = await this.prisma.integration.update({ where: { id: row.id }, data: { config: { oauthApp: app } as object, name: app.name } }); await this.audit.audit(req, req?.auth, { action: "oauth.app_updated", resourceType: "oauth_app", resourceId: clientId, workspaceId, metadata: { name: app.name } }); return this.publicAppView(updated); } async deleteApp(workspaceId: string, userId: string, clientId: string, req: Request) { await this.requireAdmin(workspaceId, userId); const row = await this.findAppRow(workspaceId, clientId); if (!row) throw ApiError.notFound("App not found."); await this.prisma.integration.delete({ where: { id: row.id } }); // Drain every in-flight credential of this client: un-exchanged // authorization codes AND all refresh tokens (+ their families). Best- // effort per key — a partial drain still leaves the app itself gone // (token issuance re-loads the app and 400s), but the short TTLs make a // straggler worthless. try { const codes = await this.redisClient().smembers(clientCodesKey(clientId)); if (codes.length) await this.redisClient().del(...codes.map((c) => codeKey(c))); const hashes = await this.redisClient().smembers(clientRtSetKey(clientId)); for (const h of hashes) { try { const metaRaw = await this.redisClient().get(`oauth:family-meta:${h}`); if (metaRaw) { const meta = JSON.parse(metaRaw) as { familyId: string; userId: string }; await this.revokeFamily(meta.familyId, meta.userId, clientId, "app_deleted", req); } } catch { /* per-key best-effort */ } await this.redisClient().del(rtKey(h)); } } catch { /* app row is already gone — the drain is an optimization */ } await this.redisClient().del(clientCodesKey(clientId), clientRtSetKey(clientId)); await this.audit.audit(req, req?.auth, { action: "oauth.app_deleted", resourceType: "oauth_app", resourceId: clientId, workspaceId, metadata: {} }); return { deleted: true, clientId }; } /** Public client metadata (name, logo, offered scopes). null if unknown. */ async clientMetadata(clientId: string) { const app = await this.loadAppAnyWorkspace(clientId); return app ? { clientId: app.clientId, name: app.name, logoUrl: app.logoUrl ?? null, scopes: app.scopes } : null; } private publicAppView(row: { id: string; createdAt: Date; config: unknown }) { const app = (row.config as unknown as { oauthApp: OAuthAppRow }).oauthApp; return { id: row.id, clientId: app.clientId, name: app.name, logoUrl: app.logoUrl ?? null, public: !app.clientSecretHash, redirectUris: app.redirectUris, scopes: app.scopes, grantTypes: app.grantTypes, createdAt: row.createdAt }; } private async findAppRow(workspaceId: string, clientId: string) { const rows = await this.prisma.integration.findMany({ where: { workspaceId, provider: "oauth" } }); return rows.find((r) => (r.config as unknown as { oauthApp: OAuthAppRow }).oauthApp?.clientId === clientId) ?? null; } private async loadAppAnyWorkspace(clientId: string): Promise { const rows = await this.prisma.integration.findMany({ where: { provider: "oauth" } }); for (const r of rows) { const app = (r.config as unknown as { oauthApp: OAuthAppRow }).oauthApp; if (app?.clientId === clientId) { return { clientId: app.clientId, workspaceId: r.workspaceId, name: app.name, logoUrl: app.logoUrl, redirectUris: app.redirectUris, scopes: app.scopes, clientSecretHash: app.clientSecretHash }; } } return null; } // ============================================================ // Authorize // ============================================================ /** * Read-only session check from the httpOnly refresh cookie (does NOT * rotate — a consent-page GET must not advance the user's login rotation * chain). Returns the logged-in userId or null. */ private async cookieUser(cookieToken: string | undefined): Promise { if (!cookieToken) return null; const row = await this.prisma.refreshToken.findUnique({ where: { tokenHash: sha256hex(cookieToken) }, select: { userId: true, revokedAt: true, expiresAt: true } }); if (!row || row.revokedAt || row.expiresAt.getTime() < Date.now()) return null; return row.userId; } /** Decode a user Bearer JWT to its userId (null if absent/invalid). */ private bearerUserId(token: string | undefined): string | null { if (!token) return null; try { const claims = verifyToken(token, loadEnv().JWT_SECRET); return typeof claims.sub === "string" && claims.type !== "oauth" ? claims.sub : null; } catch { return null; } } async authorize( params: Record, cookieToken: string | undefined, bearerToken: string | undefined, /** The API origin (`scheme://host[:port]`) — used to build an ABSOLUTE * login-resume URL (the flow crosses API → web /login → back to the * API authorize endpoint, so `next` must be absolute). */ apiOrigin: string ): Promise { if (params.response_type !== "code") { return { type: "error", status: 400, error: "unsupported_response_type", description: "response_type must be 'code'." }; } const clientId = params.client_id; if (!clientId) { return { type: "error", status: 400, error: "invalid_request", description: "client_id is required." }; } const app = await this.loadAppAnyWorkspace(clientId); if (!app) { return { type: "error", status: 401, error: "unauthorized_client", description: "Unknown client." }; } const redirectUri = params.redirect_uri; // EXACT match against the registered allow-list — open-redirect protection. if (!redirectUri || !app.redirectUris.includes(redirectUri)) { return { type: "error", status: 400, error: "invalid_request", description: "redirect_uri does not match a registered allow-list entry." }; } const requested = parseScopeList(params.scope); for (const s of requested) { if (!app.scopes.includes(s)) { return { type: "error", status: 400, error: "invalid_scope", description: `Scope '${s}' is not offered by this client.` }; } } const isPublic = !app.clientSecretHash; if (isPublic) { if (!params.code_challenge || params.code_challenge_method !== "S256") { return { type: "error", status: 400, error: "invalid_request", description: "Public clients must use PKCE (code_challenge + code_challenge_method=S256)." }; } } // Resolve the user: a valid user Bearer JWT wins, else the login cookie. const userId = this.bearerUserId(bearerToken) ?? (await this.cookieUser(cookieToken)); if (!userId) { // Resume the FULL authorize URL after login (the web login page honors // ?next=). BOTH hops are cross-origin, so both URLs must be ABSOLUTE: // - `nextUrl` → the WEB /login page (a relative /login in an API 302 // would resolve against the API origin and 404); // - `next` → the API /oauth/authorize endpoint (the login page's // resolveNext allows the API origin — see apps/web login page). const qs = new URLSearchParams(); for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, v); const next = `${apiOrigin}/api/v1/oauth/authorize?${qs.toString()}`; const webOrigin = loadEnv().WEB_ORIGIN.replace(/\/$/, ""); return { type: "login", nextUrl: `${webOrigin}/login?next=${encodeURIComponent(next)}` }; } return { type: "consent", userId, app: { name: app.name, logoUrl: app.logoUrl, clientId: app.clientId }, requestedScopes: requested, redirectUri, state: params.state, codeChallenge: params.code_challenge, codeChallengeMethod: params.code_challenge_method, consentToken: this.signConsentToken(userId, { clientId: app.clientId, redirectUri, scopes: serializeScopeList(requested), codeChallenge: params.code_challenge ?? null, codeChallengeMethod: params.code_challenge_method ?? null }) }; } /** * Sign the consent CSRF token: a short-lived JWT (`kind: "oauth_consent"`) * binding the RE-RESOLVED user to the exact authorize request. `sub` is * the user, `v` pins the request parameters — verified with a constant- * time comparison in {@link verifyConsentToken}. */ signConsentToken(userId: string, v: { clientId: string; redirectUri: string; scopes: string; codeChallenge: string | null; codeChallengeMethod: string | null }): string { return signToken({ sub: userId, kind: CONSENT_KIND, v }, CONSENT_TTL_SECONDS, loadEnv().JWT_SECRET); } /** * Consent CSRF check for `POST /oauth/authorize`: the token the consent * form echoed back must (1) verify against the server secret, (2) be a * `kind: "oauth_consent"` token whose `sub` is the RE-RESOLVED user (the * form's identity fields are never trusted), and (3) bind the SAME * request parameters that were just re-validated. Any mismatch → the * posted form was not rendered for this session → refuse (400 page). */ verifyConsentToken( consentToken: string | undefined, userId: string, v: { clientId: string; redirectUri: string; scopes: string; codeChallenge: string | null; codeChallengeMethod: string | null } ): boolean { if (!consentToken) return false; let claims; try { claims = verifyToken(consentToken, loadEnv().JWT_SECRET); } catch { return false; } if (claims.kind !== CONSENT_KIND || claims.sub !== userId) return false; const got = claims.v as typeof v | undefined; if (!got) return false; const same = (a: string | null, b: string | null) => a === b; return ( got.clientId === v.clientId && got.redirectUri === v.redirectUri && got.scopes === v.scopes && same(got.codeChallenge, v.codeChallenge) && same(got.codeChallengeMethod, v.codeChallengeMethod) ); } /** Issue the code and return the final redirect Location (approve). */ async approveCode( input: { clientId: string; userId: string; scopes: string[]; redirectUri: string; state?: string; codeChallenge?: string; codeChallengeMethod?: string; }, req: Request ): Promise { const app = await this.loadAppAnyWorkspace(input.clientId); if (!app) throw ApiError.unauthorized("Unknown client.", [{ reason: "invalid_client" }]); const code = generateId("oc"); const payload = { clientId: input.clientId, userId: input.userId, workspaceId: app.workspaceId, scopes: input.scopes, codeChallenge: input.codeChallenge ?? null, codeChallengeMethod: input.codeChallengeMethod ?? null, redirectUri: input.redirectUri }; await this.redisClient().set(codeKey(code), JSON.stringify(payload), "EX", codeTtlSeconds()); // Track the code per client so `DELETE …/oauth/apps/:clientId` can drain // in-flight codes (task: "DELETE revokes app + in-flight codes/tokens"). await this.redisClient().sadd(clientCodesKey(input.clientId), code); await this.redisClient().expire(clientCodesKey(input.clientId), codeTtlSeconds()); await this.audit.audit(req, req?.auth, { action: "oauth.code_issued", resourceType: "user", resourceId: input.userId, workspaceId: app.workspaceId, metadata: { clientId: input.clientId, scopes: input.scopes } }); const sep = input.redirectUri.includes("?") ? "&" : "?"; let location = `${input.redirectUri}${sep}code=${encodeURIComponent(code)}`; if (input.state !== undefined) location += `&state=${encodeURIComponent(input.state)}`; return location; } async denyRedirect(redirectUri: string, state?: string): Promise { const sep = redirectUri.includes("?") ? "&" : "?"; let location = `${redirectUri}${sep}error=access_denied`; if (state !== undefined) location += `&state=${encodeURIComponent(state)}`; return location; } // ============================================================ // Token // ============================================================ private parseClientAuth(req: Request, body: Record): { clientId?: string; clientSecret?: string } { const header = req.headers.authorization; if (header && header.startsWith("Basic ")) { const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); const idx = decoded.indexOf(":"); if (idx !== -1) return { clientId: decodeURIComponent(decoded.slice(0, idx)), clientSecret: decodeURIComponent(decoded.slice(idx + 1)) }; } return { clientId: typeof body.client_id === "string" ? body.client_id : undefined, clientSecret: typeof body.client_secret === "string" ? body.client_secret : undefined }; } private async authenticateClient(app: LoadedApp, clientId: string | undefined, clientSecret: string | undefined): Promise { if (!clientId || clientId !== app.clientId) { throw new ApiError("INVALID_CLIENT", 400, "Client authentication failed.", [{ reason: "invalid_client" }]); } if (app.clientSecretHash) { if (!clientSecret) throw ApiError.unauthorized("Client secret required.", [{ reason: "invalid_client" }]); if (sha256hex(clientSecret) !== app.clientSecretHash) { throw new ApiError("INVALID_CLIENT", 400, "Client authentication failed.", [{ reason: "invalid_client" }]); } } } private async issueOAuthTokens( app: LoadedApp, userId: string, scopes: string[], familyId: string ): Promise<{ access_token: string; refresh_token: string; scope: string; expires_in: number }> { const env = loadEnv(); const accessTtl = parseTtl(env.OAUTH_ACCESS_TOKEN_TTL ?? "900s"); // Docs §8.2 claims: sub, client_id, scope, type:"oauth", jti. const accessToken = signToken( { sub: userId, client_id: app.clientId, scope: serializeScopeList(scopes), type: "oauth", jti: uuid() }, accessTtl, env.JWT_SECRET ); const refreshPlain = `rft_${randomBytes(32).toString("hex")}`; const hash = sha256hex(refreshPlain); await this.redisClient().set(rtKey(hash), JSON.stringify({ familyId, clientId: app.clientId, userId, scopes }), "EX", this.refreshTtlSeconds()); // Bookkeeping for classification + drain (reuse kill, app delete): // - client rt-set: every refresh hash this client holds (drained on // app delete); // - per-hash family meta: the family identity of a DEAD token (so a // replay can be classified + the kill audited). await this.redisClient().sadd(clientRtSetKey(app.clientId), hash); await this.redisClient().expire(clientRtSetKey(app.clientId), this.refreshTtlSeconds()); await this.redisClient().set(`oauth:family-meta:${hash}`, JSON.stringify({ familyId, clientId: app.clientId, userId }), "EX", this.refreshTtlSeconds()); return { access_token: accessToken, refresh_token: refreshPlain, scope: serializeScopeList(scopes), expires_in: accessTtl }; } async token(req: Request, body: Record): Promise> { const grantType = typeof body.grant_type === "string" ? body.grant_type : ""; const { clientId, clientSecret } = this.parseClientAuth(req, body); if (grantType === "authorization_code") { const code = typeof body.code === "string" ? body.code : ""; const verifier = typeof body.code_verifier === "string" ? body.code_verifier : ""; const redirectUri = typeof body.redirect_uri === "string" ? body.redirect_uri : ""; // GETDEL = single-use: a second use (or a replay) gets nothing. const raw = await this.redisClient().getdel(codeKey(code)); if (!raw) throw new ApiError("INVALID_GRANT", 400, "Authorization code is invalid or expired.", [{ reason: "invalid_grant" }]); const stored = JSON.parse(raw) as { clientId: string; userId: string; workspaceId: string; scopes: string[]; codeChallenge: string | null; codeChallengeMethod: string | null; redirectUri: string; }; const app = await this.loadAppAnyWorkspace(stored.clientId); if (!app) throw new ApiError("INVALID_GRANT", 400, "Client no longer exists.", [{ reason: "invalid_grant" }]); await this.authenticateClient(app, clientId, clientSecret); if (stored.codeChallenge) { if (stored.codeChallengeMethod !== "S256" || !verifier || pkceChallenge(verifier) !== stored.codeChallenge) { throw new ApiError("INVALID_GRANT", 400, "PKCE verification failed.", [{ reason: "invalid_grant" }]); } } if (redirectUri && redirectUri !== stored.redirectUri) { throw new ApiError("INVALID_GRANT", 400, "redirect_uri does not match the authorize step.", [{ reason: "invalid_grant" }]); } const tokens = await this.issueOAuthTokens(app, stored.userId, stored.scopes, uuid()); await this.audit.audit(req, undefined, { action: "oauth.token_issued", resourceType: "user", resourceId: stored.userId, workspaceId: stored.workspaceId, metadata: { clientId: stored.clientId, scopes: stored.scopes, grant: "authorization_code" } }); return { access_token: tokens.access_token, token_type: "Bearer", expires_in: tokens.expires_in, refresh_token: tokens.refresh_token, scope: tokens.scope }; } if (grantType === "refresh_token") { const presented = typeof body.refresh_token === "string" ? body.refresh_token : ""; if (!presented) throw new ApiError("INVALID_GRANT", 400, "refresh_token is required.", [{ reason: "invalid_grant" }]); const hash = sha256hex(presented); // READ (not GETDEL) — classification must not consume the token. const raw = await this.redisClient().get(rtKey(hash)); if (!raw) { // The presented token is DEAD (rotated out, revoked, expired, or // never valid). Classification (TASK-0129 acceptance criterion // "refresh rotation + reuse detection"; security-architecture // §1.2 lineage): // (a) KNOWN dead token, family STILL ACTIVE — the presented value // was rotated out (or consumed) and its value is back in // circulation → REUSE (token theft). Containment: KILL the // whole family (every remaining token dies) + security audit // `oauth.token_revoked { reason: "reuse_detected" }`. // (b) Family ALREADY killed (reuse / RFC 7009 revoke / app // delete) → plain INVALID_GRANT; the kill and its audit // already happened — do not double-report. // (c) UNKNOWN token (no metadata — never issued, TTL-expired, or // pre-dating this key) → plain INVALID_GRANT. // Divergence from the LOGIN flow's §1.2 stale-retry nuance (a // rotated-out token that is the direct parent of the live // successor is a benign stale retry there, via the replacedBy // chain): the OAuth task spec mandates the conservative containment // — "reusing the old refresh after rotation → whole family // revoked" — and a false kill here costs only a re-consent. // Recorded as C-13.7e in docs/tasks/phase-13-api.md. OAuth // families are per-(user, client), so the kill can never // collateral-revoke the user's LOGIN sessions (DB refresh chain). let meta: { familyId: string; clientId: string; userId: string } | null = null; try { const m = await this.redisClient().get(`oauth:family-meta:${hash}`); if (m) meta = JSON.parse(m); } catch { /* metadata is advisory — fall through to the generic 400 */ } if (meta) { const familyRevoked = (await this.redisClient().get(familyKey(meta.familyId))) === "revoked"; if (!familyRevoked) { // (a) REUSE of a rotated-out token → family kill + audit. await this.revokeFamily(meta.familyId, meta.userId, meta.clientId, "reuse_detected", req); } } throw new ApiError("INVALID_GRANT", 400, "Refresh token is invalid or expired.", [{ reason: "invalid_grant" }]); } const rt = JSON.parse(raw) as { familyId: string; clientId: string; userId: string; scopes: string[] }; const familyRevoked = (await this.redisClient().get(familyKey(rt.familyId))) === "revoked"; if (familyRevoked) { // Family was killed (reuse / revoke_endpoint) and this token is a // zombie → plain 400 (the kill + its audit already happened). throw new ApiError("INVALID_GRANT", 400, "Refresh token is invalid or expired.", [{ reason: "invalid_grant" }]); } // Live token → rotate: consume it (DELETE — the single-use step), drop // it from the family's live set, and issue a successor in the SAME // family. A racing loser (two clients on the same old token) fails the // DELETE → plain 400 stale retry; the winner's successor stays live. const deleted = await this.redisClient().del(rtKey(hash)); if (deleted !== 1) { throw new ApiError("INVALID_GRANT", 400, "Refresh token already rotated.", [{ reason: "invalid_grant" }]); } const app = await this.loadAppAnyWorkspace(rt.clientId); if (!app) throw new ApiError("INVALID_GRANT", 400, "Client no longer exists.", [{ reason: "invalid_grant" }]); await this.authenticateClient(app, clientId, clientSecret); const tokens = await this.issueOAuthTokens(app, rt.userId, rt.scopes, rt.familyId); await this.audit.audit(req, undefined, { action: "oauth.token_issued", resourceType: "user", resourceId: rt.userId, metadata: { clientId: rt.clientId, scopes: rt.scopes, grant: "refresh_token" } }); return { access_token: tokens.access_token, token_type: "Bearer", expires_in: tokens.expires_in, refresh_token: tokens.refresh_token, scope: tokens.scope }; } throw new ApiError("INVALID_GRANT", 400, `Unsupported grant_type '${grantType || "(none)"}'.`, [{ reason: "invalid_grant" }]); } // ============================================================ // Revoke (RFC 7009 — always 200, never 401/404) // ============================================================ /** * RFC 7009 revocation. `req` (optional) is forwarded to the audit: an * explicit endpoint revoke IS a security operation (rule 27) and must be * recorded — `oauth.token_revoked { reason: "revoked" }`. */ async revoke(token: string | undefined, hint: string | undefined, req?: Request): Promise { if (!token) return; try { if (hint === "refresh_token" || !hint) { const hash = sha256hex(token); const raw = await this.redisClient().get(rtKey(hash)); if (raw) { const rt = JSON.parse(raw) as { familyId: string; clientId: string; userId: string }; // Consume the token and KILL its family — the RFC 7009 semantics // for a refresh token: the whole rotation chain stops, and any // other token in the family dies with it (reuse-detection stays // armed for replays of the old value via the family flag). await this.redisClient().del(rtKey(hash)); await this.revokeFamily(rt.familyId, rt.userId, rt.clientId, "revoked", req); } } if (hint === "access_token" || !hint) { // Decode the JWT to get the jti (no full verification — revoke is // best-effort and must never 401 an unknown token). The denylist // entry lives only as long as the token could: remaining lifetime // capped at the access TTL (the JWT can't outlive its exp). const parts = token.split("."); if (parts.length === 3) { try { const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); if (payload.jti) { const remaining = typeof payload.exp === "number" ? payload.exp - Math.floor(Date.now() / 1000) : 0; const ttl = Math.max(1, Math.min(remaining > 0 ? remaining : 1, parseTtl(loadEnv().OAUTH_ACCESS_TOKEN_TTL))); await this.redisClient().set(jtiKey(payload.jti), "1", "EX", ttl); } } catch { /* not a decodable JWT → ignore */ } } } } catch { /* RFC 7009: never surface a failure */ } } /** * Kill a refresh-token family (reuse attack or RFC 7009 revocation): * set the family's revoked flag (so every remaining token in it is dead * on presentation), drop its live-token set, and audit the security * event (never tokens/hashes — only the family + client identity). * Best-effort: Redis failure must not break the 400 response. */ private async revokeFamily( familyId: string, userId: string, clientId: string, reason: string, req: Request | undefined ): Promise { try { await this.redisClient().set(familyKey(familyId), "revoked", "EX", this.refreshTtlSeconds()); } catch { /* the 400 above is what the client sees */ } if (req) { await this.audit .audit(req, undefined, { action: "oauth.token_revoked", resourceType: "user", resourceId: userId, metadata: { clientId, reason, familyId } }) .catch(() => undefined); } } /** Guard helper: is this access-token jti on the denylist? (token-keys is fail-open.) */ isAccessRevoked(jti: string | undefined): Promise { return isJtiRevoked(this.redisClient(), jti); } }