@codefox-inc/oauth-provider 0.2.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 (113) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +572 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/auth-config.d.ts +85 -0
  8. package/dist/client/auth-config.d.ts.map +1 -0
  9. package/dist/client/auth-config.js +81 -0
  10. package/dist/client/auth-config.js.map +1 -0
  11. package/dist/client/auth-helper.d.ts +81 -0
  12. package/dist/client/auth-helper.d.ts.map +1 -0
  13. package/dist/client/auth-helper.js +97 -0
  14. package/dist/client/auth-helper.js.map +1 -0
  15. package/dist/client/index.d.ts +189 -0
  16. package/dist/client/index.d.ts.map +1 -0
  17. package/dist/client/index.js +230 -0
  18. package/dist/client/index.js.map +1 -0
  19. package/dist/client/routes.d.ts +94 -0
  20. package/dist/client/routes.d.ts.map +1 -0
  21. package/dist/client/routes.js +113 -0
  22. package/dist/client/routes.js.map +1 -0
  23. package/dist/component/_generated/api.d.ts +44 -0
  24. package/dist/component/_generated/api.d.ts.map +1 -0
  25. package/dist/component/_generated/api.js +31 -0
  26. package/dist/component/_generated/api.js.map +1 -0
  27. package/dist/component/_generated/component.d.ts +123 -0
  28. package/dist/component/_generated/component.d.ts.map +1 -0
  29. package/dist/component/_generated/component.js +11 -0
  30. package/dist/component/_generated/component.js.map +1 -0
  31. package/dist/component/_generated/dataModel.d.ts +46 -0
  32. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  33. package/dist/component/_generated/dataModel.js +11 -0
  34. package/dist/component/_generated/dataModel.js.map +1 -0
  35. package/dist/component/_generated/server.d.ts +121 -0
  36. package/dist/component/_generated/server.d.ts.map +1 -0
  37. package/dist/component/_generated/server.js +78 -0
  38. package/dist/component/_generated/server.js.map +1 -0
  39. package/dist/component/clientManagement.d.ts +39 -0
  40. package/dist/component/clientManagement.d.ts.map +1 -0
  41. package/dist/component/clientManagement.js +169 -0
  42. package/dist/component/clientManagement.js.map +1 -0
  43. package/dist/component/constants.d.ts +31 -0
  44. package/dist/component/constants.d.ts.map +1 -0
  45. package/dist/component/constants.js +36 -0
  46. package/dist/component/constants.js.map +1 -0
  47. package/dist/component/convex.config.d.ts +3 -0
  48. package/dist/component/convex.config.d.ts.map +1 -0
  49. package/dist/component/convex.config.js +3 -0
  50. package/dist/component/convex.config.js.map +1 -0
  51. package/dist/component/handlers.d.ts +143 -0
  52. package/dist/component/handlers.d.ts.map +1 -0
  53. package/dist/component/handlers.js +624 -0
  54. package/dist/component/handlers.js.map +1 -0
  55. package/dist/component/mutations.d.ts +111 -0
  56. package/dist/component/mutations.d.ts.map +1 -0
  57. package/dist/component/mutations.js +459 -0
  58. package/dist/component/mutations.js.map +1 -0
  59. package/dist/component/queries.d.ts +127 -0
  60. package/dist/component/queries.d.ts.map +1 -0
  61. package/dist/component/queries.js +145 -0
  62. package/dist/component/queries.js.map +1 -0
  63. package/dist/component/schema.d.ts +116 -0
  64. package/dist/component/schema.d.ts.map +1 -0
  65. package/dist/component/schema.js +77 -0
  66. package/dist/component/schema.js.map +1 -0
  67. package/dist/component/token_security.d.ts +53 -0
  68. package/dist/component/token_security.d.ts.map +1 -0
  69. package/dist/component/token_security.js +91 -0
  70. package/dist/component/token_security.js.map +1 -0
  71. package/dist/lib/convex-types.d.ts +21 -0
  72. package/dist/lib/convex-types.d.ts.map +1 -0
  73. package/dist/lib/convex-types.js +2 -0
  74. package/dist/lib/convex-types.js.map +1 -0
  75. package/dist/lib/oauth.d.ts +123 -0
  76. package/dist/lib/oauth.d.ts.map +1 -0
  77. package/dist/lib/oauth.js +295 -0
  78. package/dist/lib/oauth.js.map +1 -0
  79. package/dist/react/index.d.ts +2 -0
  80. package/dist/react/index.d.ts.map +1 -0
  81. package/dist/react/index.js +6 -0
  82. package/dist/react/index.js.map +1 -0
  83. package/package.json +121 -0
  84. package/src/client/__tests__/auth-config.test.ts +244 -0
  85. package/src/client/__tests__/auth-helper.test.ts +273 -0
  86. package/src/client/__tests__/oauth-provider.test.ts +418 -0
  87. package/src/client/__tests__/routes.test.ts +428 -0
  88. package/src/client/_generated/_ignore.ts +1 -0
  89. package/src/client/auth-config.ts +157 -0
  90. package/src/client/auth-helper.ts +201 -0
  91. package/src/client/index.ts +326 -0
  92. package/src/client/routes.ts +251 -0
  93. package/src/component/__tests__/oauth.test.ts +3310 -0
  94. package/src/component/__tests__/rfc-compliance.test.ts +788 -0
  95. package/src/component/__tests__/token-security.test.ts +133 -0
  96. package/src/component/_generated/api.ts +60 -0
  97. package/src/component/_generated/component.ts +201 -0
  98. package/src/component/_generated/dataModel.ts +60 -0
  99. package/src/component/_generated/server.ts +156 -0
  100. package/src/component/clientManagement.ts +189 -0
  101. package/src/component/constants.ts +40 -0
  102. package/src/component/convex.config.ts +3 -0
  103. package/src/component/handlers.ts +964 -0
  104. package/src/component/mutations.ts +531 -0
  105. package/src/component/queries.ts +165 -0
  106. package/src/component/schema.ts +92 -0
  107. package/src/component/token_security.ts +102 -0
  108. package/src/lib/__tests__/oauth-helpers.test.ts +143 -0
  109. package/src/lib/__tests__/oauth-jwt.test.ts +405 -0
  110. package/src/lib/convex-types.ts +37 -0
  111. package/src/lib/oauth.ts +412 -0
  112. package/src/react/index.ts +7 -0
  113. package/src/test.ts +21 -0
@@ -0,0 +1,788 @@
1
+ /**
2
+ * OAuth 2.1 RFC Compliance Tests
3
+ * Based on draft-ietf-oauth-v2-1-14
4
+ *
5
+ * This test suite validates compliance with OAuth 2.1 specification requirements.
6
+ * Each test maps to specific MUST/MUST NOT/SHOULD requirements from the RFC.
7
+ */
8
+
9
+ import { convexTest } from "convex-test";
10
+ import { expect, test, describe } from "vitest";
11
+ import { api, internal } from "../_generated/api";
12
+ import schema from "../schema";
13
+
14
+ const modules = import.meta.glob("../**/*.ts");
15
+
16
+ describe("OAuth 2.1 RFC Compliance", () => {
17
+ describe("Section 4.1.1 - PKCE Requirements", () => {
18
+ test("MUST support code_challenge and code_verifier parameters", async () => {
19
+ const t = convexTest(schema, modules);
20
+
21
+ // Register client
22
+ const client = await t.mutation(api.clientManagement.registerClient, {
23
+ name: "Test Client",
24
+ type: "public",
25
+ redirectUris: ["https://example.com/callback"],
26
+ scopes: ["openid"],
27
+ });
28
+
29
+ // Test that PKCE parameters are accepted
30
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
31
+ userId: "user123",
32
+ clientId: client.clientId,
33
+ scopes: ["openid"],
34
+ redirectUri: "https://example.com/callback",
35
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
36
+ codeChallengeMethod: "S256",
37
+ });
38
+
39
+ expect(authCode).toBeDefined();
40
+ expect(typeof authCode).toBe("string");
41
+ });
42
+
43
+ test("MUST reject authorization requests without code_challenge from public clients", async () => {
44
+ const t = convexTest(schema, modules);
45
+
46
+ // Register public client
47
+ const client = await t.mutation(api.clientManagement.registerClient, {
48
+ name: "Public Client",
49
+ type: "public",
50
+ redirectUris: ["https://example.com/callback"],
51
+ scopes: ["openid"],
52
+ });
53
+
54
+ // Attempt to issue authorization code without PKCE (should fail for public clients)
55
+ await expect(
56
+ t.mutation(api.mutations.issueAuthorizationCode, {
57
+ userId: "user123",
58
+ clientId: client.clientId,
59
+ scopes: ["openid"],
60
+ redirectUri: "https://example.com/callback",
61
+ codeChallenge: "", // Empty code_challenge
62
+ codeChallengeMethod: "S256",
63
+ })
64
+ ).rejects.toThrow();
65
+ });
66
+
67
+ test("MUST support S256 code_challenge_method", async () => {
68
+ const t = convexTest(schema, modules);
69
+
70
+ const client = await t.mutation(api.clientManagement.registerClient, {
71
+ name: "Test Client",
72
+ type: "public",
73
+ redirectUris: ["https://example.com/callback"],
74
+ scopes: ["openid"],
75
+ });
76
+
77
+ // S256 should be supported
78
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
79
+ userId: "user123",
80
+ clientId: client.clientId,
81
+ scopes: ["openid"],
82
+ redirectUri: "https://example.com/callback",
83
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
84
+ codeChallengeMethod: "S256",
85
+ });
86
+
87
+ expect(authCode).toBeDefined();
88
+ });
89
+
90
+ test("SHOULD reject 'plain' code_challenge_method", async () => {
91
+ const t = convexTest(schema, modules);
92
+
93
+ const client = await t.mutation(api.clientManagement.registerClient, {
94
+ name: "Test Client",
95
+ type: "public",
96
+ redirectUris: ["https://example.com/callback"],
97
+ scopes: ["openid"],
98
+ });
99
+
100
+ // Plain method should be rejected or at minimum warned about
101
+ await expect(
102
+ t.mutation(api.mutations.issueAuthorizationCode, {
103
+ userId: "user123",
104
+ clientId: client.clientId,
105
+ scopes: ["openid"],
106
+ redirectUri: "https://example.com/callback",
107
+ codeChallenge: "test-verifier",
108
+ codeChallengeMethod: "plain",
109
+ })
110
+ ).rejects.toThrow(/plain.*not.*support/i);
111
+ });
112
+ });
113
+
114
+ describe("Section 4.1.2 - Authorization Code Properties", () => {
115
+ test("Authorization code MUST expire shortly (10 minutes max RECOMMENDED)", async () => {
116
+ const t = convexTest(schema, modules);
117
+
118
+ const client = await t.mutation(api.clientManagement.registerClient, {
119
+ name: "Test Client",
120
+ type: "public",
121
+ redirectUris: ["https://example.com/callback"],
122
+ scopes: ["openid"],
123
+ });
124
+
125
+ await t.mutation(api.mutations.issueAuthorizationCode, {
126
+ userId: "user123",
127
+ clientId: client.clientId,
128
+ scopes: ["openid"],
129
+ redirectUri: "https://example.com/callback",
130
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
131
+ codeChallengeMethod: "S256",
132
+ });
133
+
134
+ // Advance time by 11 minutes
135
+ await t.run(async (ctx) => {
136
+ await ctx.scheduler.runAfter(11 * 60 * 1000, internal.mutations.cleanupExpired, {});
137
+ });
138
+
139
+ // Code should be expired - attempting to consume it should fail
140
+ // Note: This test may need adjustment based on actual expiration implementation
141
+ });
142
+
143
+ test("Authorization code MUST be bound to client_id, code_challenge, and redirect_uri", async () => {
144
+ const t = convexTest(schema, modules);
145
+
146
+ const client = await t.mutation(api.clientManagement.registerClient, {
147
+ name: "Test Client",
148
+ type: "public",
149
+ redirectUris: ["https://example.com/callback"],
150
+ scopes: ["openid"],
151
+ });
152
+
153
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
154
+ userId: "user123",
155
+ clientId: client.clientId,
156
+ scopes: ["openid"],
157
+ redirectUri: "https://example.com/callback",
158
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
159
+ codeChallengeMethod: "S256",
160
+ });
161
+
162
+ // Attempt to use code with different redirect_uri should fail
163
+ await expect(
164
+ t.mutation(api.mutations.consumeAuthCode, {
165
+ code: authCode,
166
+ clientId: client.clientId,
167
+ redirectUri: "https://different.com/callback", // Wrong redirect_uri
168
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
169
+ })
170
+ ).rejects.toThrow(/redirect.*uri/i);
171
+ });
172
+ });
173
+
174
+ describe("Section 4.1.3 - Token Endpoint (Authorization Code)", () => {
175
+ test("MUST return access token only once for a given authorization code", async () => {
176
+ const t = convexTest(schema, modules);
177
+
178
+ const client = await t.mutation(api.clientManagement.registerClient, {
179
+ name: "Test Client",
180
+ type: "public",
181
+ redirectUris: ["https://example.com/callback"],
182
+ scopes: ["openid"],
183
+ });
184
+
185
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
186
+ userId: "user123",
187
+ clientId: client.clientId,
188
+ scopes: ["openid"],
189
+ redirectUri: "https://example.com/callback",
190
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
191
+ codeChallengeMethod: "S256",
192
+ });
193
+
194
+ // First consumption should succeed
195
+ const result1 = await t.mutation(api.mutations.consumeAuthCode, {
196
+ code: authCode,
197
+ clientId: client.clientId,
198
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
199
+ redirectUri: "https://example.com/callback",
200
+ });
201
+
202
+ expect(result1.userId).toBeDefined();
203
+
204
+ // Second consumption MUST fail
205
+ const result2: any = await t.mutation(api.mutations.consumeAuthCode, {
206
+ code: authCode,
207
+ clientId: client.clientId,
208
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
209
+ redirectUri: "https://example.com/callback",
210
+ });
211
+ expect(result2.error).toBe("authorization_code_reuse_detected");
212
+ });
213
+
214
+ test("MUST verify code_verifier parameter is present if code_challenge was sent", async () => {
215
+ const t = convexTest(schema, modules);
216
+
217
+ const client = await t.mutation(api.clientManagement.registerClient, {
218
+ name: "Test Client",
219
+ type: "public",
220
+ redirectUris: ["https://example.com/callback"],
221
+ scopes: ["openid"],
222
+ });
223
+
224
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
225
+ userId: "user123",
226
+ clientId: client.clientId,
227
+ scopes: ["openid"],
228
+ redirectUri: "https://example.com/callback",
229
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
230
+ codeChallengeMethod: "S256",
231
+ });
232
+
233
+ // Attempt to consume without code_verifier should fail
234
+ await expect(
235
+ t.mutation(api.mutations.consumeAuthCode, {
236
+ code: authCode,
237
+ clientId: client.clientId,
238
+ redirectUri: "https://example.com/callback",
239
+ codeVerifier: "", // Missing code_verifier
240
+ })
241
+ ).rejects.toThrow();
242
+ });
243
+
244
+ test("MUST verify code_verifier matches code_challenge", async () => {
245
+ const t = convexTest(schema, modules);
246
+
247
+ const client = await t.mutation(api.clientManagement.registerClient, {
248
+ name: "Test Client",
249
+ type: "public",
250
+ redirectUris: ["https://example.com/callback"],
251
+ scopes: ["openid"],
252
+ });
253
+
254
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
255
+ userId: "user123",
256
+ clientId: client.clientId,
257
+ scopes: ["openid"],
258
+ redirectUri: "https://example.com/callback",
259
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
260
+ codeChallengeMethod: "S256",
261
+ });
262
+
263
+ // Wrong code_verifier should fail
264
+ await expect(
265
+ t.mutation(api.mutations.consumeAuthCode, {
266
+ code: authCode,
267
+ clientId: client.clientId,
268
+ redirectUri: "https://example.com/callback",
269
+ codeVerifier: "wrong-verifier-value",
270
+ })
271
+ ).rejects.toThrow(/verifier/i);
272
+ });
273
+ });
274
+
275
+ describe("Section 2.3 & 4.1.1 - Redirect URI Validation", () => {
276
+ test("MUST use exact string comparison for redirect_uri validation", async () => {
277
+ const t = convexTest(schema, modules);
278
+
279
+ const client = await t.mutation(api.clientManagement.registerClient, {
280
+ name: "Test Client",
281
+ type: "public",
282
+ redirectUris: ["https://example.com/callback"],
283
+ scopes: ["openid"],
284
+ });
285
+
286
+ // Exact match should work
287
+ const authCode1 = await t.mutation(api.mutations.issueAuthorizationCode, {
288
+ userId: "user123",
289
+ clientId: client.clientId,
290
+ scopes: ["openid"],
291
+ redirectUri: "https://example.com/callback",
292
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
293
+ codeChallengeMethod: "S256",
294
+ });
295
+ expect(authCode1).toBeDefined();
296
+
297
+ // Different path should fail
298
+ await expect(
299
+ t.mutation(api.mutations.issueAuthorizationCode, {
300
+ userId: "user123",
301
+ clientId: client.clientId,
302
+ scopes: ["openid"],
303
+ redirectUri: "https://example.com/different",
304
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
305
+ codeChallengeMethod: "S256",
306
+ })
307
+ ).rejects.toThrow(/redirect/i);
308
+
309
+ // Additional query parameter should fail (no substring matching)
310
+ await expect(
311
+ t.mutation(api.mutations.issueAuthorizationCode, {
312
+ userId: "user123",
313
+ clientId: client.clientId,
314
+ scopes: ["openid"],
315
+ redirectUri: "https://example.com/callback?extra=param",
316
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
317
+ codeChallengeMethod: "S256",
318
+ })
319
+ ).rejects.toThrow(/redirect/i);
320
+ });
321
+
322
+ test("MUST allow variable port numbers for localhost URIs (native apps)", async () => {
323
+ const t = convexTest(schema, modules);
324
+
325
+ // Register client with localhost redirect (ポート省略で登録)
326
+ const client = await t.mutation(api.clientManagement.registerClient, {
327
+ name: "Native App",
328
+ type: "public",
329
+ redirectUris: ["http://127.0.0.1/callback"],
330
+ scopes: ["openid"],
331
+ });
332
+
333
+ // 異なるポートでも許可されるべき
334
+ const authCode1 = await t.mutation(api.mutations.issueAuthorizationCode, {
335
+ userId: "user123",
336
+ clientId: client.clientId,
337
+ scopes: ["openid"],
338
+ redirectUri: "http://127.0.0.1:8080/callback",
339
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
340
+ codeChallengeMethod: "S256",
341
+ });
342
+ expect(authCode1).toBeDefined();
343
+
344
+ const authCode2 = await t.mutation(api.mutations.issueAuthorizationCode, {
345
+ userId: "user123",
346
+ clientId: client.clientId,
347
+ scopes: ["openid"],
348
+ redirectUri: "http://127.0.0.1:9090/callback",
349
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
350
+ codeChallengeMethod: "S256",
351
+ });
352
+ expect(authCode2).toBeDefined();
353
+ });
354
+ });
355
+
356
+ describe("Section 3.2.3 - Token Response", () => {
357
+ test("Refresh tokens MUST be bound to scope and resource servers", async () => {
358
+ const t = convexTest(schema, modules);
359
+
360
+ const client = await t.mutation(api.clientManagement.registerClient, {
361
+ name: "Test Client",
362
+ type: "confidential",
363
+ redirectUris: ["https://example.com/callback"],
364
+ scopes: ["openid", "offline_access"],
365
+ });
366
+
367
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
368
+ userId: "user123",
369
+ clientId: client.clientId,
370
+ scopes: ["openid", "offline_access"],
371
+ redirectUri: "https://example.com/callback",
372
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
373
+ codeChallengeMethod: "S256",
374
+ });
375
+
376
+ const result = await t.mutation(api.mutations.consumeAuthCode, {
377
+ code: authCode,
378
+ clientId: client.clientId,
379
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
380
+ redirectUri: "https://example.com/callback",
381
+ });
382
+
383
+ expect(result.userId).toBeDefined();
384
+ // Note: This mutation returns userId and other metadata, not tokens directly
385
+ // Token issuance happens at handler level
386
+ });
387
+ });
388
+
389
+ describe("Section 4.3 - Refresh Token", () => {
390
+ test("MUST maintain binding between refresh token and client", async () => {
391
+ const t = convexTest(schema, modules);
392
+
393
+ const client1 = await t.mutation(api.clientManagement.registerClient, {
394
+ name: "Client 1",
395
+ type: "confidential",
396
+ redirectUris: ["https://example.com/callback"],
397
+ scopes: ["openid", "offline_access"],
398
+ });
399
+
400
+ // Create another client to ensure token isolation
401
+ await t.mutation(api.clientManagement.registerClient, {
402
+ name: "Client 2",
403
+ type: "confidential",
404
+ redirectUris: ["https://example.com/callback"],
405
+ scopes: ["openid", "offline_access"],
406
+ });
407
+
408
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
409
+ userId: "user123",
410
+ clientId: client1.clientId,
411
+ scopes: ["openid", "offline_access"],
412
+ redirectUri: "https://example.com/callback",
413
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
414
+ codeChallengeMethod: "S256",
415
+ });
416
+
417
+ const result = await t.mutation(api.mutations.consumeAuthCode, {
418
+ code: authCode,
419
+ clientId: client1.clientId,
420
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
421
+ redirectUri: "https://example.com/callback",
422
+ });
423
+
424
+ expect(result.userId).toBeDefined();
425
+ // Note: Client binding is tested at handler level where tokens are issued
426
+ });
427
+
428
+ test("Public clients SHOULD implement refresh token rotation", async () => {
429
+ const t = convexTest(schema, modules);
430
+
431
+ const client = await t.mutation(api.clientManagement.registerClient, {
432
+ name: "Public Client",
433
+ type: "public",
434
+ redirectUris: ["https://example.com/callback"],
435
+ scopes: ["openid", "offline_access"],
436
+ });
437
+
438
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
439
+ userId: "user123",
440
+ clientId: client.clientId,
441
+ scopes: ["openid", "offline_access"],
442
+ redirectUri: "https://example.com/callback",
443
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
444
+ codeChallengeMethod: "S256",
445
+ });
446
+
447
+ const result1 = await t.mutation(api.mutations.consumeAuthCode, {
448
+ code: authCode,
449
+ clientId: client.clientId,
450
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
451
+ redirectUri: "https://example.com/callback",
452
+ });
453
+
454
+ expect(result1.userId).toBeDefined();
455
+ // Note: Refresh token rotation is tested at handler level where tokens are issued
456
+ });
457
+ });
458
+
459
+ describe("Section 5.1 - Bearer Token Usage", () => {
460
+ test("MUST NOT send access token in URI query parameter", async () => {
461
+ // This is primarily a client requirement, but we can test that
462
+ // the resource server should ignore tokens in query parameters
463
+
464
+ // Note: This test verifies documentation/guidance rather than code behavior
465
+ // Implementation should document that query parameter tokens are not supported
466
+ expect(true).toBe(true); // Placeholder - adjust based on implementation
467
+ });
468
+ });
469
+
470
+ describe("Section 10 - OAuth 2.0 Differences", () => {
471
+ test("Implicit grant (response_type=token) MUST NOT be supported", async () => {
472
+ // OAuth 2.1 removes the implicit grant
473
+ // Authorization server should reject response_type=token
474
+
475
+ // Note: This test should verify that the handler rejects implicit flow
476
+ // Implementation-specific based on how authorization endpoint is exposed
477
+ expect(true).toBe(true); // Placeholder - adjust based on implementation
478
+ });
479
+
480
+ test("Password grant MUST NOT be supported", async () => {
481
+ // OAuth 2.1 removes the resource owner password credentials grant
482
+
483
+ // Note: Implementation does not expose password grant
484
+ expect(true).toBe(true); // Placeholder - verify no password grant implementation
485
+ });
486
+
487
+ test("RFC 6749 4.1.3: redirect_uri REQUIRED if included in authorization request", async () => {
488
+ const t = convexTest(schema, modules);
489
+
490
+ const client = await t.mutation(api.clientManagement.registerClient, {
491
+ name: "OAuth 2.1 Client",
492
+ type: "public",
493
+ redirectUris: ["https://example.com/callback"],
494
+ scopes: ["openid"],
495
+ });
496
+
497
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
498
+ userId: "user123",
499
+ clientId: client.clientId,
500
+ scopes: ["openid"],
501
+ redirectUri: "https://example.com/callback",
502
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
503
+ codeChallengeMethod: "S256",
504
+ });
505
+
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");
515
+
516
+ // redirect_uri付きなら成功
517
+ const authCode2 = await t.mutation(api.mutations.issueAuthorizationCode, {
518
+ userId: "user123",
519
+ clientId: client.clientId,
520
+ scopes: ["openid"],
521
+ redirectUri: "https://example.com/callback",
522
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
523
+ codeChallengeMethod: "S256",
524
+ });
525
+
526
+ const codeData = await t.mutation(api.mutations.consumeAuthCode, {
527
+ code: authCode2,
528
+ clientId: client.clientId,
529
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
530
+ redirectUri: "https://example.com/callback",
531
+ });
532
+
533
+ expect(codeData.userId).toBeDefined();
534
+ });
535
+ });
536
+
537
+ describe("Section 4.2 - Client Credentials Grant", () => {
538
+ test("Client credentials grant MUST only be used by confidential clients", async () => {
539
+ const t = convexTest(schema, modules);
540
+
541
+ // Register public client
542
+ const publicClient = await t.mutation(api.clientManagement.registerClient, {
543
+ name: "Public Client",
544
+ type: "public",
545
+ redirectUris: ["https://example.com/callback"],
546
+ scopes: ["openid"],
547
+ });
548
+
549
+ // Public client should not be able to use client credentials grant
550
+ // Note: Implementation-specific - adjust based on actual client_credentials implementation
551
+ expect(publicClient.clientSecret).toBeUndefined();
552
+ });
553
+ });
554
+
555
+ describe("Scope Validation", () => {
556
+ test("MUST validate requested scopes against client's allowed scopes", async () => {
557
+ const t = convexTest(schema, modules);
558
+
559
+ const client = await t.mutation(api.clientManagement.registerClient, {
560
+ name: "Limited Client",
561
+ type: "public",
562
+ redirectUris: ["https://example.com/callback"],
563
+ scopes: ["openid"], // Only openid scope allowed
564
+ });
565
+
566
+ // Requesting disallowed scope should fail
567
+ await expect(
568
+ t.mutation(api.mutations.issueAuthorizationCode, {
569
+ userId: "user123",
570
+ clientId: client.clientId,
571
+ scopes: ["openid", "profile"], // profile not in allowed scopes
572
+ redirectUri: "https://example.com/callback",
573
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
574
+ codeChallengeMethod: "S256",
575
+ })
576
+ ).rejects.toThrow(/scope/i);
577
+ });
578
+
579
+ test("RFC Line 1251: New refresh token MUST have identical scope", async () => {
580
+ const t = convexTest(schema, modules);
581
+
582
+ const client = await t.mutation(api.clientManagement.registerClient, {
583
+ name: "Test Client",
584
+ type: "confidential",
585
+ redirectUris: ["https://example.com/callback"],
586
+ scopes: ["openid", "profile", "offline_access"],
587
+ });
588
+
589
+ // 最初にリフレッシュトークンを保存
590
+ const oldRefreshToken = "test-refresh-token-123";
591
+ await t.mutation(api.mutations.saveTokens, {
592
+ accessToken: "test-access-token",
593
+ refreshToken: oldRefreshToken,
594
+ clientId: client.clientId,
595
+ userId: "user123",
596
+ scopes: ["openid", "profile", "offline_access"],
597
+ expiresAt: Date.now() + 3600000,
598
+ refreshTokenExpiresAt: Date.now() + 2592000000,
599
+ });
600
+
601
+ // RTローテーションでスコープ縮小を試行 → エラー
602
+ await expect(
603
+ t.mutation(api.mutations.rotateRefreshToken, {
604
+ oldRefreshToken: oldRefreshToken,
605
+ accessToken: "new-access-token",
606
+ refreshToken: "new-refresh-token",
607
+ clientId: client.clientId,
608
+ userId: "user123",
609
+ scopes: ["openid", "profile"], // offline_accessを削除(縮小)
610
+ expiresAt: Date.now() + 3600000,
611
+ refreshTokenExpiresAt: Date.now() + 2592000000,
612
+ })
613
+ ).rejects.toThrow(/scope/i);
614
+ });
615
+
616
+ test("RFC Line 1217: refresh_token grant scope MUST NOT exceed original", async () => {
617
+ const t = convexTest(schema, modules);
618
+
619
+ const client = await t.mutation(api.clientManagement.registerClient, {
620
+ name: "Test Client",
621
+ type: "confidential",
622
+ redirectUris: ["https://example.com/callback"],
623
+ scopes: ["openid", "profile", "email", "offline_access"],
624
+ });
625
+
626
+ // 最初にリフレッシュトークンを保存(emailスコープなし)
627
+ const oldRefreshToken = "test-refresh-token-456";
628
+ await t.mutation(api.mutations.saveTokens, {
629
+ accessToken: "test-access-token",
630
+ refreshToken: oldRefreshToken,
631
+ clientId: client.clientId,
632
+ userId: "user123",
633
+ scopes: ["openid", "profile", "offline_access"], // emailは含まない
634
+ expiresAt: Date.now() + 3600000,
635
+ refreshTokenExpiresAt: Date.now() + 2592000000,
636
+ });
637
+
638
+ // Mutation level test: rotateRefreshToken with expanded scopes should fail
639
+ await expect(
640
+ t.mutation(api.mutations.rotateRefreshToken, {
641
+ oldRefreshToken: oldRefreshToken,
642
+ accessToken: "new-access-token",
643
+ refreshToken: "new-refresh-token",
644
+ clientId: client.clientId,
645
+ userId: "user123",
646
+ scopes: ["openid", "profile", "email", "offline_access"], // email追加(拡大)
647
+ expiresAt: Date.now() + 3600000,
648
+ refreshTokenExpiresAt: Date.now() + 2592000000,
649
+ })
650
+ ).rejects.toThrow(/scope/i);
651
+ });
652
+ });
653
+
654
+ describe("Section 4.1.3 - Authorization Code Replay Detection", () => {
655
+ test("RFC Line 1136: MUST deny authorization code reuse and SHOULD revoke tokens", async () => {
656
+ const t = convexTest(schema, modules);
657
+
658
+ // Register client
659
+ const client = await t.mutation(api.clientManagement.registerClient, {
660
+ name: "Test Client",
661
+ type: "public",
662
+ redirectUris: ["https://example.com/callback"],
663
+ scopes: ["openid", "profile"],
664
+ });
665
+
666
+ // Issue authorization code
667
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
668
+ userId: "user123",
669
+ clientId: client.clientId,
670
+ scopes: ["openid", "profile"],
671
+ redirectUri: "https://example.com/callback",
672
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
673
+ codeChallengeMethod: "S256",
674
+ });
675
+
676
+ // First use - should succeed
677
+ const codeData = await t.mutation(api.mutations.consumeAuthCode, {
678
+ code: authCode,
679
+ clientId: client.clientId,
680
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
681
+ redirectUri: "https://example.com/callback",
682
+ });
683
+
684
+ expect(codeData.userId).toBe("user123");
685
+ expect(codeData.codeHash).toBeDefined(); // Verify codeHash is returned
686
+
687
+ // Save tokens using the returned codeHash
688
+ await t.mutation(api.mutations.saveTokens, {
689
+ accessToken: "test-access-token",
690
+ refreshToken: "test-refresh-token",
691
+ clientId: client.clientId,
692
+ userId: "user123",
693
+ scopes: ["openid", "profile"],
694
+ expiresAt: Date.now() + 3600000,
695
+ refreshTokenExpiresAt: Date.now() + 2592000000,
696
+ authorizationCode: codeData.codeHash,
697
+ });
698
+
699
+ // Verify token was saved with correct authorizationCode
700
+ const tokensBefore = await t.run(async (ctx) => {
701
+ return await ctx.db
702
+ .query("oauthTokens")
703
+ .withIndex("by_authorization_code", (q) =>
704
+ q.eq("authorizationCode", codeData.codeHash)
705
+ )
706
+ .collect();
707
+ });
708
+ expect(tokensBefore.length).toBe(1); // Token should exist before replay
709
+
710
+ // Verify code is marked as used
711
+ const codeAfterFirstUse = await t.run(async (ctx) => {
712
+ const { hashToken } = await import("../token_security");
713
+ const hash = await hashToken(authCode);
714
+ return await ctx.db
715
+ .query("oauthCodes")
716
+ .withIndex("by_code", (q) => q.eq("code", hash))
717
+ .unique();
718
+ });
719
+ expect(codeAfterFirstUse?.usedAt).toBeDefined(); // Code should be marked as used
720
+
721
+ // Second use - should return error status (not throw, to allow token deletion to commit)
722
+ const secondUseResult: any = await t.mutation(api.mutations.consumeAuthCode, {
723
+ code: authCode,
724
+ clientId: client.clientId,
725
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
726
+ redirectUri: "https://example.com/callback",
727
+ });
728
+
729
+ expect(secondUseResult.error).toBe("authorization_code_reuse_detected");
730
+ expect(secondUseResult.revokedTokens).toBe(1);
731
+
732
+ // Verify that tokens were revoked
733
+ const tokensAfter = await t.run(async (ctx) => {
734
+ return await ctx.db
735
+ .query("oauthTokens")
736
+ .withIndex("by_authorization_code", (q) =>
737
+ q.eq("authorizationCode", codeData.codeHash)
738
+ )
739
+ .collect();
740
+ });
741
+
742
+ expect(tokensAfter.length).toBe(0); // All tokens should be deleted
743
+ });
744
+
745
+ test("RFC Line 1136: Single use enforcement - code is marked as used", async () => {
746
+ const t = convexTest(schema, modules);
747
+
748
+ const client = await t.mutation(api.clientManagement.registerClient, {
749
+ name: "Test Client",
750
+ type: "public",
751
+ redirectUris: ["https://example.com/callback"],
752
+ scopes: ["openid"],
753
+ });
754
+
755
+ const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
756
+ userId: "user123",
757
+ clientId: client.clientId,
758
+ scopes: ["openid"],
759
+ redirectUri: "https://example.com/callback",
760
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
761
+ codeChallengeMethod: "S256",
762
+ });
763
+
764
+ // First use
765
+ await t.mutation(api.mutations.consumeAuthCode, {
766
+ code: authCode,
767
+ clientId: client.clientId,
768
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
769
+ redirectUri: "https://example.com/callback",
770
+ });
771
+
772
+ // Verify code is marked as used in database
773
+ const usedCode = await t.run(async (ctx) => {
774
+ const codeHash = await import("../token_security").then((m) =>
775
+ m.hashToken(authCode)
776
+ );
777
+ return await ctx.db
778
+ .query("oauthCodes")
779
+ .withIndex("by_code", (q) => q.eq("code", codeHash))
780
+ .unique();
781
+ });
782
+
783
+ expect(usedCode).toBeDefined();
784
+ expect(usedCode?.usedAt).toBeDefined();
785
+ expect(usedCode?.usedAt).toBeGreaterThan(0);
786
+ });
787
+ });
788
+ });