@codefox-inc/oauth-provider 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +28 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +5 -1
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/clientManagement.d.ts.map +1 -1
  6. package/dist/component/clientManagement.js +9 -0
  7. package/dist/component/clientManagement.js.map +1 -1
  8. package/dist/component/handlers.d.ts +19 -1
  9. package/dist/component/handlers.d.ts.map +1 -1
  10. package/dist/component/handlers.js +76 -15
  11. package/dist/component/handlers.js.map +1 -1
  12. package/dist/component/mutations.d.ts +3 -1
  13. package/dist/component/mutations.d.ts.map +1 -1
  14. package/dist/component/mutations.js +113 -19
  15. package/dist/component/mutations.js.map +1 -1
  16. package/dist/component/queries.d.ts +7 -1
  17. package/dist/component/queries.d.ts.map +1 -1
  18. package/dist/component/queries.js +7 -1
  19. package/dist/component/queries.js.map +1 -1
  20. package/dist/component/schema.d.ts +7 -1
  21. package/dist/component/schema.d.ts.map +1 -1
  22. package/dist/component/schema.js +3 -0
  23. package/dist/component/schema.js.map +1 -1
  24. package/dist/lib/oauth.d.ts.map +1 -1
  25. package/dist/lib/oauth.js +26 -8
  26. package/dist/lib/oauth.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/client/__tests__/oauth-provider.test.ts +15 -0
  29. package/src/client/index.ts +6 -1
  30. package/src/component/__tests__/bugs.test.ts +1001 -0
  31. package/src/component/__tests__/handlers-protocol.test.ts +148 -0
  32. package/src/component/__tests__/oauth.test.ts +18 -15
  33. package/src/component/__tests__/rfc-compliance.test.ts +233 -0
  34. package/src/component/clientManagement.ts +11 -0
  35. package/src/component/handlers.ts +116 -18
  36. package/src/component/mutations.ts +159 -17
  37. package/src/component/queries.ts +6 -1
  38. package/src/component/schema.ts +3 -0
  39. package/src/lib/__tests__/oauth-jwt.test.ts +1 -1
  40. package/src/lib/oauth.ts +28 -8
@@ -245,14 +245,24 @@ export interface OAuthComponentAPI {
245
245
  allowedScopes: string[];
246
246
  tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
247
247
  } | null>;
248
+ getAuthorization?: (ctx: RunQueryCtx, args: { userId: string; clientId: string }) => Promise<{
249
+ userId: string;
250
+ clientId: string;
251
+ scopes: string[];
252
+ resource?: string;
253
+ } | null>;
248
254
  getRefreshToken: (ctx: RunQueryCtx, args: { refreshToken: string }) => Promise<{
249
255
  refreshToken?: string;
250
256
  clientId: string;
251
257
  userId: string;
252
258
  scopes: string[];
253
259
  refreshTokenExpiresAt?: number;
260
+ authorizationCode?: string;
261
+ refreshTokenFamilyId?: string;
262
+ refreshTokenRotatedAt?: number;
254
263
  resource?: string;
255
264
  audience?: string;
265
+ authTime?: number;
256
266
  } | null>;
257
267
  getTokensByUser: (ctx: RunQueryCtx, args: { userId: string }) => Promise<Array<{
258
268
  _id: string;
@@ -303,6 +313,7 @@ export interface OAuthComponentAPI {
303
313
  authorizationCode?: string;
304
314
  resource?: string;
305
315
  audience?: string;
316
+ authTime?: number;
306
317
  }) => Promise<void>;
307
318
  rotateRefreshToken: (ctx: RunMutationCtx, args: {
308
319
  oldRefreshToken: string;
@@ -315,7 +326,7 @@ export interface OAuthComponentAPI {
315
326
  refreshTokenExpiresAt: number;
316
327
  resource?: string;
317
328
  audience?: string;
318
- }) => Promise<void>;
329
+ }) => Promise<void | { error: string; revokedTokens: number; authorizationDeleted: boolean }>;
319
330
  upsertAuthorization: (ctx: RunMutationCtx, args: {
320
331
  userId: string;
321
332
  clientId: string;
@@ -437,6 +448,15 @@ export async function authorizeHandler(
437
448
  );
438
449
  }
439
450
 
451
+ if (params.has("request") || params.has("request_uri")) {
452
+ return buildAuthorizeErrorRedirect(
453
+ redirectUri,
454
+ "invalid_request",
455
+ "request and request_uri parameters are not supported",
456
+ state
457
+ );
458
+ }
459
+
440
460
  if (consent === "approve" && !isConsentFromProvider(request, config)) {
441
461
  return buildAuthorizeErrorRedirect(
442
462
  redirectUri,
@@ -485,7 +505,11 @@ export async function authorizeHandler(
485
505
  let requestedScopes = scope
486
506
  ? scope.split(" ").filter(Boolean)
487
507
  : [];
488
- if (requestedScopes.includes("offline_access") && !promptValues.has("consent")) {
508
+ if (
509
+ requestedScopes.includes("offline_access") &&
510
+ !promptValues.has("consent") &&
511
+ !promptValues.has("none")
512
+ ) {
489
513
  requestedScopes = requestedScopes.filter((s) => s !== "offline_access");
490
514
  }
491
515
  if (requestedScopes.length === 0) {
@@ -531,7 +555,52 @@ export async function authorizeHandler(
531
555
  );
532
556
  }
533
557
 
534
- if (consent !== "approve") {
558
+ if (!config.getUserId) {
559
+ return new OAuthError("server_error", "getUserId is not configured", 500).toResponse(headers);
560
+ }
561
+ const userId = await config.getUserId(ctx as RunActionCtx & { auth: Auth }, request);
562
+
563
+ if (promptValues.has("none")) {
564
+ if (promptValues.size > 1) {
565
+ return buildAuthorizeErrorRedirect(
566
+ redirectUri,
567
+ "invalid_request",
568
+ "prompt=none cannot be combined with other prompt values",
569
+ state
570
+ );
571
+ }
572
+ if (!userId) {
573
+ return buildAuthorizeErrorRedirect(
574
+ redirectUri,
575
+ "login_required",
576
+ "User not authenticated",
577
+ state
578
+ );
579
+ }
580
+ if (consent !== "approve") {
581
+ if (!api.queries.getAuthorization) {
582
+ return buildAuthorizeErrorRedirect(
583
+ redirectUri,
584
+ "server_error",
585
+ "OAuth component API is out of date; regenerate component API references",
586
+ state
587
+ );
588
+ }
589
+ const authorization = await api.queries.getAuthorization(ctx, { userId, clientId });
590
+ const hasScopes = authorization !== null &&
591
+ requestedScopes.every((scope) => authorization.scopes.includes(scope));
592
+ const hasResource = authorization !== null &&
593
+ authorization.resource === (resource ?? undefined);
594
+ if (!hasScopes || !hasResource) {
595
+ return buildAuthorizeErrorRedirect(
596
+ redirectUri,
597
+ "consent_required",
598
+ "User consent required",
599
+ state
600
+ );
601
+ }
602
+ }
603
+ } else if (consent !== "approve") {
535
604
  return buildAuthorizeErrorRedirect(
536
605
  redirectUri,
537
606
  "access_denied",
@@ -540,10 +609,6 @@ export async function authorizeHandler(
540
609
  );
541
610
  }
542
611
 
543
- if (!config.getUserId) {
544
- return new OAuthError("server_error", "getUserId is not configured", 500).toResponse(headers);
545
- }
546
- const userId = await config.getUserId(ctx as RunActionCtx & { auth: Auth }, request);
547
612
  if (!userId) {
548
613
  return buildAuthorizeErrorRedirect(
549
614
  redirectUri,
@@ -586,7 +651,7 @@ export async function openIdConfigurationHandler(
586
651
  if (corsResponse) return corsResponse;
587
652
  const headers = createCorsHeaders(request.headers.get("Origin"), config, "GET, OPTIONS");
588
653
 
589
- const backendUrl = config.convexSiteUrl ?? config.siteUrl;
654
+ const backendUrl = (config.convexSiteUrl ?? config.siteUrl).replace(/\/+$/, "");
590
655
  const prefix = normalizePrefix(config.prefix);
591
656
 
592
657
  const issuerUrl = getIssuerUrl(config);
@@ -607,6 +672,9 @@ export async function openIdConfigurationHandler(
607
672
  token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
608
673
  grant_types_supported: ["authorization_code", "refresh_token"],
609
674
  code_challenge_methods_supported: ["S256"],
675
+ request_uri_parameter_supported: false,
676
+ request_parameter_supported: false,
677
+ claims_parameter_supported: false,
610
678
  };
611
679
 
612
680
  if (config.allowDynamicClientRegistration) {
@@ -695,12 +763,16 @@ export async function tokenHandler(
695
763
  throw new OAuthError("invalid_request", "Multiple client authentication methods");
696
764
  }
697
765
  const basicCredentials = parseBasicClientCredentials(authHeader);
766
+ if (bodyClientId && bodyClientId !== basicCredentials.clientId) {
767
+ throw new OAuthError("invalid_request", "Conflicting client_id");
768
+ }
698
769
  clientId = basicCredentials.clientId;
699
770
  clientSecret = basicCredentials.clientSecret;
700
771
  usedAuthMethod = "client_secret_basic";
701
772
  }
702
773
 
703
774
  if (!clientId) throw new OAuthError("invalid_request", "client_id required");
775
+ if (!grantType) throw new OAuthError("invalid_request", "grant_type required");
704
776
 
705
777
  // Client existence + confidential client check
706
778
  const client = await api.queries.getClient(ctx, { clientId });
@@ -816,6 +888,7 @@ export async function tokenHandler(
816
888
  authorizationCode: codeData.codeHash, // Link to authorization code
817
889
  resource: codeData.resource,
818
890
  audience: accessTokenAudience,
891
+ authTime: codeData.authTime,
819
892
  });
820
893
 
821
894
  // F. Create/Update Authorization Record
@@ -858,6 +931,23 @@ export async function tokenHandler(
858
931
  const oldToken = await api.queries.getRefreshToken(ctx, { refreshToken });
859
932
 
860
933
  if (!oldToken) throw new OAuthError("invalid_grant", "Invalid refresh token");
934
+ if (oldToken.refreshTokenRotatedAt !== undefined) {
935
+ // rotateRefreshToken detects tombstones before storing the supplied replacement tokens.
936
+ await api.mutations.rotateRefreshToken(ctx, {
937
+ oldRefreshToken: refreshToken,
938
+ accessToken: "refresh-token-reuse-detected",
939
+ refreshToken: "refresh-token-reuse-detected",
940
+ clientId: oldToken.clientId,
941
+ userId: oldToken.userId,
942
+ scopes: oldToken.scopes,
943
+ expiresAt: Date.now(),
944
+ refreshTokenExpiresAt: Date.now(),
945
+ resource: oldToken.resource,
946
+ audience: oldToken.audience,
947
+ });
948
+ throw new OAuthError("invalid_grant", "Invalid refresh token");
949
+ }
950
+ if (oldToken.clientId !== clientId) throw new OAuthError("invalid_grant", "Client mismatch");
861
951
  const refreshTokenResource = oldToken.resource;
862
952
  const refreshTokenAudience = oldToken.audience ?? refreshTokenResource ?? config.applicationID ?? "convex";
863
953
  const accessTokenAudience = refreshTokenResource ?? refreshTokenAudience;
@@ -872,8 +962,6 @@ export async function tokenHandler(
872
962
  throw new OAuthError("invalid_grant", "Refresh token expired");
873
963
  }
874
964
 
875
- if (oldToken.clientId !== clientId) throw new OAuthError("invalid_grant", "Client mismatch");
876
-
877
965
  const userId = oldToken.userId;
878
966
 
879
967
  // RFC 6749 Section 6: スコープパラメータ処理(アクセストークン用)
@@ -943,6 +1031,7 @@ export async function tokenHandler(
943
1031
  sub: userId,
944
1032
  iss: issuerUrl,
945
1033
  aud: clientId,
1034
+ auth_time: oldToken.authTime,
946
1035
  })
947
1036
  .setProtectedHeader({ alg: "RS256", typ: "JWT", kid: keyId })
948
1037
  .setIssuedAt()
@@ -952,7 +1041,7 @@ export async function tokenHandler(
952
1041
 
953
1042
  // Rotate - 元のスコープ維持
954
1043
  try {
955
- await api.mutations.rotateRefreshToken(ctx, {
1044
+ const rotationResult = await api.mutations.rotateRefreshToken(ctx, {
956
1045
  oldRefreshToken: refreshToken,
957
1046
  accessToken,
958
1047
  refreshToken: newRefreshToken,
@@ -964,6 +1053,9 @@ export async function tokenHandler(
964
1053
  resource: refreshTokenResource,
965
1054
  audience: refreshTokenAudience,
966
1055
  });
1056
+ if (rotationResult && "error" in rotationResult && rotationResult.error === "refresh_token_reuse_detected") {
1057
+ throw new OAuthError("invalid_grant", "Invalid refresh token");
1058
+ }
967
1059
 
968
1060
  // Update authorization lastUsedAt
969
1061
  await api.mutations.updateAuthorizationLastUsed(ctx, {
@@ -1038,8 +1130,7 @@ export async function tokenHandler(
1038
1130
  return new OAuthError("invalid_scope", e.message).toResponse(tokenHeaders);
1039
1131
  }
1040
1132
  }
1041
- const message = e instanceof Error ? e.message : String(e);
1042
- return new OAuthError("invalid_request", message).toResponse(tokenHeaders);
1133
+ return new OAuthError("invalid_request", "Invalid request").toResponse(tokenHeaders);
1043
1134
  }
1044
1135
  }
1045
1136
 
@@ -1057,7 +1148,8 @@ export async function userInfoHandler(
1057
1148
  const headers = createCorsHeaders(request.headers.get("Origin"), config, "GET, POST, OPTIONS");
1058
1149
 
1059
1150
  const authHeader = request.headers.get("Authorization");
1060
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
1151
+ const authMatch = authHeader?.match(/^Bearer\s+(.+)$/i);
1152
+ if (!authMatch) {
1061
1153
  return new Response(null, {
1062
1154
  status: 401,
1063
1155
  headers: {
@@ -1067,7 +1159,7 @@ export async function userInfoHandler(
1067
1159
  });
1068
1160
  }
1069
1161
 
1070
- const token = authHeader.split(" ")[1];
1162
+ const token = authMatch[1];
1071
1163
 
1072
1164
  try {
1073
1165
  const issuerUrl = getIssuerUrl(config);
@@ -1126,7 +1218,14 @@ export async function userInfoHandler(
1126
1218
  responseBody.email_verified = user.email_verified;
1127
1219
  }
1128
1220
 
1129
- return new Response(JSON.stringify(responseBody), { headers });
1221
+ return new Response(JSON.stringify(responseBody), {
1222
+ headers: {
1223
+ ...headers,
1224
+ "Content-Type": "application/json",
1225
+ "Cache-Control": "no-store",
1226
+ "Pragma": "no-cache",
1227
+ },
1228
+ });
1130
1229
 
1131
1230
  } catch {
1132
1231
  return new Response(null, {
@@ -1257,8 +1356,7 @@ export async function registerHandler(
1257
1356
  if (e instanceof OAuthError) {
1258
1357
  return e.toResponse(headers);
1259
1358
  }
1260
- const message = e instanceof Error ? e.message : String(e);
1261
- return new OAuthError("invalid_request", message).toResponse(headers);
1359
+ return new OAuthError("invalid_request", "Invalid client metadata").toResponse(headers);
1262
1360
  }
1263
1361
  }
1264
1362
 
@@ -21,6 +21,7 @@ export function isLoopbackRedirectUri(uri: string): boolean {
21
21
  return (
22
22
  parsed.hostname === "127.0.0.1" ||
23
23
  parsed.hostname === "::1" ||
24
+ parsed.hostname === "[::1]" ||
24
25
  parsed.hostname === "localhost"
25
26
  );
26
27
  } catch {
@@ -49,12 +50,50 @@ function validateResourceUri(resource: string | undefined): void {
49
50
  }
50
51
  }
51
52
 
53
+ async function revokeTokenFamily(ctx: any, token: {
54
+ clientId: string;
55
+ userId: string;
56
+ authorizationCode?: string;
57
+ refreshTokenFamilyId?: string;
58
+ }) {
59
+ const tokens = await ctx.db
60
+ .query("oauthTokens")
61
+ .withIndex("by_user", (q: any) => q.eq("userId", token.userId))
62
+ .collect();
63
+ const toDelete = tokens.filter((candidate: any) =>
64
+ candidate.clientId === token.clientId &&
65
+ (
66
+ (token.refreshTokenFamilyId !== undefined && candidate.refreshTokenFamilyId === token.refreshTokenFamilyId) ||
67
+ (token.authorizationCode !== undefined && candidate.authorizationCode === token.authorizationCode)
68
+ )
69
+ );
70
+
71
+ for (const candidate of toDelete) {
72
+ await ctx.db.delete(candidate._id);
73
+ }
74
+
75
+ const authorization = await ctx.db
76
+ .query("oauthAuthorizations")
77
+ .withIndex("by_user_client", (q: any) =>
78
+ q.eq("userId", token.userId).eq("clientId", token.clientId)
79
+ )
80
+ .unique();
81
+ if (authorization) {
82
+ await ctx.db.delete(authorization._id);
83
+ }
84
+
85
+ return {
86
+ revokedTokens: toDelete.length,
87
+ authorizationDeleted: authorization !== null,
88
+ };
89
+ }
90
+
52
91
  async function verifyPkce(
53
92
  codeChallengeMethod: string,
54
93
  codeChallenge: string,
55
94
  codeVerifier: string
56
95
  ) {
57
- if (codeChallengeMethod !== "S256" && codeChallengeMethod !== "plain") {
96
+ if (codeChallengeMethod !== "S256") {
58
97
  throw new Error("unsupported_code_challenge_method");
59
98
  }
60
99
 
@@ -75,12 +114,6 @@ async function verifyPkce(
75
114
  if (hashBase64 !== codeChallenge) {
76
115
  throw new Error("invalid_code_verifier");
77
116
  }
78
- } else if (codeChallengeMethod === "plain") {
79
- if (codeVerifier !== codeChallenge) {
80
- throw new Error("invalid_code_verifier");
81
- }
82
- } else {
83
- throw new Error("unsupported_code_challenge_method");
84
117
  }
85
118
  }
86
119
 
@@ -282,12 +315,23 @@ export const consumeAuthCode = mutation({
282
315
  await ctx.db.delete(token._id);
283
316
  }
284
317
 
318
+ const authorization = await ctx.db
319
+ .query("oauthAuthorizations")
320
+ .withIndex("by_user_client", (q) =>
321
+ q.eq("userId", authCode.userId).eq("clientId", authCode.clientId)
322
+ )
323
+ .unique();
324
+ if (authorization) {
325
+ await ctx.db.delete(authorization._id);
326
+ }
327
+
285
328
  await ctx.db.patch(authCode._id, { replayDetectedAt: Date.now() });
286
329
 
287
330
  // Return error status (cannot throw because it would rollback token deletion)
288
331
  return {
289
332
  error: "authorization_code_reuse_detected",
290
333
  revokedTokens: tokensToRevoke.length,
334
+ authorizationDeleted: authorization !== null,
291
335
  } as any;
292
336
  }
293
337
 
@@ -334,6 +378,7 @@ export const saveTokens = mutation({
334
378
  authorizationCode: v.optional(v.string()), // Hashed code for replay detection (RFC Line 1136)
335
379
  resource: v.optional(v.string()),
336
380
  audience: v.optional(v.string()),
381
+ authTime: v.optional(v.number()),
337
382
  },
338
383
  handler: async (ctx, args) => {
339
384
  const authorizationCode = args.authorizationCode;
@@ -356,6 +401,9 @@ export const saveTokens = mutation({
356
401
  refreshToken: args.refreshToken
357
402
  ? await hashToken(args.refreshToken)
358
403
  : undefined,
404
+ refreshTokenFamilyId: args.refreshToken
405
+ ? (args.authorizationCode ?? crypto.randomUUID())
406
+ : undefined,
359
407
  });
360
408
  },
361
409
  });
@@ -402,6 +450,14 @@ export const rotateRefreshToken = mutation({
402
450
  throw new Error("invalid_grant");
403
451
  }
404
452
 
453
+ if (oldToken.refreshTokenRotatedAt !== undefined) {
454
+ const result = await revokeTokenFamily(ctx, oldToken);
455
+ return {
456
+ error: "refresh_token_reuse_detected",
457
+ ...result,
458
+ } as any;
459
+ }
460
+
405
461
  // 2. Validate Client/User consistency
406
462
  if (oldToken.clientId !== args.clientId || oldToken.userId !== args.userId) {
407
463
  throw new Error("invalid_grant");
@@ -437,8 +493,13 @@ export const rotateRefreshToken = mutation({
437
493
  throw new Error(`invalid_scope: ${invalidScopes.join(", ")}`);
438
494
  }
439
495
 
440
- // 5. Delete Old Token
441
- await ctx.db.delete(oldToken._id);
496
+ const refreshTokenFamilyId = oldToken.refreshTokenFamilyId ?? oldToken.authorizationCode ?? crypto.randomUUID();
497
+
498
+ // 5. Keep old refresh token as a tombstone so reuse can revoke the family.
499
+ await ctx.db.patch(oldToken._id, {
500
+ refreshTokenFamilyId,
501
+ refreshTokenRotatedAt: Date.now(),
502
+ });
442
503
 
443
504
  // 6. Insert New Token (with hashed values)
444
505
  await ctx.db.insert("oauthTokens", {
@@ -451,6 +512,9 @@ export const rotateRefreshToken = mutation({
451
512
  refreshTokenExpiresAt: args.refreshTokenExpiresAt,
452
513
  resource: args.resource,
453
514
  audience: args.audience,
515
+ authorizationCode: oldToken.authorizationCode,
516
+ refreshTokenFamilyId,
517
+ authTime: oldToken.authTime,
454
518
  });
455
519
  },
456
520
  });
@@ -472,6 +536,30 @@ export const deleteClient = mutation({
472
536
  throw new Error("Client not found");
473
537
  }
474
538
 
539
+ const tokens = await ctx.db
540
+ .query("oauthTokens")
541
+ .filter((q) => q.eq(q.field("clientId"), args.clientId))
542
+ .collect();
543
+ for (const token of tokens) {
544
+ await ctx.db.delete(token._id);
545
+ }
546
+
547
+ const codes = await ctx.db
548
+ .query("oauthCodes")
549
+ .filter((q) => q.eq(q.field("clientId"), args.clientId))
550
+ .collect();
551
+ for (const code of codes) {
552
+ await ctx.db.delete(code._id);
553
+ }
554
+
555
+ const authorizations = await ctx.db
556
+ .query("oauthAuthorizations")
557
+ .filter((q) => q.eq(q.field("clientId"), args.clientId))
558
+ .collect();
559
+ for (const authorization of authorizations) {
560
+ await ctx.db.delete(authorization._id);
561
+ }
562
+
475
563
  await ctx.db.delete(client._id);
476
564
  },
477
565
  });
@@ -485,24 +573,65 @@ export const cleanupExpired = internalMutation({
485
573
  handler: async (ctx) => {
486
574
  const now = Date.now();
487
575
 
488
- // Cleanup expired codes (both unused and used codes past retention period)
576
+ // Cleanup unused expired codes. Used codes are replay-detection tombstones and
577
+ // must outlive refresh tokens minted from the same authorization.
489
578
  const expiredCodes = await ctx.db
490
579
  .query("oauthCodes")
491
580
  .filter(q => q.lt(q.field("expiresAt"), now))
492
581
  .take(100);
493
582
 
494
583
  for (const code of expiredCodes) {
495
- await ctx.db.delete(code._id);
584
+ const descendantTokens = await ctx.db
585
+ .query("oauthTokens")
586
+ .withIndex("by_authorization_code", (q) => q.eq("authorizationCode", code.code))
587
+ .collect();
588
+ const maxDescendantRefreshExpiry = Math.max(
589
+ 0,
590
+ ...descendantTokens.map((token) => token.refreshTokenExpiresAt ?? 0)
591
+ );
592
+ const replayRetentionUntil = Math.max(
593
+ (code.usedAt ?? code.replayDetectedAt ?? 0) + OAUTH_CONSTANTS.REFRESH_TOKEN_EXPIRY_MS,
594
+ maxDescendantRefreshExpiry
595
+ );
596
+ if (code.usedAt === undefined && code.replayDetectedAt === undefined) {
597
+ await ctx.db.delete(code._id);
598
+ } else if (replayRetentionUntil < now) {
599
+ await ctx.db.delete(code._id);
600
+ }
496
601
  }
497
602
 
498
- // Cleanup expired tokens
603
+ // Cleanup tokens only after every credential stored in the row has expired.
499
604
  const expiredTokens = await ctx.db
500
605
  .query("oauthTokens")
501
606
  .filter(q => q.lt(q.field("expiresAt"), now))
502
607
  .take(100);
503
608
 
504
609
  for (const token of expiredTokens) {
505
- await ctx.db.delete(token._id);
610
+ const familyTokens = token.refreshTokenFamilyId
611
+ ? await ctx.db
612
+ .query("oauthTokens")
613
+ .filter((q) =>
614
+ q.and(
615
+ q.eq(q.field("clientId"), token.clientId),
616
+ q.eq(q.field("userId"), token.userId),
617
+ q.eq(q.field("refreshTokenFamilyId"), token.refreshTokenFamilyId)
618
+ )
619
+ )
620
+ .collect()
621
+ : [];
622
+ const maxFamilyRefreshExpiry = Math.max(
623
+ 0,
624
+ ...familyTokens.map((familyToken) => familyToken.refreshTokenExpiresAt ?? 0)
625
+ );
626
+ const shouldKeepTombstone =
627
+ token.refreshTokenRotatedAt !== undefined &&
628
+ maxFamilyRefreshExpiry >= now;
629
+ if (
630
+ !shouldKeepTombstone &&
631
+ (!token.refreshToken || !token.refreshTokenExpiresAt || token.refreshTokenExpiresAt < now)
632
+ ) {
633
+ await ctx.db.delete(token._id);
634
+ }
506
635
  }
507
636
 
508
637
  return {
@@ -540,11 +669,9 @@ export const upsertAuthorization = mutation({
540
669
  const now = Date.now();
541
670
 
542
671
  if (existing) {
543
- // Update: merge scopes, update lastUsedAt
544
- const mergedScopes = [...new Set([...existing.scopes, ...args.scopes])];
545
672
  await ctx.db.patch(existing._id, {
546
- scopes: mergedScopes,
547
- resource: args.resource ?? existing.resource,
673
+ scopes: args.scopes,
674
+ resource: args.resource,
548
675
  lastUsedAt: now,
549
676
  });
550
677
  return existing._id;
@@ -616,9 +743,24 @@ export const revokeAuthorization = mutation({
616
743
  await ctx.db.delete(token._id);
617
744
  }
618
745
 
746
+ // 3. Delete pending authorization codes for this user-client pair.
747
+ const codes = (await ctx.db
748
+ .query("oauthCodes")
749
+ .filter((q) =>
750
+ q.and(
751
+ q.eq(q.field("userId"), args.userId),
752
+ q.eq(q.field("clientId"), args.clientId)
753
+ )
754
+ )
755
+ .collect()).filter((code) => code.usedAt === undefined);
756
+ for (const code of codes) {
757
+ await ctx.db.delete(code._id);
758
+ }
759
+
619
760
  return {
620
761
  authorizationDeleted: !!auth,
621
762
  tokensDeleted: toDelete.length,
763
+ codesDeleted: codes.length,
622
764
  };
623
765
  },
624
766
  });
@@ -8,10 +8,15 @@ import { hashToken, isHashedToken } from "./token_security.js";
8
8
  export const getClient = query({
9
9
  args: { clientId: v.string() },
10
10
  handler: async (ctx, args) => {
11
- return await ctx.db
11
+ const client = await ctx.db
12
12
  .query("oauthClients")
13
13
  .withIndex("by_client_id", (q) => q.eq("clientId", args.clientId))
14
14
  .unique();
15
+ if (!client) return null;
16
+ return {
17
+ ...client,
18
+ clientSecret: undefined,
19
+ };
15
20
  },
16
21
  });
17
22
 
@@ -72,8 +72,11 @@ export default defineSchema({
72
72
 
73
73
  // RFC Line 1136: Track which authorization code issued this token for replay detection
74
74
  authorizationCode: v.optional(v.string()), // Hashed authorization code
75
+ refreshTokenFamilyId: v.optional(v.string()),
76
+ refreshTokenRotatedAt: v.optional(v.number()),
75
77
  resource: v.optional(v.string()),
76
78
  audience: v.optional(v.string()),
79
+ authTime: v.optional(v.number()),
77
80
  })
78
81
  .index("by_access_token", ["accessToken"])
79
82
  .index("by_refresh_token", ["refreshToken"])
@@ -402,7 +402,7 @@ describe("OAuth JWT and Utilities", () => {
402
402
  privateKey: TEST_PRIVATE_KEY,
403
403
  keyId: "override-key"
404
404
  };
405
- expect(getSigningKeyId(configWithOverride)).toBe("override-key");
405
+ expect(() => getSigningKeyId(configWithOverride)).toThrow(/not present in JWKS/);
406
406
 
407
407
  const configWithMissingKid: OAuthConfig = {
408
408
  siteUrl: "https://example.com",