@codefox-inc/oauth-provider 0.3.2 → 0.4.0

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 (42) hide show
  1. package/README.md +40 -14
  2. package/dist/client/index.d.ts +4 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +1 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +9 -0
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/clientManagement.d.ts +1 -0
  9. package/dist/component/clientManagement.d.ts.map +1 -1
  10. package/dist/component/clientManagement.js +24 -0
  11. package/dist/component/clientManagement.js.map +1 -1
  12. package/dist/component/handlers.d.ts +16 -0
  13. package/dist/component/handlers.d.ts.map +1 -1
  14. package/dist/component/handlers.js +275 -28
  15. package/dist/component/handlers.js.map +1 -1
  16. package/dist/component/mutations.d.ts +9 -0
  17. package/dist/component/mutations.d.ts.map +1 -1
  18. package/dist/component/mutations.js +112 -40
  19. package/dist/component/mutations.js.map +1 -1
  20. package/dist/component/queries.d.ts +8 -0
  21. package/dist/component/queries.d.ts.map +1 -1
  22. package/dist/component/schema.d.ts +18 -4
  23. package/dist/component/schema.d.ts.map +1 -1
  24. package/dist/component/schema.js +7 -0
  25. package/dist/component/schema.js.map +1 -1
  26. package/dist/lib/oauth.d.ts.map +1 -1
  27. package/dist/lib/oauth.js +5 -2
  28. package/dist/lib/oauth.js.map +1 -1
  29. package/package.json +39 -39
  30. package/src/client/__tests__/oauth-provider.test.ts +39 -0
  31. package/src/client/index.ts +4 -0
  32. package/src/component/__tests__/handlers-protocol.test.ts +880 -0
  33. package/src/component/__tests__/mutations-protocol.test.ts +448 -0
  34. package/src/component/__tests__/oauth.test.ts +32 -28
  35. package/src/component/__tests__/rfc-compliance.test.ts +79 -11
  36. package/src/component/_generated/component.ts +17 -1
  37. package/src/component/clientManagement.ts +31 -0
  38. package/src/component/handlers.ts +355 -31
  39. package/src/component/mutations.ts +133 -40
  40. package/src/component/schema.ts +11 -0
  41. package/src/lib/__tests__/oauth-jwt.test.ts +68 -0
  42. package/src/lib/oauth.ts +8 -4
@@ -9,6 +9,9 @@ import type { OAuthComponentAPI } from "../handlers";
9
9
  import type { OAuthConfig } from "../../lib/oauth";
10
10
 
11
11
  const modules = import.meta.glob("../**/*.ts");
12
+ const validCodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
13
+ const validCodeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
14
+ const wrongCodeVerifier = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
12
15
 
13
16
  describe("OAuth 2.1 Flow", () => {
14
17
  let t: ReturnType<typeof convexTest>;
@@ -321,7 +324,7 @@ describe("OAuth 2.1 Flow", () => {
321
324
 
322
325
  test("Token Handler: authorization_code grant issues tokens with ID token and refresh token", async () => {
323
326
  // Generate valid RSA key pair for JWT signing
324
- const { privateKey, publicKey } = await generateKeyPair("RS256");
327
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
325
328
  const privateKeyPem = await exportPKCS8(privateKey);
326
329
  const jwk = await exportJWK(publicKey);
327
330
  const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
@@ -788,7 +791,7 @@ describe("OAuth 2.1 Flow", () => {
788
791
  },
789
792
  };
790
793
 
791
- const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&code_challenge=challenge&code_challenge_method=S256", {
794
+ const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
792
795
  method: "GET",
793
796
  });
794
797
 
@@ -844,7 +847,7 @@ describe("OAuth 2.1 Flow", () => {
844
847
  },
845
848
  };
846
849
 
847
- const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20admin&state=abc&consent=approve&code_challenge=challenge&code_challenge_method=S256", {
850
+ const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20admin&state=abc&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
848
851
  method: "GET",
849
852
  headers: {
850
853
  "Referer": "https://example.com/consent",
@@ -903,7 +906,7 @@ describe("OAuth 2.1 Flow", () => {
903
906
  },
904
907
  };
905
908
 
906
- const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20profile&state=state-123&consent=approve&code_challenge=challenge&code_challenge_method=S256", {
909
+ const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20profile&state=state-123&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
907
910
  method: "GET",
908
911
  headers: {
909
912
  "Referer": "https://example.com/consent",
@@ -962,7 +965,7 @@ describe("OAuth 2.1 Flow", () => {
962
965
  },
963
966
  };
964
967
 
965
- const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=challenge&code_challenge_method=S256", {
968
+ const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
966
969
  method: "GET",
967
970
  headers: {
968
971
  "Referer": "https://example.com/consent",
@@ -1181,7 +1184,7 @@ describe("OAuth 2.1 Flow", () => {
1181
1184
  },
1182
1185
  };
1183
1186
 
1184
- const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=challenge&code_challenge_method=plain", {
1187
+ const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=plain", {
1185
1188
  method: "GET",
1186
1189
  headers: {
1187
1190
  "Referer": "https://example.com/consent",
@@ -1240,7 +1243,7 @@ describe("OAuth 2.1 Flow", () => {
1240
1243
  },
1241
1244
  };
1242
1245
 
1243
- const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=challenge&code_challenge_method=S256", {
1246
+ const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
1244
1247
  method: "GET",
1245
1248
  headers: {
1246
1249
  "Referer": "https://client.example.com/start",
@@ -1450,7 +1453,7 @@ describe("OAuth 2.1 Flow", () => {
1450
1453
  method: "POST",
1451
1454
  body: JSON.stringify({
1452
1455
  redirect_uris: ["https://cb"],
1453
- token_endpoint_auth_method: "client_secret_basic",
1456
+ token_endpoint_auth_method: "private_key_jwt",
1454
1457
  }),
1455
1458
  headers: { "Content-Type": "application/json" },
1456
1459
  });
@@ -1561,7 +1564,7 @@ describe("OAuth 2.1 Flow", () => {
1561
1564
  const response = await registerHandler({} as any, request, config, apiStub);
1562
1565
  expect(response.status).toBe(400);
1563
1566
  const body = await response.json();
1564
- expect(body.error).toBe("invalid_request");
1567
+ expect(body.error).toBe("invalid_redirect_uri");
1565
1568
  expect(body.error_description).toContain("Invalid redirect_uri");
1566
1569
  });
1567
1570
 
@@ -1877,7 +1880,7 @@ describe("OAuth 2.1 Flow", () => {
1877
1880
 
1878
1881
  test("Token Handler: handles rotateRefreshToken error with invalid_grant", async () => {
1879
1882
  // Generate valid RSA key pair for JWT signing
1880
- const { privateKey, publicKey } = await generateKeyPair("RS256");
1883
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
1881
1884
  const privateKeyPem = await exportPKCS8(privateKey);
1882
1885
  const jwk = await exportJWK(publicKey);
1883
1886
  const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
@@ -1949,7 +1952,7 @@ describe("OAuth 2.1 Flow", () => {
1949
1952
 
1950
1953
  test("Token Handler: handles rotateRefreshToken error (non-invalid_grant)", async () => {
1951
1954
  // Generate valid RSA key pair for JWT signing
1952
- const { privateKey, publicKey } = await generateKeyPair("RS256");
1955
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
1953
1956
  const privateKeyPem = await exportPKCS8(privateKey);
1954
1957
  const jwk = await exportJWK(publicKey);
1955
1958
  const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
@@ -2061,7 +2064,7 @@ describe("OAuth 2.1 Flow", () => {
2061
2064
  },
2062
2065
  };
2063
2066
 
2064
- const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&consent=approve&code_challenge=challenge&code_challenge_method=S256", {
2067
+ const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
2065
2068
  method: "GET",
2066
2069
  headers: {
2067
2070
  "Referer": "https://example.com/consent",
@@ -2093,7 +2096,7 @@ describe("OAuth 2.1 Flow", () => {
2093
2096
  userId,
2094
2097
  redirectUri: "https://cb",
2095
2098
  scopes: [],
2096
- codeChallenge: "c",
2099
+ codeChallenge: validCodeChallenge,
2097
2100
  codeChallengeMethod: "S256" // Changed from "plain" to "S256"
2098
2101
  });
2099
2102
 
@@ -2563,7 +2566,7 @@ describe("OAuth 2.1 Flow", () => {
2563
2566
  scopes: string[],
2564
2567
  options: { clientId?: string } = {}
2565
2568
  ) {
2566
- const { publicKey, privateKey } = await generateKeyPair("RS256");
2569
+ const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true });
2567
2570
  const jwk = await exportJWK(publicKey);
2568
2571
  const jwks = JSON.stringify({
2569
2572
  keys: [{
@@ -2581,14 +2584,17 @@ describe("OAuth 2.1 Flow", () => {
2581
2584
  };
2582
2585
  const payload: Record<string, unknown> = {
2583
2586
  scp: scopes.join(" "),
2587
+ scope: scopes.join(" "),
2588
+ jti: crypto.randomUUID(),
2584
2589
  };
2585
2590
  if (options.clientId) {
2586
2591
  payload.cid = options.clientId;
2592
+ payload.client_id = options.clientId;
2587
2593
  }
2588
2594
  const token = await new SignJWT({
2589
2595
  ...payload,
2590
2596
  })
2591
- .setProtectedHeader({ alg: "RS256", kid: "default-key" })
2597
+ .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: "default-key" })
2592
2598
  .setIssuedAt()
2593
2599
  .setSubject("user-1")
2594
2600
  .setAudience("convex")
@@ -2765,8 +2771,7 @@ describe("OAuth 2.1 Flow", () => {
2765
2771
 
2766
2772
  expect(response.status).toBe(401);
2767
2773
  const wwwAuth = response.headers.get("WWW-Authenticate");
2768
- expect(wwwAuth).toContain("invalid_token");
2769
- expect(wwwAuth).toContain("Missing bearer token");
2774
+ expect(wwwAuth).toBe('Bearer realm="userinfo"');
2770
2775
  });
2771
2776
 
2772
2777
  test("UserInfo: returns 401 when Authorization header is malformed", async () => {
@@ -2781,8 +2786,7 @@ describe("OAuth 2.1 Flow", () => {
2781
2786
 
2782
2787
  expect(response.status).toBe(401);
2783
2788
  const wwwAuth = response.headers.get("WWW-Authenticate");
2784
- expect(wwwAuth).toContain("invalid_token");
2785
- expect(wwwAuth).toContain("Missing bearer token");
2789
+ expect(wwwAuth).toBe('Bearer realm="userinfo"');
2786
2790
  });
2787
2791
 
2788
2792
  test("UserInfo: returns 401 when token verification fails", async () => {
@@ -2866,7 +2870,7 @@ codeHash: "test-code-hash",
2866
2870
  });
2867
2871
 
2868
2872
  test("Token Handler: refresh_token grant succeeds with offline_access scope", async () => {
2869
- const { privateKey, publicKey } = await generateKeyPair("RS256");
2873
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
2870
2874
  const privateKeyPEM = await exportPKCS8(privateKey);
2871
2875
  const publicJWK = await exportJWK(publicKey);
2872
2876
 
@@ -2950,7 +2954,7 @@ codeHash: "test-code-hash",
2950
2954
  userId,
2951
2955
  redirectUri: "https://cb",
2952
2956
  scopes: ["openid"],
2953
- codeChallenge: "challenge",
2957
+ codeChallenge: validCodeChallenge,
2954
2958
  codeChallengeMethod: "S256"
2955
2959
  });
2956
2960
 
@@ -2959,7 +2963,7 @@ codeHash: "test-code-hash",
2959
2963
  code,
2960
2964
  clientId: client.clientId,
2961
2965
  redirectUri: "https://wrong-redirect",
2962
- codeVerifier: "verifier",
2966
+ codeVerifier: validCodeVerifier,
2963
2967
  })
2964
2968
  ).rejects.toThrow("redirect_uri_mismatch");
2965
2969
  });
@@ -2978,7 +2982,7 @@ codeHash: "test-code-hash",
2978
2982
  userId,
2979
2983
  redirectUri: "https://cb",
2980
2984
  scopes: ["openid"],
2981
- codeChallenge: "correct-challenge",
2985
+ codeChallenge: validCodeChallenge,
2982
2986
  codeChallengeMethod: "S256"
2983
2987
  });
2984
2988
 
@@ -2987,7 +2991,7 @@ codeHash: "test-code-hash",
2987
2991
  code,
2988
2992
  clientId: client.clientId,
2989
2993
  redirectUri: "https://cb",
2990
- codeVerifier: "wrong-verifier",
2994
+ codeVerifier: wrongCodeVerifier,
2991
2995
  })
2992
2996
  ).rejects.toThrow("invalid_code_verifier");
2993
2997
  });
@@ -3044,7 +3048,7 @@ codeHash: "test-code-hash",
3044
3048
  userId,
3045
3049
  redirectUri: "https://cb",
3046
3050
  scopes: ["openid"],
3047
- codeChallenge: "challenge",
3051
+ codeChallenge: validCodeChallenge,
3048
3052
  codeChallengeMethod: "S256",
3049
3053
  expiresAt: Date.now() - 1000, // Expired
3050
3054
  });
@@ -3078,7 +3082,7 @@ codeHash: "test-code-hash",
3078
3082
  userId,
3079
3083
  redirectUri: "https://cb",
3080
3084
  scopes: ["openid"],
3081
- codeChallenge: "challenge",
3085
+ codeChallenge: validCodeChallenge,
3082
3086
  codeChallengeMethod: "MD5", // Invalid method
3083
3087
  expiresAt: Date.now() + 600000,
3084
3088
  });
@@ -3089,7 +3093,7 @@ codeHash: "test-code-hash",
3089
3093
  code,
3090
3094
  clientId: client.clientId,
3091
3095
  redirectUri: "https://cb",
3092
- codeVerifier: "verifier",
3096
+ codeVerifier: validCodeVerifier,
3093
3097
  })
3094
3098
  ).rejects.toThrow("unsupported_code_challenge_method");
3095
3099
  });
@@ -3268,7 +3272,7 @@ codeHash: "test-code-hash",
3268
3272
  userId,
3269
3273
  redirectUri: "https://cb",
3270
3274
  scopes: ["openid"],
3271
- codeChallenge: "challenge",
3275
+ codeChallenge: validCodeChallenge,
3272
3276
  codeChallengeMethod: "S256"
3273
3277
  });
3274
3278
 
@@ -13,6 +13,8 @@ import schema from "../schema";
13
13
 
14
14
  const modules = import.meta.glob("../**/*.ts");
15
15
 
16
+ const validCodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
17
+
16
18
  describe("OAuth 2.1 RFC Compliance", () => {
17
19
  describe("Section 4.1.1 - PKCE Requirements", () => {
18
20
  test("MUST support code_challenge and code_verifier parameters", async () => {
@@ -104,7 +106,7 @@ describe("OAuth 2.1 RFC Compliance", () => {
104
106
  clientId: client.clientId,
105
107
  scopes: ["openid"],
106
108
  redirectUri: "https://example.com/callback",
107
- codeChallenge: "test-verifier",
109
+ codeChallenge: validCodeChallenge,
108
110
  codeChallengeMethod: "plain",
109
111
  })
110
112
  ).rejects.toThrow(/plain.*not.*support/i);
@@ -351,6 +353,60 @@ describe("OAuth 2.1 RFC Compliance", () => {
351
353
  });
352
354
  expect(authCode2).toBeDefined();
353
355
  });
356
+
357
+ test("MUST only allow port variance for loopback redirect URIs", async () => {
358
+ const t = convexTest(schema, modules);
359
+
360
+ const client = await t.mutation(api.clientManagement.registerClient, {
361
+ name: "Native App Strict Loopback",
362
+ type: "public",
363
+ redirectUris: ["http://127.0.0.1/callback?flow=oauth"],
364
+ scopes: ["openid"],
365
+ });
366
+
367
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
368
+ userId: "user123",
369
+ clientId: client.clientId,
370
+ scopes: ["openid"],
371
+ redirectUri: "http://127.0.0.1:8080/callback?flow=oauth",
372
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
373
+ codeChallengeMethod: "S256",
374
+ });
375
+ expect(authCode).toBeDefined();
376
+
377
+ await expect(
378
+ t.mutation(api.mutations.issueAuthorizationCode, {
379
+ userId: "user123",
380
+ clientId: client.clientId,
381
+ scopes: ["openid"],
382
+ redirectUri: "https://127.0.0.1:8080/callback?flow=oauth",
383
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
384
+ codeChallengeMethod: "S256",
385
+ })
386
+ ).rejects.toThrow(/redirect/i);
387
+
388
+ await expect(
389
+ t.mutation(api.mutations.issueAuthorizationCode, {
390
+ userId: "user123",
391
+ clientId: client.clientId,
392
+ scopes: ["openid"],
393
+ redirectUri: "http://127.0.0.1:8080/different?flow=oauth",
394
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
395
+ codeChallengeMethod: "S256",
396
+ })
397
+ ).rejects.toThrow(/redirect/i);
398
+
399
+ await expect(
400
+ t.mutation(api.mutations.issueAuthorizationCode, {
401
+ userId: "user123",
402
+ clientId: client.clientId,
403
+ scopes: ["openid"],
404
+ redirectUri: "http://127.0.0.1:8080/callback?flow=other",
405
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
406
+ codeChallengeMethod: "S256",
407
+ })
408
+ ).rejects.toThrow(/redirect/i);
409
+ });
354
410
  });
355
411
 
356
412
  describe("Section 3.2.3 - Token Response", () => {
@@ -484,7 +540,7 @@ describe("OAuth 2.1 RFC Compliance", () => {
484
540
  expect(true).toBe(true); // Placeholder - verify no password grant implementation
485
541
  });
486
542
 
487
- test("RFC 6749 4.1.3: redirect_uri REQUIRED if included in authorization request", async () => {
543
+ test("OAuth 2.1: redirect_uri is optional at token endpoint and validated only when supplied", async () => {
488
544
  const t = convexTest(schema, modules);
489
545
 
490
546
  const client = await t.mutation(api.clientManagement.registerClient, {
@@ -503,15 +559,14 @@ describe("OAuth 2.1 RFC Compliance", () => {
503
559
  codeChallengeMethod: "S256",
504
560
  });
505
561
 
506
- // redirect_uri省略時はエラー
507
- await expect(
508
- t.mutation(api.mutations.consumeAuthCode, {
509
- code: authCode,
510
- clientId: client.clientId,
511
- codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
512
- // redirect_uriを省略
513
- })
514
- ).rejects.toThrow("redirect_uri_required");
562
+ // redirect_uri 省略時も許可
563
+ const omittedRedirectData = await t.mutation(api.mutations.consumeAuthCode, {
564
+ code: authCode,
565
+ clientId: client.clientId,
566
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
567
+ });
568
+
569
+ expect(omittedRedirectData.userId).toBeDefined();
515
570
 
516
571
  // redirect_uri付きなら成功
517
572
  const authCode2 = await t.mutation(api.mutations.issueAuthorizationCode, {
@@ -740,6 +795,19 @@ describe("OAuth 2.1 RFC Compliance", () => {
740
795
  });
741
796
 
742
797
  expect(tokensAfter.length).toBe(0); // All tokens should be deleted
798
+
799
+ await expect(
800
+ t.mutation(api.mutations.saveTokens, {
801
+ accessToken: "replayed-access-token",
802
+ refreshToken: "replayed-refresh-token",
803
+ clientId: client.clientId,
804
+ userId: "user123",
805
+ scopes: ["openid", "profile"],
806
+ expiresAt: Date.now() + 3600000,
807
+ refreshTokenExpiresAt: Date.now() + 2592000000,
808
+ authorizationCode: codeData.codeHash,
809
+ })
810
+ ).rejects.toThrow("authorization_code_reuse_detected");
743
811
  });
744
812
 
745
813
  test("RFC Line 1136: Single use enforcement - code is marked as used", async () => {
@@ -42,6 +42,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
42
42
  policyUrl?: string;
43
43
  redirectUris: Array<string>;
44
44
  scopes: Array<string>;
45
+ tokenEndpointAuthMethod?:
46
+ | "client_secret_basic"
47
+ | "client_secret_post"
48
+ | "none";
45
49
  tosUrl?: string;
46
50
  type: "confidential" | "public";
47
51
  website?: string;
@@ -66,6 +70,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
66
70
  code: string;
67
71
  codeVerifier: string;
68
72
  redirectUri?: string;
73
+ resource?: string;
69
74
  },
70
75
  any,
71
76
  Name
@@ -81,11 +86,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
81
86
  "mutation",
82
87
  "internal",
83
88
  {
89
+ authTime?: number;
84
90
  clientId: string;
85
91
  codeChallenge: string;
86
92
  codeChallengeMethod: string;
87
93
  nonce?: string;
88
94
  redirectUri: string;
95
+ resource?: string;
89
96
  scopes: Array<string>;
90
97
  userId: string;
91
98
  },
@@ -104,11 +111,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
104
111
  "internal",
105
112
  {
106
113
  accessToken: string;
114
+ audience?: string;
107
115
  clientId: string;
108
116
  expiresAt: number;
109
117
  oldRefreshToken: string;
110
118
  refreshToken?: string;
111
119
  refreshTokenExpiresAt?: number;
120
+ resource?: string;
112
121
  scopes: Array<string>;
113
122
  userId: string;
114
123
  },
@@ -120,11 +129,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
120
129
  "internal",
121
130
  {
122
131
  accessToken: string;
132
+ audience?: string;
123
133
  authorizationCode?: string;
124
134
  clientId: string;
125
135
  expiresAt: number;
126
136
  refreshToken?: string;
127
137
  refreshTokenExpiresAt?: number;
138
+ resource?: string;
128
139
  scopes: Array<string>;
129
140
  userId: string;
130
141
  },
@@ -141,7 +152,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
141
152
  upsertAuthorization: FunctionReference<
142
153
  "mutation",
143
154
  "internal",
144
- { clientId: string; scopes: Array<string>; userId: string },
155
+ {
156
+ clientId: string;
157
+ resource?: string;
158
+ scopes: Array<string>;
159
+ userId: string;
160
+ },
145
161
  any,
146
162
  Name
147
163
  >;
@@ -20,6 +20,7 @@ function isValidRedirectUri(uri: string): boolean {
20
20
  }
21
21
 
22
22
  if (parsed.hash) return false;
23
+ if (parsed.username || parsed.password) return false;
23
24
 
24
25
  const host = parsed.hostname.toLowerCase();
25
26
  const isLoopback =
@@ -29,10 +30,23 @@ function isValidRedirectUri(uri: string): boolean {
29
30
 
30
31
  if (parsed.protocol === "https:") return true;
31
32
  if (parsed.protocol === "http:" && isLoopback) return true;
33
+ if (isValidPrivateUseRedirectUri(parsed)) return true;
32
34
 
33
35
  return false;
34
36
  }
35
37
 
38
+ function isValidPrivateUseRedirectUri(parsed: URL): boolean {
39
+ const scheme = parsed.protocol.slice(0, -1);
40
+ const reverseDomainStyle = /^[a-z][a-z0-9]*(\.[a-z0-9][a-z0-9-]*){2,}$/i;
41
+ return (
42
+ reverseDomainStyle.test(scheme) &&
43
+ parsed.hostname === "" &&
44
+ parsed.host === "" &&
45
+ parsed.pathname.startsWith("/") &&
46
+ parsed.pathname.length > 1
47
+ );
48
+ }
49
+
36
50
  /**
37
51
  * Register OAuth Client
38
52
  */
@@ -48,12 +62,27 @@ export const registerClient = mutation({
48
62
  logoUrl: v.optional(v.string()),
49
63
  tosUrl: v.optional(v.string()),
50
64
  policyUrl: v.optional(v.string()),
65
+ tokenEndpointAuthMethod: v.optional(v.union(
66
+ v.literal("client_secret_basic"),
67
+ v.literal("client_secret_post"),
68
+ v.literal("none"),
69
+ )),
51
70
  isInternal: v.optional(v.boolean()),
52
71
  },
53
72
  handler: async (ctx, args) => {
54
73
  if (args.redirectUris.length === 0) {
55
74
  throw new Error("redirect_uris required");
56
75
  }
76
+ if (
77
+ args.type === "public" &&
78
+ args.tokenEndpointAuthMethod &&
79
+ args.tokenEndpointAuthMethod !== "none"
80
+ ) {
81
+ throw new Error("invalid_client_metadata: public clients must use token_endpoint_auth_method none");
82
+ }
83
+ if (args.type === "confidential" && args.tokenEndpointAuthMethod === "none") {
84
+ throw new Error("invalid_client_metadata: confidential clients must authenticate at the token endpoint");
85
+ }
57
86
  const invalidRedirect = args.redirectUris.find((uri) => !isValidRedirectUri(uri));
58
87
  if (invalidRedirect) {
59
88
  throw new Error(`Invalid redirect_uri: ${invalidRedirect}`);
@@ -83,6 +112,7 @@ export const registerClient = mutation({
83
112
  logoUrl: args.logoUrl,
84
113
  tosUrl: args.tosUrl,
85
114
  policyUrl: args.policyUrl,
115
+ tokenEndpointAuthMethod: args.tokenEndpointAuthMethod ?? "client_secret_basic",
86
116
  isInternal: args.isInternal,
87
117
  });
88
118
 
@@ -107,6 +137,7 @@ export const registerClient = mutation({
107
137
  logoUrl: args.logoUrl,
108
138
  tosUrl: args.tosUrl,
109
139
  policyUrl: args.policyUrl,
140
+ tokenEndpointAuthMethod: args.tokenEndpointAuthMethod ?? "none",
110
141
  isInternal: args.isInternal,
111
142
  });
112
143