@codefox-inc/oauth-provider 0.4.0 → 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 +79 -16
  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 +182 -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 +119 -19
  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
@@ -109,6 +109,27 @@ describe("OAuth handler protocol checks", () => {
109
109
  expect(issueAuthorizationCode).not.toHaveBeenCalled();
110
110
  });
111
111
 
112
+ test("authorization endpoint rejects unsupported request object parameters", async () => {
113
+ const issueAuthorizationCode = vi.fn(async () => "code");
114
+
115
+ for (const parameter of ["request", "request_uri"]) {
116
+ const response = await authorizeHandler(
117
+ {} as any,
118
+ new Request(`https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&consent=approve&${parameter}=unsupported`, {
119
+ method: "GET",
120
+ headers: { Origin: "https://example.com" },
121
+ }),
122
+ { ...config, getUserId: async () => "user" },
123
+ makeApi({ mutations: { issueAuthorizationCode } as any })
124
+ );
125
+
126
+ expect(response.status).toBe(302);
127
+ const redirect = new URL(response.headers.get("Location") as string);
128
+ expect(redirect.searchParams.get("error")).toBe("invalid_request");
129
+ }
130
+ expect(issueAuthorizationCode).not.toHaveBeenCalled();
131
+ });
132
+
112
133
  test("authorization endpoint rejects code_challenge values outside PKCE ABNF", async () => {
113
134
  const issueAuthorizationCode = vi.fn(async () => "code");
114
135
 
@@ -149,6 +170,88 @@ describe("OAuth handler protocol checks", () => {
149
170
  );
150
171
  });
151
172
 
173
+ test("authorization endpoint allows prompt=none when existing consent covers the request", async () => {
174
+ const issueAuthorizationCode = vi.fn(async () => "silent-code");
175
+
176
+ const response = await authorizeHandler(
177
+ {} as any,
178
+ new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20profile&prompt=none&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
179
+ method: "GET",
180
+ headers: { Origin: "https://example.com" },
181
+ }),
182
+ { ...config, getUserId: async () => "user" },
183
+ makeApi({
184
+ queries: {
185
+ getAuthorization: async () => ({
186
+ userId: "user",
187
+ clientId: "client",
188
+ scopes: ["openid", "profile", "email"],
189
+ }),
190
+ } as any,
191
+ mutations: { issueAuthorizationCode } as any,
192
+ })
193
+ );
194
+
195
+ expect(response.status).toBe(302);
196
+ const redirect = new URL(response.headers.get("Location") as string);
197
+ expect(redirect.searchParams.get("code")).toBe("silent-code");
198
+ expect(issueAuthorizationCode).toHaveBeenCalledWith(
199
+ expect.anything(),
200
+ expect.objectContaining({ scopes: ["openid", "profile"] })
201
+ );
202
+ });
203
+
204
+ test("authorization endpoint preserves offline_access for prompt=none when prior consent covers it", async () => {
205
+ const issueAuthorizationCode = vi.fn(async () => "silent-code");
206
+
207
+ const response = await authorizeHandler(
208
+ {} as any,
209
+ new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20offline_access&prompt=none&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
210
+ method: "GET",
211
+ headers: { Origin: "https://example.com" },
212
+ }),
213
+ { ...config, getUserId: async () => "user" },
214
+ makeApi({
215
+ queries: {
216
+ getAuthorization: async () => ({
217
+ userId: "user",
218
+ clientId: "client",
219
+ scopes: ["openid", "offline_access"],
220
+ }),
221
+ } as any,
222
+ mutations: { issueAuthorizationCode } as any,
223
+ })
224
+ );
225
+
226
+ expect(response.status).toBe(302);
227
+ expect(issueAuthorizationCode).toHaveBeenCalledWith(
228
+ expect.anything(),
229
+ expect.objectContaining({ scopes: ["openid", "offline_access"] })
230
+ );
231
+ });
232
+
233
+ test("authorization endpoint returns server_error for prompt=none when component API is missing getAuthorization", async () => {
234
+ const issueAuthorizationCode = vi.fn(async () => "silent-code");
235
+
236
+ const response = await authorizeHandler(
237
+ {} as any,
238
+ new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&prompt=none&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
239
+ method: "GET",
240
+ headers: { Origin: "https://example.com" },
241
+ }),
242
+ { ...config, getUserId: async () => "user" },
243
+ makeApi({
244
+ queries: { getAuthorization: undefined } as any,
245
+ mutations: { issueAuthorizationCode } as any,
246
+ })
247
+ );
248
+
249
+ expect(response.status).toBe(302);
250
+ const redirect = new URL(response.headers.get("Location") as string);
251
+ expect(redirect.searchParams.get("error")).toBe("server_error");
252
+ expect(issueAuthorizationCode).not.toHaveBeenCalled();
253
+ });
254
+
152
255
  test("authorization endpoint returns login_required for max_age it cannot safely satisfy", async () => {
153
256
  const issueAuthorizationCode = vi.fn(async () => "code");
154
257
 
@@ -230,6 +333,51 @@ describe("OAuth handler protocol checks", () => {
230
333
  await expect(response.json()).resolves.toMatchObject({ error: "invalid_target" });
231
334
  });
232
335
 
336
+ test("token endpoint delegates refresh-token reuse detection to rotation without a family revocation API", async () => {
337
+ const { privateKey } = await generateKeyPair("RS256", { extractable: true });
338
+ const privateKeyPem = await exportPKCS8(privateKey);
339
+ const rotateRefreshToken = vi.fn(async () => ({
340
+ error: "refresh_token_reuse_detected",
341
+ revokedTokens: 2,
342
+ authorizationDeleted: true,
343
+ }));
344
+ const updateAuthorizationLastUsed = vi.fn(async () => undefined);
345
+
346
+ const response = await tokenHandler(
347
+ {} as any,
348
+ new Request("https://example.com/oauth/token", {
349
+ method: "POST",
350
+ body: new URLSearchParams({
351
+ grant_type: "refresh_token",
352
+ client_id: "client",
353
+ refresh_token: "rotated-rt",
354
+ client_secret: "secret",
355
+ }),
356
+ }),
357
+ { ...config, privateKey: privateKeyPem },
358
+ makeApi({
359
+ queries: {
360
+ getRefreshToken: async () => ({
361
+ clientId: "client",
362
+ userId: "user",
363
+ scopes: ["openid", "offline_access"],
364
+ refreshTokenExpiresAt: Date.now() + 3600000,
365
+ refreshTokenRotatedAt: Date.now() - 1000,
366
+ refreshTokenFamilyId: "family",
367
+ authorizationCode: "code",
368
+ }),
369
+ } as any,
370
+ mutations: { rotateRefreshToken, updateAuthorizationLastUsed } as any,
371
+ })
372
+ );
373
+
374
+ expect(rotateRefreshToken).toHaveBeenCalledOnce();
375
+ expect(updateAuthorizationLastUsed).not.toHaveBeenCalled();
376
+ expect(response.status).toBe(400);
377
+ const body = await response.json();
378
+ expect(body.error).toBe("invalid_grant");
379
+ });
380
+
233
381
  test("token endpoint stores the default audience on refresh tokens when no resource is requested", async () => {
234
382
  const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
235
383
  const privateKeyPem = await exportPKCS8(privateKey);
@@ -723,6 +871,40 @@ describe("OAuth handler protocol checks", () => {
723
871
  );
724
872
  });
725
873
 
874
+ test("DCR preserves provider-supported offline_access for later authorization requests", async () => {
875
+ const registerClient = vi.fn(async () => ({
876
+ clientId: "client",
877
+ clientSecret: "secret",
878
+ clientIdIssuedAt: 0,
879
+ }));
880
+
881
+ const response = await registerHandler(
882
+ {} as any,
883
+ new Request("https://example.com/oauth/register", {
884
+ method: "POST",
885
+ body: JSON.stringify({
886
+ redirect_uris: ["https://client.example.com/cb"],
887
+ scope: "openid profile email",
888
+ token_endpoint_auth_method: "none",
889
+ }),
890
+ headers: { "Content-Type": "application/json" },
891
+ }),
892
+ config,
893
+ makeApi({ clientManagement: { registerClient } as any })
894
+ );
895
+
896
+ expect(response.status).toBe(201);
897
+ expect(registerClient).toHaveBeenCalledWith(
898
+ expect.anything(),
899
+ expect.objectContaining({
900
+ scopes: ["openid", "profile", "email", "offline_access"],
901
+ })
902
+ );
903
+ await expect(response.json()).resolves.toMatchObject({
904
+ scope: "openid profile email offline_access",
905
+ });
906
+ });
907
+
726
908
  test("DCR rejects unsafe metadata URLs before saving", async () => {
727
909
  const registerClient = vi.fn();
728
910
 
@@ -37,8 +37,11 @@ describe("OAuth 2.1 Flow", () => {
37
37
  expect(result.clientSecret).toBeDefined();
38
38
 
39
39
  // Check DB for Hash
40
- const clientInDb = await t.query(api.queries.getClient, {
41
- clientId: result.clientId
40
+ const clientInDb = await t.run(async (ctx: any) => {
41
+ return await ctx.db
42
+ .query("oauthClients")
43
+ .withIndex("by_client_id", (q: any) => q.eq("clientId", result.clientId))
44
+ .unique();
42
45
  });
43
46
  expect(clientInDb).toBeDefined();
44
47
  // Secret in DB should NOT be the plain secret returned
@@ -1671,7 +1674,7 @@ describe("OAuth 2.1 Flow", () => {
1671
1674
  expect(response.status).toBe(400);
1672
1675
  const body = await response.json();
1673
1676
  expect(body.error).toBe("invalid_request");
1674
- expect(body.error_description).toContain("Database error");
1677
+ expect(body.error_description).toBe("Invalid client metadata");
1675
1678
  });
1676
1679
 
1677
1680
  test("Register Handler: succeeds with confidential client", async () => {
@@ -2019,7 +2022,7 @@ describe("OAuth 2.1 Flow", () => {
2019
2022
  expect(response.status).toBe(400);
2020
2023
  const body = await response.json();
2021
2024
  expect(body.error).toBe("invalid_request");
2022
- expect(body.error_description).toBe("Database error");
2025
+ expect(body.error_description).toBe("Invalid request");
2023
2026
  });
2024
2027
 
2025
2028
  test("Authorize Handler: returns error when getUserId not configured", async () => {
@@ -2142,14 +2145,14 @@ describe("OAuth 2.1 Flow", () => {
2142
2145
  refreshTokenExpiresAt: Date.now() + 864000000,
2143
2146
  });
2144
2147
 
2145
- // 3. Verify Old Token Gone (tokens are stored as hashes)
2148
+ // 3. Verify Old Token Kept as Rotation Tombstone (tokens are stored as hashes)
2146
2149
  const oldTokenHash = await hashToken(oldRefreshToken);
2147
2150
  const oldTokenRecord = await t.run(async (ctx) => {
2148
2151
  return await ctx.db.query("oauthTokens")
2149
2152
  .filter(q => q.eq(q.field("refreshToken"), oldTokenHash))
2150
2153
  .first();
2151
2154
  });
2152
- expect(oldTokenRecord).toBeNull();
2155
+ expect(oldTokenRecord?.refreshTokenRotatedAt).toBeDefined();
2153
2156
 
2154
2157
  // 4. Verify New Token Exists (stored as hash)
2155
2158
  const newTokenHash = await hashToken(newRefreshToken);
@@ -2163,8 +2166,8 @@ describe("OAuth 2.1 Flow", () => {
2163
2166
  expect(newTokenRecord?.accessToken).toBe(await hashToken(accessToken));
2164
2167
 
2165
2168
  // 5. Replay Attack (Try to rotate old token again)
2166
- await expect(t.mutation(api.mutations.rotateRefreshToken, {
2167
- oldRefreshToken: oldRefreshToken, // Already used/deleted
2169
+ const replayResult: any = await t.mutation(api.mutations.rotateRefreshToken, {
2170
+ oldRefreshToken: oldRefreshToken, // Already used/rotated
2168
2171
  accessToken: "at2",
2169
2172
  refreshToken: "rt2",
2170
2173
  clientId: client.clientId,
@@ -2172,7 +2175,8 @@ describe("OAuth 2.1 Flow", () => {
2172
2175
  scopes: ["openid"],
2173
2176
  expiresAt: Date.now() + 3600000,
2174
2177
  refreshTokenExpiresAt: Date.now() + 864000000,
2175
- })).rejects.toThrow(); // Should fail "invalid_grant"
2178
+ });
2179
+ expect(replayResult.error).toBe("refresh_token_reuse_detected");
2176
2180
  });
2177
2181
 
2178
2182
  // ==========================================
@@ -2448,7 +2452,7 @@ describe("OAuth 2.1 Flow", () => {
2448
2452
  expect(auths[0].clientWebsite).toBe("https://example.com");
2449
2453
  });
2450
2454
 
2451
- test("Authorization: upsertAuthorization (merge scopes)", async () => {
2455
+ test("Authorization: upsertAuthorization replaces scopes with the latest consent", async () => {
2452
2456
  const userId = "user-1";
2453
2457
  const client = await t.mutation(api.clientManagement.registerClient, {
2454
2458
  name: "Client",
@@ -2464,7 +2468,7 @@ describe("OAuth 2.1 Flow", () => {
2464
2468
  scopes: ["openid"]
2465
2469
  });
2466
2470
 
2467
- // Second authorization (should merge scopes)
2471
+ // Second authorization narrows/replaces the stored grant.
2468
2472
  await t.mutation(api.mutations.upsertAuthorization, {
2469
2473
  userId,
2470
2474
  clientId: client.clientId,
@@ -2475,8 +2479,7 @@ describe("OAuth 2.1 Flow", () => {
2475
2479
  userId,
2476
2480
  clientId: client.clientId
2477
2481
  });
2478
- expect(auth?.scopes).toContain("openid");
2479
- expect(auth?.scopes).toContain("profile");
2482
+ expect(auth?.scopes).toEqual(["profile"]);
2480
2483
  });
2481
2484
 
2482
2485
  test("Authorization: updateAuthorizationLastUsed", async () => {
@@ -2996,7 +2999,7 @@ codeHash: "test-code-hash",
2996
2999
  ).rejects.toThrow("invalid_code_verifier");
2997
3000
  });
2998
3001
 
2999
- test("consumeAuthCode: rejects plain PKCE verification failure", async () => {
3002
+ test("consumeAuthCode: rejects stored plain PKCE method", async () => {
3000
3003
  const userId = "test-user-id";
3001
3004
  const client = await t.mutation(api.clientManagement.registerClient, {
3002
3005
  name: "Test Client",
@@ -3027,7 +3030,7 @@ codeHash: "test-code-hash",
3027
3030
  redirectUri: "https://cb",
3028
3031
  codeVerifier: "wrong-verifier",
3029
3032
  })
3030
- ).rejects.toThrow("invalid_code_verifier");
3033
+ ).rejects.toThrow("unsupported_code_challenge_method");
3031
3034
  });
3032
3035
 
3033
3036
  test("consumeAuthCode: rejects expired code", async () => {
@@ -510,6 +510,189 @@ describe("OAuth 2.1 RFC Compliance", () => {
510
510
  expect(result1.userId).toBeDefined();
511
511
  // Note: Refresh token rotation is tested at handler level where tokens are issued
512
512
  });
513
+
514
+ test("refresh token reuse revokes the active token family and authorization", async () => {
515
+ const t = convexTest(schema, modules);
516
+
517
+ const client = await t.mutation(api.clientManagement.registerClient, {
518
+ name: "Reuse Detection Client",
519
+ type: "public",
520
+ redirectUris: ["https://example.com/callback"],
521
+ scopes: ["openid", "offline_access"],
522
+ });
523
+
524
+ await t.mutation(api.mutations.upsertAuthorization, {
525
+ userId: "user123",
526
+ clientId: client.clientId,
527
+ scopes: ["openid", "offline_access"],
528
+ });
529
+
530
+ await t.mutation(api.mutations.saveTokens, {
531
+ accessToken: "access-token-1",
532
+ refreshToken: "refresh-token-1",
533
+ clientId: client.clientId,
534
+ userId: "user123",
535
+ scopes: ["openid", "offline_access"],
536
+ expiresAt: Date.now() + 3600000,
537
+ refreshTokenExpiresAt: Date.now() + 2592000000,
538
+ authorizationCode: "authorization-code-family",
539
+ });
540
+
541
+ await t.mutation(api.mutations.rotateRefreshToken, {
542
+ oldRefreshToken: "refresh-token-1",
543
+ accessToken: "access-token-2",
544
+ refreshToken: "refresh-token-2",
545
+ clientId: client.clientId,
546
+ userId: "user123",
547
+ scopes: ["openid", "offline_access"],
548
+ expiresAt: Date.now() + 3600000,
549
+ refreshTokenExpiresAt: Date.now() + 2592000000,
550
+ });
551
+
552
+ const reuseResult: any = await t.mutation(api.mutations.rotateRefreshToken, {
553
+ oldRefreshToken: "refresh-token-1",
554
+ accessToken: "access-token-3",
555
+ refreshToken: "refresh-token-3",
556
+ clientId: client.clientId,
557
+ userId: "user123",
558
+ scopes: ["openid", "offline_access"],
559
+ expiresAt: Date.now() + 3600000,
560
+ refreshTokenExpiresAt: Date.now() + 2592000000,
561
+ });
562
+
563
+ expect(reuseResult.error).toBe("refresh_token_reuse_detected");
564
+
565
+ const remainingTokens = await t.run(async (ctx) => {
566
+ return await ctx.db
567
+ .query("oauthTokens")
568
+ .filter((q) => q.eq(q.field("clientId"), client.clientId))
569
+ .collect();
570
+ });
571
+ expect(remainingTokens).toHaveLength(0);
572
+
573
+ const authorization = await t.query(api.queries.getAuthorization, {
574
+ userId: "user123",
575
+ clientId: client.clientId,
576
+ });
577
+ expect(authorization).toBeNull();
578
+ });
579
+
580
+ test("refresh token reuse after multiple rotations revokes the whole token family", async () => {
581
+ const t = convexTest(schema, modules);
582
+
583
+ const client = await t.mutation(api.clientManagement.registerClient, {
584
+ name: "Multi Rotate Reuse Client",
585
+ type: "public",
586
+ redirectUris: ["https://example.com/callback"],
587
+ scopes: ["openid", "offline_access"],
588
+ });
589
+
590
+ await t.mutation(api.mutations.upsertAuthorization, {
591
+ userId: "user123",
592
+ clientId: client.clientId,
593
+ scopes: ["openid", "offline_access"],
594
+ });
595
+
596
+ await t.mutation(api.mutations.saveTokens, {
597
+ accessToken: "access-token-1",
598
+ refreshToken: "refresh-token-1",
599
+ clientId: client.clientId,
600
+ userId: "user123",
601
+ scopes: ["openid", "offline_access"],
602
+ expiresAt: Date.now() + 3600000,
603
+ refreshTokenExpiresAt: Date.now() + 2592000000,
604
+ authorizationCode: "authorization-code-family",
605
+ });
606
+
607
+ await t.mutation(api.mutations.rotateRefreshToken, {
608
+ oldRefreshToken: "refresh-token-1",
609
+ accessToken: "access-token-2",
610
+ refreshToken: "refresh-token-2",
611
+ clientId: client.clientId,
612
+ userId: "user123",
613
+ scopes: ["openid", "offline_access"],
614
+ expiresAt: Date.now() + 3600000,
615
+ refreshTokenExpiresAt: Date.now() + 2592000000,
616
+ });
617
+
618
+ await t.mutation(api.mutations.rotateRefreshToken, {
619
+ oldRefreshToken: "refresh-token-2",
620
+ accessToken: "access-token-3",
621
+ refreshToken: "refresh-token-3",
622
+ clientId: client.clientId,
623
+ userId: "user123",
624
+ scopes: ["openid", "offline_access"],
625
+ expiresAt: Date.now() + 3600000,
626
+ refreshTokenExpiresAt: Date.now() + 2592000000,
627
+ });
628
+
629
+ const reuseResult: any = await t.mutation(api.mutations.rotateRefreshToken, {
630
+ oldRefreshToken: "refresh-token-2",
631
+ accessToken: "access-token-4",
632
+ refreshToken: "refresh-token-4",
633
+ clientId: client.clientId,
634
+ userId: "user123",
635
+ scopes: ["openid", "offline_access"],
636
+ expiresAt: Date.now() + 3600000,
637
+ refreshTokenExpiresAt: Date.now() + 2592000000,
638
+ });
639
+
640
+ expect(reuseResult.error).toBe("refresh_token_reuse_detected");
641
+
642
+ const remainingTokens = await t.run(async (ctx) => {
643
+ return await ctx.db
644
+ .query("oauthTokens")
645
+ .filter((q) => q.eq(q.field("clientId"), client.clientId))
646
+ .collect();
647
+ });
648
+ expect(remainingTokens).toHaveLength(0);
649
+
650
+ const authorization = await t.query(api.queries.getAuthorization, {
651
+ userId: "user123",
652
+ clientId: client.clientId,
653
+ });
654
+ expect(authorization).toBeNull();
655
+ });
656
+
657
+ test("cleanupExpired preserves rotated refresh-token tombstones while descendants are active", async () => {
658
+ const t = convexTest(schema, modules);
659
+
660
+ await t.run(async (ctx) => {
661
+ await ctx.db.insert("oauthTokens", {
662
+ accessToken: "a".repeat(64),
663
+ refreshToken: "b".repeat(64),
664
+ clientId: "client",
665
+ userId: "user",
666
+ scopes: ["openid", "offline_access"],
667
+ expiresAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
668
+ refreshTokenExpiresAt: Date.now() - 1000,
669
+ authorizationCode: "authorization-code-family",
670
+ refreshTokenFamilyId: "family",
671
+ refreshTokenRotatedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
672
+ });
673
+ await ctx.db.insert("oauthTokens", {
674
+ accessToken: "c".repeat(64),
675
+ refreshToken: "d".repeat(64),
676
+ clientId: "client",
677
+ userId: "user",
678
+ scopes: ["openid", "offline_access"],
679
+ expiresAt: Date.now() - 1000,
680
+ refreshTokenExpiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
681
+ authorizationCode: "authorization-code-family",
682
+ refreshTokenFamilyId: "family",
683
+ });
684
+ });
685
+
686
+ await t.mutation(internal.mutations.cleanupExpired, {});
687
+
688
+ const tombstone = await t.run(async (ctx) => {
689
+ return await ctx.db
690
+ .query("oauthTokens")
691
+ .withIndex("by_refresh_token", (q) => q.eq("refreshToken", "b".repeat(64)))
692
+ .unique();
693
+ });
694
+ expect(tombstone?.refreshTokenRotatedAt).toBeDefined();
695
+ });
513
696
  });
514
697
 
515
698
  describe("Section 5.1 - Bearer Token Usage", () => {
@@ -739,6 +922,12 @@ describe("OAuth 2.1 RFC Compliance", () => {
739
922
  expect(codeData.userId).toBe("user123");
740
923
  expect(codeData.codeHash).toBeDefined(); // Verify codeHash is returned
741
924
 
925
+ await t.mutation(api.mutations.upsertAuthorization, {
926
+ userId: "user123",
927
+ clientId: client.clientId,
928
+ scopes: ["openid", "profile"],
929
+ });
930
+
742
931
  // Save tokens using the returned codeHash
743
932
  await t.mutation(api.mutations.saveTokens, {
744
933
  accessToken: "test-access-token",
@@ -796,6 +985,12 @@ describe("OAuth 2.1 RFC Compliance", () => {
796
985
 
797
986
  expect(tokensAfter.length).toBe(0); // All tokens should be deleted
798
987
 
988
+ const authorizationAfter = await t.query(api.queries.getAuthorization, {
989
+ userId: "user123",
990
+ clientId: client.clientId,
991
+ });
992
+ expect(authorizationAfter).toBeNull();
993
+
799
994
  await expect(
800
995
  t.mutation(api.mutations.saveTokens, {
801
996
  accessToken: "replayed-access-token",
@@ -810,6 +1005,44 @@ describe("OAuth 2.1 RFC Compliance", () => {
810
1005
  ).rejects.toThrow("authorization_code_reuse_detected");
811
1006
  });
812
1007
 
1008
+ test("used auth code tombstone survives while descendant refresh tokens are still valid", async () => {
1009
+ const t = convexTest(schema, modules);
1010
+
1011
+ await t.run(async (ctx) => {
1012
+ await ctx.db.insert("oauthCodes", {
1013
+ code: "b".repeat(64),
1014
+ clientId: "client",
1015
+ userId: "user",
1016
+ scopes: ["openid", "offline_access"],
1017
+ redirectUri: "https://cb",
1018
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
1019
+ codeChallengeMethod: "S256",
1020
+ expiresAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
1021
+ usedAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
1022
+ });
1023
+ await ctx.db.insert("oauthTokens", {
1024
+ accessToken: "a".repeat(64),
1025
+ refreshToken: "c".repeat(64),
1026
+ clientId: "client",
1027
+ userId: "user",
1028
+ scopes: ["openid", "offline_access"],
1029
+ expiresAt: Date.now() - 1000,
1030
+ refreshTokenExpiresAt: Date.now() + 29 * 24 * 60 * 60 * 1000,
1031
+ authorizationCode: "b".repeat(64),
1032
+ });
1033
+ });
1034
+
1035
+ await t.mutation(internal.mutations.cleanupExpired, {});
1036
+
1037
+ const remaining = await t.run(async (ctx) => {
1038
+ return await ctx.db
1039
+ .query("oauthCodes")
1040
+ .withIndex("by_code", (q) => q.eq("code", "b".repeat(64)))
1041
+ .unique();
1042
+ });
1043
+ expect(remaining).not.toBeNull();
1044
+ });
1045
+
813
1046
  test("RFC Line 1136: Single use enforcement - code is marked as used", async () => {
814
1047
  const t = convexTest(schema, modules);
815
1048
 
@@ -26,6 +26,7 @@ function isValidRedirectUri(uri: string): boolean {
26
26
  const isLoopback =
27
27
  host === "localhost" ||
28
28
  host === "127.0.0.1" ||
29
+ host === "[::1]" ||
29
30
  host === "::1";
30
31
 
31
32
  if (parsed.protocol === "https:") return true;
@@ -212,6 +213,16 @@ export const deleteClient = mutation({
212
213
  await ctx.db.delete(code._id);
213
214
  }
214
215
 
216
+ // Delete all authorization records for this client
217
+ const authorizations = await ctx.db
218
+ .query("oauthAuthorizations")
219
+ .filter(q => q.eq(q.field("clientId"), args.clientId))
220
+ .collect();
221
+
222
+ for (const authorization of authorizations) {
223
+ await ctx.db.delete(authorization._id);
224
+ }
225
+
215
226
  // Delete the client
216
227
  await ctx.db.delete(client._id);
217
228