1import { Inject, Injectable } from "@nestjs/common";
2import type { Request } from "express";
3import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
4import { generateId } from "@factory/shared";
5import { PrismaService } from "../../common/prisma.service.js";
6import { RedisService } from "../../common/redis.service.js";
7import { AuditService } from "../../common/audit.service.js";
8import { ApiError } from "../../common/errors.js";
9import { assertRole } from "../../lib/authorize.js";
10import { loadEnv } from "../../config/env.js";
11import { signToken, verifyToken, parseTtl, TokenError } from "../../lib/jwt.js";
12import { sha256hex } from "../../lib/crypto.js";
13import { OAUTH_SCOPES, parseScopeList, serializeScopeList, pkceChallenge } from "./scopes.js";
14import { codeKey, rtKey, familyKey, jtiKey, isJtiRevoked, clientCodesKey, clientRtSetKey } from "./token-keys.js";
15import { v4 as uuid } from "uuid";
16
17
42
43
44export interface OAuthAppRow {
45 clientId: string;
46
47 clientSecretHash?: string;
48 name: string;
49 logoUrl?: string;
50 redirectUris: string[];
51 scopes: string[];
52 grantTypes: string[];
53 createdAt: string;
54}
55
56
57export interface LoadedApp {
58 clientId: string;
59 workspaceId: string;
60 name: string;
61 logoUrl?: string;
62 redirectUris: string[];
63 scopes: string[];
64 clientSecretHash?: string;
65}
66
67
68export interface AuthorizeDecision {
69 type: "login" | "consent" | "error";
70
71 status?: number;
72
73 error?: string;
74 description?: string;
75
76 nextUrl?: string;
77
86 consentToken?: string;
87
88 userId?: string;
89 app?: { name: string; logoUrl?: string; clientId: string };
90 requestedScopes?: string[];
91 redirectUri?: string;
92 state?: string;
93 codeChallenge?: string;
94 codeChallengeMethod?: string;
95}
96
97
98const CONSENT_KIND = "oauth_consent";
99
100const CONSENT_TTL_SECONDS = 600;
101
102
103const codeTtlSeconds = (): number => parseTtl(loadEnv().OAUTH_CODE_TTL);
104
105@Injectable()
106export class OauthService {
107 constructor(
108 @Inject(PrismaService) private readonly prisma: PrismaService,
109 @Inject(RedisService) private readonly redis: RedisService,
110 @Inject(AuditService) private readonly audit: AuditService
111 ) {}
112
113 private redisClient() {
114 return this.redis.getRedis();
115 }
116 private refreshTtlSeconds(): number {
117 return parseTtl(loadEnv().REFRESH_TOKEN_TTL);
118 }
119
120
121
122
123
124 private async requireAdmin(workspaceId: string, userId: string) {
125 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
126 }
127
128 async listApps(workspaceId: string, userId: string) {
129 await this.requireAdmin(workspaceId, userId);
130 const rows = await this.prisma.integration.findMany({
131 where: { workspaceId, provider: "oauth" },
132 orderBy: { createdAt: "asc" }
133 });
134 return { data: rows.map((r) => this.publicAppView(r)) };
135 }
136
137 async createApp(
138 workspaceId: string,
139 userId: string,
140 input: { name: string; logoUrl?: string; redirectUris: string[]; scopes: string[]; public?: boolean },
141 req: Request
142 ) {
143 await this.requireAdmin(workspaceId, userId);
144 for (const uri of input.redirectUris) {
145 const u = new URL(uri);
146 if (u.protocol !== "http:" && u.protocol !== "https:") throw ApiError.validation("redirectUris must be http(s).", [{ field: "redirectUris", issue: "protocol" }]);
147 }
148 const scopes = input.scopes.filter((s) => (OAUTH_SCOPES as readonly string[]).includes(s));
149 if (scopes.length === 0) throw ApiError.validation("App must offer at least one valid scope.", [{ field: "scopes", issue: "no valid scopes" }]);
150 const isPublic = input.public ?? false;
151 const clientId = generateId("cli");
152 let clientSecret: string | undefined;
153 let clientSecretHash: string | undefined;
154 if (!isPublic) {
155 clientSecret = `cli_sec_${randomBytes(24).toString("hex")}`;
156 clientSecretHash = sha256hex(clientSecret);
157 }
158 const app: OAuthAppRow = {
159 clientId,
160 ...(clientSecretHash ? { clientSecretHash } : {}),
161 name: input.name,
162 ...(input.logoUrl ? { logoUrl: input.logoUrl } : {}),
163 redirectUris: input.redirectUris,
164 scopes,
165 grantTypes: ["authorization_code", "refresh_token"],
166 createdAt: new Date().toISOString()
167 };
168 const row = await this.prisma.integration.create({
169 data: {
170 workspaceId,
171 name: input.name,
172 provider: "oauth",
173 config: { oauthApp: app } as object,
174 status: "active",
175 createdById: userId
176 }
177 });
178 await this.audit.audit(req, req?.auth, {
179 action: "oauth.app_created",
180 resourceType: "oauth_app",
181 resourceId: clientId,
182 workspaceId,
183 metadata: { name: input.name, public: isPublic, scopes }
184 });
185 return {
186 id: row.id,
187 clientId,
188 name: input.name,
189 ...(clientSecret ? { clientSecret } : {}),
190 public: isPublic,
191 redirectUris: input.redirectUris,
192 scopes,
193 createdAt: row.createdAt
194 };
195 }
196
197 async updateApp(
198 workspaceId: string,
199 userId: string,
200 clientId: string,
201 patch: { name?: string; logoUrl?: string | null; redirectUris?: string[]; scopes?: string[] },
202 req: Request
203 ) {
204 await this.requireAdmin(workspaceId, userId);
205 const row = await this.findAppRow(workspaceId, clientId);
206 if (!row) throw ApiError.notFound("App not found.");
207 const app = (row.config as unknown as { oauthApp: OAuthAppRow }).oauthApp;
208 if (patch.name !== undefined) app.name = patch.name;
209 if (patch.logoUrl !== undefined) {
210 if (patch.logoUrl === null) delete app.logoUrl;
211 else app.logoUrl = patch.logoUrl;
212 }
213 if (patch.redirectUris !== undefined) {
214 for (const uri of patch.redirectUris) {
215 const u = new URL(uri);
216 if (u.protocol !== "http:" && u.protocol !== "https:") throw ApiError.validation("redirectUris must be http(s).", [{ field: "redirectUris", issue: "protocol" }]);
217 }
218 app.redirectUris = patch.redirectUris;
219 }
220 if (patch.scopes !== undefined) {
221 const scopes = patch.scopes.filter((s) => (OAUTH_SCOPES as readonly string[]).includes(s));
222 if (scopes.length === 0) throw ApiError.validation("App must offer at least one valid scope.", [{ field: "scopes", issue: "no valid scopes" }]);
223 app.scopes = scopes;
224 }
225 const updated = await this.prisma.integration.update({
226 where: { id: row.id },
227 data: { config: { oauthApp: app } as object, name: app.name }
228 });
229 await this.audit.audit(req, req?.auth, {
230 action: "oauth.app_updated",
231 resourceType: "oauth_app",
232 resourceId: clientId,
233 workspaceId,
234 metadata: { name: app.name }
235 });
236 return this.publicAppView(updated);
237 }
238
239 async deleteApp(workspaceId: string, userId: string, clientId: string, req: Request) {
240 await this.requireAdmin(workspaceId, userId);
241 const row = await this.findAppRow(workspaceId, clientId);
242 if (!row) throw ApiError.notFound("App not found.");
243 await this.prisma.integration.delete({ where: { id: row.id } });
244
245
246
247
248
249 try {
250 const codes = await this.redisClient().smembers(clientCodesKey(clientId));
251 if (codes.length) await this.redisClient().del(...codes.map((c) => codeKey(c)));
252 const hashes = await this.redisClient().smembers(clientRtSetKey(clientId));
253 for (const h of hashes) {
254 try {
255 const metaRaw = await this.redisClient().get(`oauth:family-meta:${h}`);
256 if (metaRaw) {
257 const meta = JSON.parse(metaRaw) as { familyId: string; userId: string };
258 await this.revokeFamily(meta.familyId, meta.userId, clientId, "app_deleted", req);
259 }
260 } catch {
261
262 }
263 await this.redisClient().del(rtKey(h));
264 }
265 } catch {
266
267 }
268 await this.redisClient().del(clientCodesKey(clientId), clientRtSetKey(clientId));
269 await this.audit.audit(req, req?.auth, {
270 action: "oauth.app_deleted",
271 resourceType: "oauth_app",
272 resourceId: clientId,
273 workspaceId,
274 metadata: {}
275 });
276 return { deleted: true, clientId };
277 }
278
279
280 async clientMetadata(clientId: string) {
281 const app = await this.loadAppAnyWorkspace(clientId);
282 return app ? { clientId: app.clientId, name: app.name, logoUrl: app.logoUrl ?? null, scopes: app.scopes } : null;
283 }
284
285 private publicAppView(row: { id: string; createdAt: Date; config: unknown }) {
286 const app = (row.config as unknown as { oauthApp: OAuthAppRow }).oauthApp;
287 return {
288 id: row.id,
289 clientId: app.clientId,
290 name: app.name,
291 logoUrl: app.logoUrl ?? null,
292 public: !app.clientSecretHash,
293 redirectUris: app.redirectUris,
294 scopes: app.scopes,
295 grantTypes: app.grantTypes,
296 createdAt: row.createdAt
297 };
298 }
299
300 private async findAppRow(workspaceId: string, clientId: string) {
301 const rows = await this.prisma.integration.findMany({ where: { workspaceId, provider: "oauth" } });
302 return rows.find((r) => (r.config as unknown as { oauthApp: OAuthAppRow }).oauthApp?.clientId === clientId) ?? null;
303 }
304
305 private async loadAppAnyWorkspace(clientId: string): Promise<LoadedApp | null> {
306 const rows = await this.prisma.integration.findMany({ where: { provider: "oauth" } });
307 for (const r of rows) {
308 const app = (r.config as unknown as { oauthApp: OAuthAppRow }).oauthApp;
309 if (app?.clientId === clientId) {
310 return {
311 clientId: app.clientId,
312 workspaceId: r.workspaceId,
313 name: app.name,
314 logoUrl: app.logoUrl,
315 redirectUris: app.redirectUris,
316 scopes: app.scopes,
317 clientSecretHash: app.clientSecretHash
318 };
319 }
320 }
321 return null;
322 }
323
324
325
326
327
328
333 private async cookieUser(cookieToken: string | undefined): Promise<string | null> {
334 if (!cookieToken) return null;
335 const row = await this.prisma.refreshToken.findUnique({
336 where: { tokenHash: sha256hex(cookieToken) },
337 select: { userId: true, revokedAt: true, expiresAt: true }
338 });
339 if (!row || row.revokedAt || row.expiresAt.getTime() < Date.now()) return null;
340 return row.userId;
341 }
342
343
344 private bearerUserId(token: string | undefined): string | null {
345 if (!token) return null;
346 try {
347 const claims = verifyToken(token, loadEnv().JWT_SECRET);
348 return typeof claims.sub === "string" && claims.type !== "oauth" ? claims.sub : null;
349 } catch {
350 return null;
351 }
352 }
353
354 async authorize(
355 params: Record<string, string | undefined>,
356 cookieToken: string | undefined,
357 bearerToken: string | undefined,
358
361 apiOrigin: string
362 ): Promise<AuthorizeDecision> {
363 if (params.response_type !== "code") {
364 return { type: "error", status: 400, error: "unsupported_response_type", description: "response_type must be 'code'." };
365 }
366 const clientId = params.client_id;
367 if (!clientId) {
368 return { type: "error", status: 400, error: "invalid_request", description: "client_id is required." };
369 }
370 const app = await this.loadAppAnyWorkspace(clientId);
371 if (!app) {
372 return { type: "error", status: 401, error: "unauthorized_client", description: "Unknown client." };
373 }
374 const redirectUri = params.redirect_uri;
375
376 if (!redirectUri || !app.redirectUris.includes(redirectUri)) {
377 return { type: "error", status: 400, error: "invalid_request", description: "redirect_uri does not match a registered allow-list entry." };
378 }
379 const requested = parseScopeList(params.scope);
380 for (const s of requested) {
381 if (!app.scopes.includes(s)) {
382 return { type: "error", status: 400, error: "invalid_scope", description: `Scope '${s}' is not offered by this client.` };
383 }
384 }
385 const isPublic = !app.clientSecretHash;
386 if (isPublic) {
387 if (!params.code_challenge || params.code_challenge_method !== "S256") {
388 return { type: "error", status: 400, error: "invalid_request", description: "Public clients must use PKCE (code_challenge + code_challenge_method=S256)." };
389 }
390 }
391
392 const userId = this.bearerUserId(bearerToken) ?? (await this.cookieUser(cookieToken));
393 if (!userId) {
394
395
396
397
398
399
400 const qs = new URLSearchParams();
401 for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, v);
402 const next = `${apiOrigin}/api/v1/oauth/authorize?${qs.toString()}`;
403 const webOrigin = loadEnv().WEB_ORIGIN.replace(/\/$/, "");
404 return { type: "login", nextUrl: `${webOrigin}/login?next=${encodeURIComponent(next)}` };
405 }
406 return {
407 type: "consent",
408 userId,
409 app: { name: app.name, logoUrl: app.logoUrl, clientId: app.clientId },
410 requestedScopes: requested,
411 redirectUri,
412 state: params.state,
413 codeChallenge: params.code_challenge,
414 codeChallengeMethod: params.code_challenge_method,
415 consentToken: this.signConsentToken(userId, {
416 clientId: app.clientId,
417 redirectUri,
418 scopes: serializeScopeList(requested),
419 codeChallenge: params.code_challenge ?? null,
420 codeChallengeMethod: params.code_challenge_method ?? null
421 })
422 };
423 }
424
425
431 signConsentToken(userId: string, v: { clientId: string; redirectUri: string; scopes: string; codeChallenge: string | null; codeChallengeMethod: string | null }): string {
432 return signToken({ sub: userId, kind: CONSENT_KIND, v }, CONSENT_TTL_SECONDS, loadEnv().JWT_SECRET);
433 }
434
435
443 verifyConsentToken(
444 consentToken: string | undefined,
445 userId: string,
446 v: { clientId: string; redirectUri: string; scopes: string; codeChallenge: string | null; codeChallengeMethod: string | null }
447 ): boolean {
448 if (!consentToken) return false;
449 let claims;
450 try {
451 claims = verifyToken(consentToken, loadEnv().JWT_SECRET);
452 } catch {
453 return false;
454 }
455 if (claims.kind !== CONSENT_KIND || claims.sub !== userId) return false;
456 const got = claims.v as typeof v | undefined;
457 if (!got) return false;
458 const same = (a: string | null, b: string | null) => a === b;
459 return (
460 got.clientId === v.clientId &&
461 got.redirectUri === v.redirectUri &&
462 got.scopes === v.scopes &&
463 same(got.codeChallenge, v.codeChallenge) &&
464 same(got.codeChallengeMethod, v.codeChallengeMethod)
465 );
466 }
467
468
469 async approveCode(
470 input: {
471 clientId: string;
472 userId: string;
473 scopes: string[];
474 redirectUri: string;
475 state?: string;
476 codeChallenge?: string;
477 codeChallengeMethod?: string;
478 },
479 req: Request
480 ): Promise<string> {
481 const app = await this.loadAppAnyWorkspace(input.clientId);
482 if (!app) throw ApiError.unauthorized("Unknown client.", [{ reason: "invalid_client" }]);
483 const code = generateId("oc");
484 const payload = {
485 clientId: input.clientId,
486 userId: input.userId,
487 workspaceId: app.workspaceId,
488 scopes: input.scopes,
489 codeChallenge: input.codeChallenge ?? null,
490 codeChallengeMethod: input.codeChallengeMethod ?? null,
491 redirectUri: input.redirectUri
492 };
493 await this.redisClient().set(codeKey(code), JSON.stringify(payload), "EX", codeTtlSeconds());
494
495
496 await this.redisClient().sadd(clientCodesKey(input.clientId), code);
497 await this.redisClient().expire(clientCodesKey(input.clientId), codeTtlSeconds());
498 await this.audit.audit(req, req?.auth, {
499 action: "oauth.code_issued",
500 resourceType: "user",
501 resourceId: input.userId,
502 workspaceId: app.workspaceId,
503 metadata: { clientId: input.clientId, scopes: input.scopes }
504 });
505 const sep = input.redirectUri.includes("?") ? "&" : "?";
506 let location = `${input.redirectUri}${sep}code=${encodeURIComponent(code)}`;
507 if (input.state !== undefined) location += `&state=${encodeURIComponent(input.state)}`;
508 return location;
509 }
510
511 async denyRedirect(redirectUri: string, state?: string): Promise<string> {
512 const sep = redirectUri.includes("?") ? "&" : "?";
513 let location = `${redirectUri}${sep}error=access_denied`;
514 if (state !== undefined) location += `&state=${encodeURIComponent(state)}`;
515 return location;
516 }
517
518
519
520
521
522 private parseClientAuth(req: Request, body: Record<string, unknown>): { clientId?: string; clientSecret?: string } {
523 const header = req.headers.authorization;
524 if (header && header.startsWith("Basic ")) {
525 const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
526 const idx = decoded.indexOf(":");
527 if (idx !== -1) return { clientId: decodeURIComponent(decoded.slice(0, idx)), clientSecret: decodeURIComponent(decoded.slice(idx + 1)) };
528 }
529 return {
530 clientId: typeof body.client_id === "string" ? body.client_id : undefined,
531 clientSecret: typeof body.client_secret === "string" ? body.client_secret : undefined
532 };
533 }
534
535 private async authenticateClient(app: LoadedApp, clientId: string | undefined, clientSecret: string | undefined): Promise<void> {
536 if (!clientId || clientId !== app.clientId) {
537 throw new ApiError("INVALID_CLIENT", 400, "Client authentication failed.", [{ reason: "invalid_client" }]);
538 }
539 if (app.clientSecretHash) {
540 if (!clientSecret) throw ApiError.unauthorized("Client secret required.", [{ reason: "invalid_client" }]);
541 if (sha256hex(clientSecret) !== app.clientSecretHash) {
542 throw new ApiError("INVALID_CLIENT", 400, "Client authentication failed.", [{ reason: "invalid_client" }]);
543 }
544 }
545 }
546
547 private async issueOAuthTokens(
548 app: LoadedApp,
549 userId: string,
550 scopes: string[],
551 familyId: string
552 ): Promise<{ access_token: string; refresh_token: string; scope: string; expires_in: number }> {
553 const env = loadEnv();
554 const accessTtl = parseTtl(env.OAUTH_ACCESS_TOKEN_TTL ?? "900s");
555
556 const accessToken = signToken(
557 { sub: userId, client_id: app.clientId, scope: serializeScopeList(scopes), type: "oauth", jti: uuid() },
558 accessTtl,
559 env.JWT_SECRET
560 );
561 const refreshPlain = `rft_${randomBytes(32).toString("hex")}`;
562 const hash = sha256hex(refreshPlain);
563 await this.redisClient().set(rtKey(hash), JSON.stringify({ familyId, clientId: app.clientId, userId, scopes }), "EX", this.refreshTtlSeconds());
564
565
566
567
568
569 await this.redisClient().sadd(clientRtSetKey(app.clientId), hash);
570 await this.redisClient().expire(clientRtSetKey(app.clientId), this.refreshTtlSeconds());
571 await this.redisClient().set(`oauth:family-meta:${hash}`, JSON.stringify({ familyId, clientId: app.clientId, userId }), "EX", this.refreshTtlSeconds());
572 return { access_token: accessToken, refresh_token: refreshPlain, scope: serializeScopeList(scopes), expires_in: accessTtl };
573 }
574
575 async token(req: Request, body: Record<string, unknown>): Promise<Record<string, unknown>> {
576 const grantType = typeof body.grant_type === "string" ? body.grant_type : "";
577 const { clientId, clientSecret } = this.parseClientAuth(req, body);
578
579 if (grantType === "authorization_code") {
580 const code = typeof body.code === "string" ? body.code : "";
581 const verifier = typeof body.code_verifier === "string" ? body.code_verifier : "";
582 const redirectUri = typeof body.redirect_uri === "string" ? body.redirect_uri : "";
583
584 const raw = await this.redisClient().getdel(codeKey(code));
585 if (!raw) throw new ApiError("INVALID_GRANT", 400, "Authorization code is invalid or expired.", [{ reason: "invalid_grant" }]);
586 const stored = JSON.parse(raw) as {
587 clientId: string; userId: string; workspaceId: string; scopes: string[];
588 codeChallenge: string | null; codeChallengeMethod: string | null; redirectUri: string;
589 };
590 const app = await this.loadAppAnyWorkspace(stored.clientId);
591 if (!app) throw new ApiError("INVALID_GRANT", 400, "Client no longer exists.", [{ reason: "invalid_grant" }]);
592 await this.authenticateClient(app, clientId, clientSecret);
593 if (stored.codeChallenge) {
594 if (stored.codeChallengeMethod !== "S256" || !verifier || pkceChallenge(verifier) !== stored.codeChallenge) {
595 throw new ApiError("INVALID_GRANT", 400, "PKCE verification failed.", [{ reason: "invalid_grant" }]);
596 }
597 }
598 if (redirectUri && redirectUri !== stored.redirectUri) {
599 throw new ApiError("INVALID_GRANT", 400, "redirect_uri does not match the authorize step.", [{ reason: "invalid_grant" }]);
600 }
601 const tokens = await this.issueOAuthTokens(app, stored.userId, stored.scopes, uuid());
602 await this.audit.audit(req, undefined, {
603 action: "oauth.token_issued",
604 resourceType: "user",
605 resourceId: stored.userId,
606 workspaceId: stored.workspaceId,
607 metadata: { clientId: stored.clientId, scopes: stored.scopes, grant: "authorization_code" }
608 });
609 return {
610 access_token: tokens.access_token,
611 token_type: "Bearer",
612 expires_in: tokens.expires_in,
613 refresh_token: tokens.refresh_token,
614 scope: tokens.scope
615 };
616 }
617
618 if (grantType === "refresh_token") {
619 const presented = typeof body.refresh_token === "string" ? body.refresh_token : "";
620 if (!presented) throw new ApiError("INVALID_GRANT", 400, "refresh_token is required.", [{ reason: "invalid_grant" }]);
621 const hash = sha256hex(presented);
622
623 const raw = await this.redisClient().get(rtKey(hash));
624 if (!raw) {
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648 let meta: { familyId: string; clientId: string; userId: string } | null = null;
649 try {
650 const m = await this.redisClient().get(`oauth:family-meta:${hash}`);
651 if (m) meta = JSON.parse(m);
652 } catch {
653
654 }
655 if (meta) {
656 const familyRevoked = (await this.redisClient().get(familyKey(meta.familyId))) === "revoked";
657 if (!familyRevoked) {
658
659 await this.revokeFamily(meta.familyId, meta.userId, meta.clientId, "reuse_detected", req);
660 }
661 }
662 throw new ApiError("INVALID_GRANT", 400, "Refresh token is invalid or expired.", [{ reason: "invalid_grant" }]);
663 }
664 const rt = JSON.parse(raw) as { familyId: string; clientId: string; userId: string; scopes: string[] };
665 const familyRevoked = (await this.redisClient().get(familyKey(rt.familyId))) === "revoked";
666 if (familyRevoked) {
667
668
669 throw new ApiError("INVALID_GRANT", 400, "Refresh token is invalid or expired.", [{ reason: "invalid_grant" }]);
670 }
671
672
673
674
675 const deleted = await this.redisClient().del(rtKey(hash));
676 if (deleted !== 1) {
677 throw new ApiError("INVALID_GRANT", 400, "Refresh token already rotated.", [{ reason: "invalid_grant" }]);
678 }
679 const app = await this.loadAppAnyWorkspace(rt.clientId);
680 if (!app) throw new ApiError("INVALID_GRANT", 400, "Client no longer exists.", [{ reason: "invalid_grant" }]);
681 await this.authenticateClient(app, clientId, clientSecret);
682 const tokens = await this.issueOAuthTokens(app, rt.userId, rt.scopes, rt.familyId);
683 await this.audit.audit(req, undefined, {
684 action: "oauth.token_issued",
685 resourceType: "user",
686 resourceId: rt.userId,
687 metadata: { clientId: rt.clientId, scopes: rt.scopes, grant: "refresh_token" }
688 });
689 return {
690 access_token: tokens.access_token,
691 token_type: "Bearer",
692 expires_in: tokens.expires_in,
693 refresh_token: tokens.refresh_token,
694 scope: tokens.scope
695 };
696 }
697
698 throw new ApiError("INVALID_GRANT", 400, `Unsupported grant_type '${grantType || "(none)"}'.`, [{ reason: "invalid_grant" }]);
699 }
700
701
702
703
704
709 async revoke(token: string | undefined, hint: string | undefined, req?: Request): Promise<void> {
710 if (!token) return;
711 try {
712 if (hint === "refresh_token" || !hint) {
713 const hash = sha256hex(token);
714 const raw = await this.redisClient().get(rtKey(hash));
715 if (raw) {
716 const rt = JSON.parse(raw) as { familyId: string; clientId: string; userId: string };
717
718
719
720
721 await this.redisClient().del(rtKey(hash));
722 await this.revokeFamily(rt.familyId, rt.userId, rt.clientId, "revoked", req);
723 }
724 }
725 if (hint === "access_token" || !hint) {
726
727
728
729
730 const parts = token.split(".");
731 if (parts.length === 3) {
732 try {
733 const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
734 if (payload.jti) {
735 const remaining = typeof payload.exp === "number" ? payload.exp - Math.floor(Date.now() / 1000) : 0;
736 const ttl = Math.max(1, Math.min(remaining > 0 ? remaining : 1, parseTtl(loadEnv().OAUTH_ACCESS_TOKEN_TTL)));
737 await this.redisClient().set(jtiKey(payload.jti), "1", "EX", ttl);
738 }
739 } catch {
740
741 }
742 }
743 }
744 } catch {
745
746 }
747 }
748
749
756 private async revokeFamily(
757 familyId: string,
758 userId: string,
759 clientId: string,
760 reason: string,
761 req: Request | undefined
762 ): Promise<void> {
763 try {
764 await this.redisClient().set(familyKey(familyId), "revoked", "EX", this.refreshTtlSeconds());
765 } catch {
766
767 }
768 if (req) {
769 await this.audit
770 .audit(req, undefined, {
771 action: "oauth.token_revoked",
772 resourceType: "user",
773 resourceId: userId,
774 metadata: { clientId, reason, familyId }
775 })
776 .catch(() => undefined);
777 }
778 }
779
780
781 isAccessRevoked(jti: string | undefined): Promise<boolean> {
782 return isJtiRevoked(this.redisClient(), jti);
783 }
784}
785