@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,3310 @@
1
+ import { describe, test, expect, beforeEach, vi } from "vitest";
2
+ import { convexTest } from "convex-test";
3
+ import { api } from "../_generated/api";
4
+ import schema from "../schema";
5
+ import { hashToken } from "../token_security";
6
+ import { authorizeHandler, registerHandler, tokenHandler, userInfoHandler, oauthProtectedResourceHandler, jwksHandler, openIdConfigurationHandler } from "../handlers";
7
+ import { SignJWT, generateKeyPair, exportJWK, exportPKCS8 } from "jose";
8
+ import type { OAuthComponentAPI } from "../handlers";
9
+ import type { OAuthConfig } from "../../lib/oauth";
10
+
11
+ const modules = import.meta.glob("../**/*.ts");
12
+
13
+ describe("OAuth 2.1 Flow", () => {
14
+ let t: ReturnType<typeof convexTest>;
15
+
16
+ beforeEach(async () => {
17
+ t = convexTest(schema, modules);
18
+ });
19
+
20
+ // ==========================================
21
+ // Phase 1: Critical Security Tests
22
+ // ==========================================
23
+
24
+ test("Client Registration: Verify Secret Hashing", async () => {
25
+ const redirectUri = "https://client.example.com/callback";
26
+ const result = await t.mutation(api.clientManagement.registerClient, {
27
+ name: "Test Confidential Client",
28
+ redirectUris: [redirectUri],
29
+ scopes: ["openid", "profile"],
30
+ type: "confidential",
31
+ });
32
+
33
+ expect(result.clientId).toBeDefined();
34
+ expect(result.clientSecret).toBeDefined();
35
+
36
+ // Check DB for Hash
37
+ const clientInDb = await t.query(api.queries.getClient, {
38
+ clientId: result.clientId
39
+ });
40
+ expect(clientInDb).toBeDefined();
41
+ // Secret in DB should NOT be the plain secret returned
42
+ expect(clientInDb?.clientSecret).not.toBe(result.clientSecret);
43
+ // Secret in DB should be defined
44
+ expect(clientInDb?.clientSecret).toBeDefined();
45
+ expect(clientInDb?.clientSecret!.length).toBeGreaterThan(50); // Bcrypt hash is long
46
+ });
47
+
48
+ test("Client Secret Verification", async () => {
49
+ const result = await t.mutation(api.clientManagement.registerClient, {
50
+ name: "Test Client",
51
+ redirectUris: ["https://cb"],
52
+ scopes: [],
53
+ type: "confidential",
54
+ });
55
+
56
+ // Correct Secret
57
+ const isValid = await t.mutation(api.clientManagement.verifyClientSecret, {
58
+ clientId: result.clientId,
59
+ clientSecret: result.clientSecret!,
60
+ });
61
+ expect(isValid).toBe(true);
62
+
63
+ // Incorrect Secret
64
+ const isInvalid = await t.mutation(api.clientManagement.verifyClientSecret, {
65
+ clientId: result.clientId,
66
+ clientSecret: "wrong-secret",
67
+ });
68
+ expect(isInvalid).toBe(false);
69
+ });
70
+
71
+ test("Authorization Code: Replay Attack Prevention", async () => {
72
+ // In component, userId is just a string (not Id<"users">)
73
+ const userId = "test-user-id";
74
+ const client = await t.mutation(api.clientManagement.registerClient, {
75
+ name: "Flow Client",
76
+ redirectUris: ["https://cb"],
77
+ scopes: [],
78
+ type: "public",
79
+ });
80
+
81
+ // Issue Code
82
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
83
+ clientId: client.clientId,
84
+ userId,
85
+ redirectUri: "https://cb",
86
+ scopes: [],
87
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", // Changed to S256
88
+ codeChallengeMethod: "S256", // Changed from "plain" to "S256"
89
+ });
90
+
91
+ // 1. First Consumption (Success)
92
+ await t.mutation(api.mutations.consumeAuthCode, {
93
+ code,
94
+ clientId: client.clientId,
95
+ redirectUri: "https://cb",
96
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", // Changed to match S256
97
+ });
98
+
99
+ // 2. Second Consumption (Fail)
100
+ const secondAttempt: any = await t.mutation(api.mutations.consumeAuthCode, {
101
+ code,
102
+ clientId: client.clientId,
103
+ redirectUri: "https://cb",
104
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", // Changed to match S256
105
+ });
106
+ expect(secondAttempt.error).toBe("authorization_code_reuse_detected");
107
+ });
108
+
109
+ test("Authorization Code: Wrong client does not consume code", async () => {
110
+ const userId = "test-user-id";
111
+ const clientA = await t.mutation(api.clientManagement.registerClient, {
112
+ name: "Client A",
113
+ redirectUris: ["https://cb"],
114
+ scopes: [],
115
+ type: "public",
116
+ });
117
+ const clientB = await t.mutation(api.clientManagement.registerClient, {
118
+ name: "Client B",
119
+ redirectUris: ["https://cb"],
120
+ scopes: [],
121
+ type: "public",
122
+ });
123
+
124
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
125
+ clientId: clientA.clientId,
126
+ userId,
127
+ redirectUri: "https://cb",
128
+ scopes: [],
129
+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", // Changed to S256
130
+ codeChallengeMethod: "S256", // Changed from "plain" to "S256"
131
+ });
132
+
133
+ await expect(t.mutation(api.mutations.consumeAuthCode, {
134
+ code,
135
+ clientId: clientB.clientId,
136
+ redirectUri: "https://cb",
137
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", // Changed to match S256
138
+ })).rejects.toThrow();
139
+
140
+ await t.mutation(api.mutations.consumeAuthCode, {
141
+ code,
142
+ clientId: clientA.clientId,
143
+ redirectUri: "https://cb",
144
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", // Changed to match S256
145
+ });
146
+ });
147
+
148
+ test("PKCE: S256 Calculation Helper", async () => {
149
+ // Verify our test helper usage for S256
150
+ const codeVerifier = "abcdefghijklmnopqrstuvwxyz1234567890abcdef"; // > 43 chars
151
+ const encoder = new TextEncoder();
152
+ const data = encoder.encode(codeVerifier);
153
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
154
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
155
+ const codeChallenge = btoa(String.fromCharCode(...hashArray))
156
+ .replace(/\+/g, '-')
157
+ .replace(/\//g, '_')
158
+ .replace(/=+$/, '');
159
+
160
+ expect(codeChallenge).toBeDefined();
161
+ });
162
+
163
+ // ==========================================
164
+ // Phase 1b: Handler Error Mapping
165
+ // ==========================================
166
+
167
+ test("Token Handler: invalid_grant is not mapped to invalid_request", async () => {
168
+ const config: OAuthConfig = {
169
+ privateKey: "dummy",
170
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
171
+ siteUrl: "https://example.com",
172
+ };
173
+ const apiStub: OAuthComponentAPI = {
174
+ queries: {
175
+ getClient: async () => ({
176
+ clientId: "client",
177
+ type: "public",
178
+ redirectUris: ["https://cb"],
179
+ allowedScopes: [],
180
+ }),
181
+ getRefreshToken: async () => null,
182
+ getTokensByUser: async () => [],
183
+ },
184
+ mutations: {
185
+ issueAuthorizationCode: async () => "",
186
+ consumeAuthCode: async () => {
187
+ throw new Error("invalid_grant");
188
+ },
189
+ saveTokens: async () => undefined,
190
+ rotateRefreshToken: async () => undefined,
191
+ upsertAuthorization: async () => "",
192
+ updateAuthorizationLastUsed: async () => undefined,
193
+ },
194
+ clientManagement: {
195
+ registerClient: async () => ({
196
+ clientId: "client",
197
+ clientIdIssuedAt: 0,
198
+ }),
199
+ verifyClientSecret: async () => true,
200
+ },
201
+ };
202
+
203
+ const request = new Request("https://example.com/oauth/token", {
204
+ method: "POST",
205
+ body: new URLSearchParams({
206
+ grant_type: "authorization_code",
207
+ client_id: "client",
208
+ code: "code",
209
+ redirect_uri: "https://cb",
210
+ code_verifier: "verifier",
211
+ }),
212
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
213
+ });
214
+
215
+ const response = await tokenHandler({} as any, request, config, apiStub);
216
+ expect(response.status).toBe(400);
217
+ const body = await response.json();
218
+ expect(body.error).toBe("invalid_grant");
219
+ });
220
+
221
+ test("Token Handler: invalid_client is not mapped to invalid_request", async () => {
222
+ const config: OAuthConfig = {
223
+ privateKey: "dummy",
224
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
225
+ siteUrl: "https://example.com",
226
+ };
227
+ const apiStub: OAuthComponentAPI = {
228
+ queries: {
229
+ getClient: async () => ({
230
+ clientId: "client",
231
+ type: "public",
232
+ redirectUris: ["https://cb"],
233
+ allowedScopes: [],
234
+ }),
235
+ getRefreshToken: async () => null,
236
+ getTokensByUser: async () => [],
237
+ },
238
+ mutations: {
239
+ issueAuthorizationCode: async () => "",
240
+ consumeAuthCode: async () => {
241
+ throw new Error("invalid_client");
242
+ },
243
+ saveTokens: async () => undefined,
244
+ rotateRefreshToken: async () => undefined,
245
+ upsertAuthorization: async () => "",
246
+ updateAuthorizationLastUsed: async () => undefined,
247
+ },
248
+ clientManagement: {
249
+ registerClient: async () => ({
250
+ clientId: "client",
251
+ clientIdIssuedAt: 0,
252
+ }),
253
+ verifyClientSecret: async () => true,
254
+ },
255
+ };
256
+
257
+ const request = new Request("https://example.com/oauth/token", {
258
+ method: "POST",
259
+ body: new URLSearchParams({
260
+ grant_type: "authorization_code",
261
+ client_id: "client",
262
+ code: "code",
263
+ redirect_uri: "https://cb",
264
+ code_verifier: "verifier",
265
+ }),
266
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
267
+ });
268
+
269
+ const response = await tokenHandler({} as any, request, config, apiStub);
270
+ expect(response.status).toBe(401);
271
+ const body = await response.json();
272
+ expect(body.error).toBe("invalid_client");
273
+ });
274
+
275
+ test("Token Handler: sets no-store headers on error responses", async () => {
276
+ const config: OAuthConfig = {
277
+ privateKey: "dummy",
278
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
279
+ siteUrl: "https://example.com",
280
+ };
281
+ const apiStub: OAuthComponentAPI = {
282
+ queries: {
283
+ getClient: async () => null,
284
+ getRefreshToken: async () => null,
285
+ getTokensByUser: async () => [],
286
+ },
287
+ mutations: {
288
+ issueAuthorizationCode: async () => "",
289
+ consumeAuthCode: async () => {
290
+ throw new Error("invalid_grant");
291
+ },
292
+ saveTokens: async () => undefined,
293
+ rotateRefreshToken: async () => undefined,
294
+ upsertAuthorization: async () => "",
295
+ updateAuthorizationLastUsed: async () => undefined,
296
+ },
297
+ clientManagement: {
298
+ registerClient: async () => ({
299
+ clientId: "client",
300
+ clientIdIssuedAt: 0,
301
+ }),
302
+ verifyClientSecret: async () => true,
303
+ },
304
+ };
305
+
306
+ const request = new Request("https://example.com/oauth/token", {
307
+ method: "POST",
308
+ body: new URLSearchParams({
309
+ grant_type: "authorization_code",
310
+ code: "code",
311
+ redirect_uri: "https://cb",
312
+ code_verifier: "verifier",
313
+ }),
314
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
315
+ });
316
+
317
+ const response = await tokenHandler({} as any, request, config, apiStub);
318
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
319
+ expect(response.headers.get("Pragma")).toBe("no-cache");
320
+ });
321
+
322
+ test("Token Handler: authorization_code grant issues tokens with ID token and refresh token", async () => {
323
+ // Generate valid RSA key pair for JWT signing
324
+ const { privateKey, publicKey } = await generateKeyPair("RS256");
325
+ const privateKeyPem = await exportPKCS8(privateKey);
326
+ const jwk = await exportJWK(publicKey);
327
+ const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
328
+
329
+ const config: OAuthConfig = {
330
+ privateKey: privateKeyPem,
331
+ jwks,
332
+ siteUrl: "https://example.com",
333
+ };
334
+
335
+ const apiStub: OAuthComponentAPI = {
336
+ queries: {
337
+ getClient: async () => ({
338
+ clientId: "client-1",
339
+ type: "public",
340
+ redirectUris: ["https://cb"],
341
+ allowedScopes: ["openid", "profile", "offline_access"],
342
+ }),
343
+ getRefreshToken: async () => null,
344
+ getTokensByUser: async () => [],
345
+ },
346
+ mutations: {
347
+ issueAuthorizationCode: async () => "",
348
+ consumeAuthCode: async () => ({
349
+ userId: "user-123",
350
+ scopes: ["openid", "profile", "offline_access"],
351
+ codeChallenge: "challenge",
352
+ codeChallengeMethod: "S256",
353
+ redirectUri: "https://cb",
354
+ nonce: "test-nonce-123",
355
+ codeHash: "test-code-hash",
356
+ }),
357
+ saveTokens: async () => undefined,
358
+ rotateRefreshToken: async () => undefined,
359
+ upsertAuthorization: async () => "auth-id",
360
+ updateAuthorizationLastUsed: async () => undefined,
361
+ },
362
+ clientManagement: {
363
+ registerClient: async () => ({
364
+ clientId: "client-1",
365
+ clientIdIssuedAt: Date.now(),
366
+ }),
367
+ verifyClientSecret: async () => true,
368
+ },
369
+ };
370
+
371
+ const request = new Request("https://example.com/oauth/token", {
372
+ method: "POST",
373
+ body: new URLSearchParams({
374
+ grant_type: "authorization_code",
375
+ code: "test-code",
376
+ redirect_uri: "https://cb",
377
+ code_verifier: "verifier",
378
+ client_id: "client-1",
379
+ }),
380
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
381
+ });
382
+
383
+ const response = await tokenHandler({} as any, request, config, apiStub);
384
+
385
+ expect(response.status).toBe(200);
386
+ const body = await response.json();
387
+ expect(body.access_token).toBeDefined();
388
+ expect(body.token_type).toBe("Bearer");
389
+ expect(body.expires_in).toBe(3600);
390
+ expect(body.scope).toBe("openid profile offline_access");
391
+ expect(body.id_token).toBeDefined(); // OIDC ID token should be present
392
+ expect(body.refresh_token).toBeDefined(); // Refresh token should be present
393
+ });
394
+
395
+ test("Token Handler: confidential client requires client_secret", async () => {
396
+ const config: OAuthConfig = {
397
+ privateKey: "dummy",
398
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
399
+ siteUrl: "https://example.com",
400
+ };
401
+
402
+ const apiStub: OAuthComponentAPI = {
403
+ queries: {
404
+ getClient: async () => ({
405
+ clientId: "confidential-client",
406
+ type: "confidential",
407
+ redirectUris: ["https://cb"],
408
+ allowedScopes: ["openid"],
409
+ }),
410
+ getRefreshToken: async () => null,
411
+ getTokensByUser: async () => [],
412
+ },
413
+ mutations: {
414
+ issueAuthorizationCode: async () => "",
415
+ consumeAuthCode: async () => ({
416
+ userId: "user-123",
417
+ scopes: ["openid"],
418
+ codeChallenge: "challenge",
419
+ codeChallengeMethod: "S256",
420
+ redirectUri: "https://cb",
421
+ codeHash: "test-code-hash",
422
+ }),
423
+ saveTokens: async () => undefined,
424
+ rotateRefreshToken: async () => undefined,
425
+ upsertAuthorization: async () => "auth-id",
426
+ updateAuthorizationLastUsed: async () => undefined,
427
+ },
428
+ clientManagement: {
429
+ registerClient: async () => ({
430
+ clientId: "confidential-client",
431
+ clientIdIssuedAt: Date.now(),
432
+ }),
433
+ verifyClientSecret: async () => true,
434
+ },
435
+ };
436
+
437
+ const request = new Request("https://example.com/oauth/token", {
438
+ method: "POST",
439
+ body: new URLSearchParams({
440
+ grant_type: "authorization_code",
441
+ code: "test-code",
442
+ redirect_uri: "https://cb",
443
+ code_verifier: "verifier",
444
+ client_id: "confidential-client",
445
+ // No client_secret
446
+ }),
447
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
448
+ });
449
+
450
+ const response = await tokenHandler({} as any, request, config, apiStub);
451
+
452
+ expect(response.status).toBe(401);
453
+ const body = await response.json();
454
+ expect(body.error).toBe("invalid_client");
455
+ expect(body.error_description).toBe("client_secret required");
456
+ });
457
+
458
+ test("Token Handler: confidential client rejects invalid client_secret", async () => {
459
+ const config: OAuthConfig = {
460
+ privateKey: "dummy",
461
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
462
+ siteUrl: "https://example.com",
463
+ };
464
+
465
+ const apiStub: OAuthComponentAPI = {
466
+ queries: {
467
+ getClient: async () => ({
468
+ clientId: "confidential-client",
469
+ type: "confidential",
470
+ redirectUris: ["https://cb"],
471
+ allowedScopes: ["openid"],
472
+ }),
473
+ getRefreshToken: async () => null,
474
+ getTokensByUser: async () => [],
475
+ },
476
+ mutations: {
477
+ issueAuthorizationCode: async () => "",
478
+ consumeAuthCode: async () => ({
479
+ userId: "user-123",
480
+ scopes: ["openid"],
481
+ codeChallenge: "challenge",
482
+ codeChallengeMethod: "S256",
483
+ redirectUri: "https://cb",
484
+ codeHash: "test-code-hash",
485
+ }),
486
+ saveTokens: async () => undefined,
487
+ rotateRefreshToken: async () => undefined,
488
+ upsertAuthorization: async () => "auth-id",
489
+ updateAuthorizationLastUsed: async () => undefined,
490
+ },
491
+ clientManagement: {
492
+ registerClient: async () => ({
493
+ clientId: "confidential-client",
494
+ clientIdIssuedAt: Date.now(),
495
+ }),
496
+ verifyClientSecret: async () => false, // Invalid secret
497
+ },
498
+ };
499
+
500
+ const request = new Request("https://example.com/oauth/token", {
501
+ method: "POST",
502
+ body: new URLSearchParams({
503
+ grant_type: "authorization_code",
504
+ code: "test-code",
505
+ redirect_uri: "https://cb",
506
+ code_verifier: "verifier",
507
+ client_id: "confidential-client",
508
+ client_secret: "wrong-secret",
509
+ }),
510
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
511
+ });
512
+
513
+ const response = await tokenHandler({} as any, request, config, apiStub);
514
+
515
+ expect(response.status).toBe(401);
516
+ const body = await response.json();
517
+ expect(body.error).toBe("invalid_client");
518
+ expect(body.error_description).toBe("Invalid client secret");
519
+ });
520
+
521
+ test("Token Handler: authorization_code grant requires code, redirect_uri, and code_verifier", async () => {
522
+ const config: OAuthConfig = {
523
+ privateKey: "dummy",
524
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
525
+ siteUrl: "https://example.com",
526
+ };
527
+
528
+ const apiStub: OAuthComponentAPI = {
529
+ queries: {
530
+ getClient: async () => ({
531
+ clientId: "client",
532
+ type: "public",
533
+ redirectUris: ["https://cb"],
534
+ allowedScopes: ["openid"],
535
+ }),
536
+ getRefreshToken: async () => null,
537
+ getTokensByUser: async () => [],
538
+ },
539
+ mutations: {
540
+ issueAuthorizationCode: async () => "",
541
+ consumeAuthCode: async () => ({
542
+ userId: "user-123",
543
+ scopes: ["openid"],
544
+ codeChallenge: "challenge",
545
+ codeChallengeMethod: "S256",
546
+ redirectUri: "https://cb",
547
+ codeHash: "test-code-hash",
548
+ }),
549
+ saveTokens: async () => undefined,
550
+ rotateRefreshToken: async () => undefined,
551
+ upsertAuthorization: async () => "auth-id",
552
+ updateAuthorizationLastUsed: async () => undefined,
553
+ },
554
+ clientManagement: {
555
+ registerClient: async () => ({
556
+ clientId: "client",
557
+ clientIdIssuedAt: Date.now(),
558
+ }),
559
+ verifyClientSecret: async () => true,
560
+ },
561
+ };
562
+
563
+ const request = new Request("https://example.com/oauth/token", {
564
+ method: "POST",
565
+ body: new URLSearchParams({
566
+ grant_type: "authorization_code",
567
+ client_id: "client",
568
+ // Missing code, redirect_uri, code_verifier
569
+ }),
570
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
571
+ });
572
+
573
+ const response = await tokenHandler({} as any, request, config, apiStub);
574
+
575
+ expect(response.status).toBe(400);
576
+ const body = await response.json();
577
+ expect(body.error).toBe("invalid_request");
578
+ expect(body.error_description).toBe("Missing code parameters");
579
+ });
580
+
581
+ test("Authorize Handler: rejects unsupported response_type", async () => {
582
+ const config: OAuthConfig = {
583
+ privateKey: "dummy",
584
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
585
+ siteUrl: "https://example.com",
586
+ getUserId: async () => "user-1",
587
+ };
588
+ const apiStub: OAuthComponentAPI = {
589
+ queries: {
590
+ getClient: async () => ({
591
+ clientId: "client",
592
+ type: "public",
593
+ redirectUris: ["https://cb"],
594
+ allowedScopes: ["openid"],
595
+ }),
596
+ getRefreshToken: async () => null,
597
+ getTokensByUser: async () => [],
598
+ },
599
+ mutations: {
600
+ issueAuthorizationCode: async () => "code",
601
+ consumeAuthCode: async () => ({
602
+ userId: "u",
603
+ scopes: [],
604
+ codeChallenge: "",
605
+ codeChallengeMethod: "plain",
606
+ redirectUri: "https://cb",
607
+ codeHash: "test-code-hash",
608
+ }),
609
+ saveTokens: async () => undefined,
610
+ rotateRefreshToken: async () => undefined,
611
+ upsertAuthorization: async () => "",
612
+ updateAuthorizationLastUsed: async () => undefined,
613
+ },
614
+ clientManagement: {
615
+ registerClient: async () => ({
616
+ clientId: "client",
617
+ clientIdIssuedAt: 0,
618
+ }),
619
+ verifyClientSecret: async () => true,
620
+ },
621
+ };
622
+
623
+ const request = new Request("https://example.com/oauth/authorize?response_type=token&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc", {
624
+ method: "GET",
625
+ });
626
+
627
+ const response = await authorizeHandler({} as any, request, config, apiStub);
628
+ expect(response.status).toBe(302);
629
+ const location = response.headers.get("Location");
630
+ expect(location).toBeTruthy();
631
+ const redirect = new URL(location as string);
632
+ expect(redirect.searchParams.get("error")).toBe("unsupported_response_type");
633
+ expect(redirect.searchParams.get("error_description")).toBe("response_type must be code");
634
+ expect(redirect.searchParams.get("state")).toBe("abc");
635
+ });
636
+
637
+ test("Authorize Handler: rejects empty scope", async () => {
638
+ const config: OAuthConfig = {
639
+ privateKey: "dummy",
640
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
641
+ siteUrl: "https://example.com",
642
+ getUserId: async () => "user-1",
643
+ };
644
+ const apiStub: OAuthComponentAPI = {
645
+ queries: {
646
+ getClient: async () => ({
647
+ clientId: "client",
648
+ type: "public",
649
+ redirectUris: ["https://cb"],
650
+ allowedScopes: ["openid"],
651
+ }),
652
+ getRefreshToken: async () => null,
653
+ getTokensByUser: async () => [],
654
+ },
655
+ mutations: {
656
+ issueAuthorizationCode: async () => "code",
657
+ consumeAuthCode: async () => ({
658
+ userId: "u",
659
+ scopes: [],
660
+ codeChallenge: "",
661
+ codeChallengeMethod: "plain",
662
+ redirectUri: "https://cb",
663
+ codeHash: "test-code-hash",
664
+ }),
665
+ saveTokens: async () => undefined,
666
+ rotateRefreshToken: async () => undefined,
667
+ upsertAuthorization: async () => "",
668
+ updateAuthorizationLastUsed: async () => undefined,
669
+ },
670
+ clientManagement: {
671
+ registerClient: async () => ({
672
+ clientId: "client",
673
+ clientIdIssuedAt: 0,
674
+ }),
675
+ verifyClientSecret: async () => true,
676
+ },
677
+ };
678
+
679
+ const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&state=abc", {
680
+ method: "GET",
681
+ });
682
+
683
+ const response = await authorizeHandler({} as any, request, config, apiStub);
684
+ expect(response.status).toBe(302);
685
+ const location = response.headers.get("Location");
686
+ expect(location).toBeTruthy();
687
+ const redirect = new URL(location as string);
688
+ expect(redirect.searchParams.get("error")).toBe("invalid_request");
689
+ expect(redirect.searchParams.get("error_description")).toBe("scope required");
690
+ expect(redirect.searchParams.get("state")).toBe("abc");
691
+ });
692
+
693
+ test("Authorize Handler: rejects missing code_challenge", async () => {
694
+ const config: OAuthConfig = {
695
+ privateKey: "dummy",
696
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
697
+ siteUrl: "https://example.com",
698
+ getUserId: async () => "user-1",
699
+ };
700
+ const apiStub: OAuthComponentAPI = {
701
+ queries: {
702
+ getClient: async () => ({
703
+ clientId: "client",
704
+ type: "public",
705
+ redirectUris: ["https://cb"],
706
+ allowedScopes: ["openid"],
707
+ }),
708
+ getRefreshToken: async () => null,
709
+ getTokensByUser: async () => [],
710
+ },
711
+ mutations: {
712
+ issueAuthorizationCode: async () => "code",
713
+ consumeAuthCode: async () => ({
714
+ userId: "u",
715
+ scopes: [],
716
+ codeChallenge: "",
717
+ codeChallengeMethod: "plain",
718
+ redirectUri: "https://cb",
719
+ codeHash: "test-code-hash",
720
+ }),
721
+ saveTokens: async () => undefined,
722
+ rotateRefreshToken: async () => undefined,
723
+ upsertAuthorization: async () => "",
724
+ updateAuthorizationLastUsed: async () => undefined,
725
+ },
726
+ clientManagement: {
727
+ registerClient: async () => ({
728
+ clientId: "client",
729
+ clientIdIssuedAt: 0,
730
+ }),
731
+ verifyClientSecret: async () => true,
732
+ },
733
+ };
734
+
735
+ 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", {
736
+ method: "GET",
737
+ });
738
+
739
+ const response = await authorizeHandler({} as any, request, config, apiStub);
740
+ expect(response.status).toBe(302);
741
+ const location = response.headers.get("Location");
742
+ expect(location).toBeTruthy();
743
+ const redirect = new URL(location as string);
744
+ expect(redirect.searchParams.get("error")).toBe("invalid_request");
745
+ expect(redirect.searchParams.get("error_description")).toBe("code_challenge required");
746
+ expect(redirect.searchParams.get("state")).toBe("abc");
747
+ });
748
+
749
+ test("Authorize Handler: rejects when consent is not approved", async () => {
750
+ const config: OAuthConfig = {
751
+ privateKey: "dummy",
752
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
753
+ siteUrl: "https://example.com",
754
+ getUserId: async () => "user-1",
755
+ };
756
+ const apiStub: OAuthComponentAPI = {
757
+ queries: {
758
+ getClient: async () => ({
759
+ clientId: "client",
760
+ type: "public",
761
+ redirectUris: ["https://cb"],
762
+ allowedScopes: ["openid"],
763
+ }),
764
+ getRefreshToken: async () => null,
765
+ getTokensByUser: async () => [],
766
+ },
767
+ mutations: {
768
+ issueAuthorizationCode: async () => "code",
769
+ consumeAuthCode: async () => ({
770
+ userId: "u",
771
+ scopes: [],
772
+ codeChallenge: "",
773
+ codeChallengeMethod: "plain",
774
+ redirectUri: "https://cb",
775
+ codeHash: "test-code-hash",
776
+ }),
777
+ saveTokens: async () => undefined,
778
+ rotateRefreshToken: async () => undefined,
779
+ upsertAuthorization: async () => "",
780
+ updateAuthorizationLastUsed: async () => undefined,
781
+ },
782
+ clientManagement: {
783
+ registerClient: async () => ({
784
+ clientId: "client",
785
+ clientIdIssuedAt: 0,
786
+ }),
787
+ verifyClientSecret: async () => true,
788
+ },
789
+ };
790
+
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", {
792
+ method: "GET",
793
+ });
794
+
795
+ const response = await authorizeHandler({} as any, request, config, apiStub);
796
+ expect(response.status).toBe(302);
797
+ const location = response.headers.get("Location");
798
+ expect(location).toBeTruthy();
799
+ const redirect = new URL(location as string);
800
+ expect(redirect.searchParams.get("error")).toBe("access_denied");
801
+ expect(redirect.searchParams.get("error_description")).toBe("User consent required");
802
+ expect(redirect.searchParams.get("state")).toBe("abc");
803
+ });
804
+
805
+ test("Authorize Handler: rejects invalid scope", async () => {
806
+ const config: OAuthConfig = {
807
+ privateKey: "dummy",
808
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
809
+ siteUrl: "https://example.com",
810
+ getUserId: async () => "user-1",
811
+ };
812
+ const apiStub: OAuthComponentAPI = {
813
+ queries: {
814
+ getClient: async () => ({
815
+ clientId: "client",
816
+ type: "public",
817
+ redirectUris: ["https://cb"],
818
+ allowedScopes: ["openid", "profile"],
819
+ }),
820
+ getRefreshToken: async () => null,
821
+ getTokensByUser: async () => [],
822
+ },
823
+ mutations: {
824
+ issueAuthorizationCode: async () => "code",
825
+ consumeAuthCode: async () => ({
826
+ userId: "u",
827
+ scopes: [],
828
+ codeChallenge: "",
829
+ codeChallengeMethod: "plain",
830
+ redirectUri: "https://cb",
831
+ codeHash: "test-code-hash",
832
+ }),
833
+ saveTokens: async () => undefined,
834
+ rotateRefreshToken: async () => undefined,
835
+ upsertAuthorization: async () => "",
836
+ updateAuthorizationLastUsed: async () => undefined,
837
+ },
838
+ clientManagement: {
839
+ registerClient: async () => ({
840
+ clientId: "client",
841
+ clientIdIssuedAt: 0,
842
+ }),
843
+ verifyClientSecret: async () => true,
844
+ },
845
+ };
846
+
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", {
848
+ method: "GET",
849
+ headers: {
850
+ "Referer": "https://example.com/consent",
851
+ },
852
+ });
853
+
854
+ const response = await authorizeHandler({} as any, request, config, apiStub);
855
+ expect(response.status).toBe(302);
856
+ const location = response.headers.get("Location");
857
+ expect(location).toBeTruthy();
858
+ const redirect = new URL(location as string);
859
+ expect(redirect.searchParams.get("error")).toBe("invalid_scope");
860
+ expect(redirect.searchParams.get("error_description")).toBe("Scope not allowed");
861
+ expect(redirect.searchParams.get("state")).toBe("abc");
862
+ });
863
+
864
+ test("Authorize Handler: succeeds with valid parameters", async () => {
865
+ const config: OAuthConfig = {
866
+ privateKey: "dummy",
867
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
868
+ siteUrl: "https://example.com",
869
+ getUserId: async () => "user-1",
870
+ };
871
+ const apiStub: OAuthComponentAPI = {
872
+ queries: {
873
+ getClient: async () => ({
874
+ clientId: "client",
875
+ type: "public",
876
+ redirectUris: ["https://cb"],
877
+ allowedScopes: ["openid", "profile"],
878
+ }),
879
+ getRefreshToken: async () => null,
880
+ getTokensByUser: async () => [],
881
+ },
882
+ mutations: {
883
+ issueAuthorizationCode: async () => "auth-code-123",
884
+ consumeAuthCode: async () => ({
885
+ userId: "u",
886
+ scopes: [],
887
+ codeChallenge: "",
888
+ codeChallengeMethod: "plain",
889
+ redirectUri: "https://cb",
890
+ codeHash: "test-code-hash",
891
+ }),
892
+ saveTokens: async () => undefined,
893
+ rotateRefreshToken: async () => undefined,
894
+ upsertAuthorization: async () => "",
895
+ updateAuthorizationLastUsed: async () => undefined,
896
+ },
897
+ clientManagement: {
898
+ registerClient: async () => ({
899
+ clientId: "client",
900
+ clientIdIssuedAt: 0,
901
+ }),
902
+ verifyClientSecret: async () => true,
903
+ },
904
+ };
905
+
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", {
907
+ method: "GET",
908
+ headers: {
909
+ "Referer": "https://example.com/consent",
910
+ },
911
+ });
912
+
913
+ const response = await authorizeHandler({} as any, request, config, apiStub);
914
+ expect(response.status).toBe(302);
915
+ const location = response.headers.get("Location");
916
+ expect(location).toBeTruthy();
917
+ const redirect = new URL(location as string);
918
+ expect(redirect.searchParams.get("code")).toBe("auth-code-123");
919
+ expect(redirect.searchParams.get("state")).toBe("state-123");
920
+ expect(redirect.searchParams.get("error")).toBeNull();
921
+ });
922
+
923
+ test("Authorize Handler: returns error when user not authenticated", async () => {
924
+ const config: OAuthConfig = {
925
+ privateKey: "dummy",
926
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
927
+ siteUrl: "https://example.com",
928
+ getUserId: async () => null, // User not authenticated
929
+ };
930
+ const apiStub: OAuthComponentAPI = {
931
+ queries: {
932
+ getClient: async () => ({
933
+ clientId: "client",
934
+ type: "public",
935
+ redirectUris: ["https://cb"],
936
+ allowedScopes: ["openid"],
937
+ }),
938
+ getRefreshToken: async () => null,
939
+ getTokensByUser: async () => [],
940
+ },
941
+ mutations: {
942
+ issueAuthorizationCode: async () => "code",
943
+ consumeAuthCode: async () => ({
944
+ userId: "u",
945
+ scopes: [],
946
+ codeChallenge: "",
947
+ codeChallengeMethod: "plain",
948
+ redirectUri: "https://cb",
949
+ codeHash: "test-code-hash",
950
+ }),
951
+ saveTokens: async () => undefined,
952
+ rotateRefreshToken: async () => undefined,
953
+ upsertAuthorization: async () => "",
954
+ updateAuthorizationLastUsed: async () => undefined,
955
+ },
956
+ clientManagement: {
957
+ registerClient: async () => ({
958
+ clientId: "client",
959
+ clientIdIssuedAt: 0,
960
+ }),
961
+ verifyClientSecret: async () => true,
962
+ },
963
+ };
964
+
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", {
966
+ method: "GET",
967
+ headers: {
968
+ "Referer": "https://example.com/consent",
969
+ },
970
+ });
971
+
972
+ const response = await authorizeHandler({} as any, request, config, apiStub);
973
+ expect(response.status).toBe(302);
974
+ const location = response.headers.get("Location");
975
+ expect(location).toBeTruthy();
976
+ const redirect = new URL(location as string);
977
+ expect(redirect.searchParams.get("error")).toBe("access_denied");
978
+ expect(redirect.searchParams.get("error_description")).toBe("User not authenticated");
979
+ expect(redirect.searchParams.get("state")).toBe("abc");
980
+ });
981
+
982
+ test("OpenID Configuration Handler: includes registration_endpoint when allowDynamicClientRegistration is true", async () => {
983
+ const config: OAuthConfig = {
984
+ privateKey: "dummy",
985
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
986
+ siteUrl: "https://example.com",
987
+ allowDynamicClientRegistration: true,
988
+ };
989
+
990
+ const request = new Request("https://example.com/.well-known/openid-configuration", {
991
+ method: "GET",
992
+ });
993
+
994
+ const response = await openIdConfigurationHandler({} as any, request, config);
995
+ expect(response.status).toBe(200);
996
+ const body = await response.json();
997
+ expect(body.registration_endpoint).toBe("https://example.com/oauth/register");
998
+ });
999
+
1000
+ test("OpenID Configuration Handler: uses convexSiteUrl when provided", async () => {
1001
+ const config: OAuthConfig = {
1002
+ privateKey: "dummy",
1003
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1004
+ siteUrl: "https://example.com",
1005
+ convexSiteUrl: "https://backend.convex.site",
1006
+ };
1007
+
1008
+ const request = new Request("https://example.com/.well-known/openid-configuration", {
1009
+ method: "GET",
1010
+ });
1011
+
1012
+ const response = await openIdConfigurationHandler({} as any, request, config);
1013
+ expect(response.status).toBe(200);
1014
+ const body = await response.json();
1015
+ expect(body.authorization_endpoint).toBe("https://backend.convex.site/oauth/authorize");
1016
+ expect(body.token_endpoint).toBe("https://backend.convex.site/oauth/token");
1017
+ expect(body.userinfo_endpoint).toBe("https://backend.convex.site/oauth/userinfo");
1018
+ expect(body.jwks_uri).toBe("https://backend.convex.site/oauth/.well-known/jwks.json");
1019
+ });
1020
+
1021
+ test("JWKS Handler: returns valid JWKS", async () => {
1022
+ const config: OAuthConfig = {
1023
+ privateKey: "dummy",
1024
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"test-n\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"key-1\"}]}",
1025
+ siteUrl: "https://example.com",
1026
+ };
1027
+
1028
+ const request = new Request("https://example.com/.well-known/jwks.json", {
1029
+ method: "GET",
1030
+ });
1031
+
1032
+ const response = await jwksHandler({} as any, request, config);
1033
+ expect(response.status).toBe(200);
1034
+ const body = await response.json();
1035
+ expect(body.keys).toBeDefined();
1036
+ expect(body.keys).toHaveLength(1);
1037
+ expect(body.keys[0].kty).toBe("RSA");
1038
+ expect(body.keys[0].n).toBe("test-n");
1039
+ });
1040
+
1041
+ test("Token Handler: rejects non-POST method", async () => {
1042
+ const config: OAuthConfig = {
1043
+ privateKey: "dummy",
1044
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1045
+ siteUrl: "https://example.com",
1046
+ };
1047
+ const apiStub: OAuthComponentAPI = {
1048
+ queries: {
1049
+ getClient: async () => null,
1050
+ getRefreshToken: async () => null,
1051
+ getTokensByUser: async () => [],
1052
+ },
1053
+ mutations: {
1054
+ issueAuthorizationCode: async () => "",
1055
+ consumeAuthCode: async () => ({
1056
+ userId: "u",
1057
+ scopes: [],
1058
+ codeChallenge: "",
1059
+ codeChallengeMethod: "plain",
1060
+ redirectUri: "https://cb",
1061
+ codeHash: "test-code-hash",
1062
+ }),
1063
+ saveTokens: async () => undefined,
1064
+ rotateRefreshToken: async () => undefined,
1065
+ upsertAuthorization: async () => "",
1066
+ updateAuthorizationLastUsed: async () => undefined,
1067
+ },
1068
+ clientManagement: {
1069
+ registerClient: async () => ({
1070
+ clientId: "client",
1071
+ clientIdIssuedAt: 0,
1072
+ }),
1073
+ verifyClientSecret: async () => true,
1074
+ },
1075
+ };
1076
+
1077
+ const request = new Request("https://example.com/oauth/token", {
1078
+ method: "GET",
1079
+ });
1080
+
1081
+ const response = await tokenHandler({} as any, request, config, apiStub);
1082
+ expect(response.status).toBe(405);
1083
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
1084
+ expect(response.headers.get("Pragma")).toBe("no-cache");
1085
+ });
1086
+
1087
+ test("Token Handler: rejects unknown client", async () => {
1088
+ const config: OAuthConfig = {
1089
+ privateKey: "dummy",
1090
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1091
+ siteUrl: "https://example.com",
1092
+ };
1093
+ const apiStub: OAuthComponentAPI = {
1094
+ queries: {
1095
+ getClient: async () => null,
1096
+ getRefreshToken: async () => null,
1097
+ getTokensByUser: async () => [],
1098
+ },
1099
+ mutations: {
1100
+ issueAuthorizationCode: async () => "",
1101
+ consumeAuthCode: async () => ({
1102
+ userId: "u",
1103
+ scopes: [],
1104
+ codeChallenge: "",
1105
+ codeChallengeMethod: "plain",
1106
+ redirectUri: "https://cb",
1107
+ codeHash: "test-code-hash",
1108
+ }),
1109
+ saveTokens: async () => undefined,
1110
+ rotateRefreshToken: async () => undefined,
1111
+ upsertAuthorization: async () => "",
1112
+ updateAuthorizationLastUsed: async () => undefined,
1113
+ },
1114
+ clientManagement: {
1115
+ registerClient: async () => ({
1116
+ clientId: "client",
1117
+ clientIdIssuedAt: 0,
1118
+ }),
1119
+ verifyClientSecret: async () => true,
1120
+ },
1121
+ };
1122
+
1123
+ const request = new Request("https://example.com/oauth/token", {
1124
+ method: "POST",
1125
+ body: new URLSearchParams({
1126
+ grant_type: "authorization_code",
1127
+ client_id: "unknown-client",
1128
+ code: "code",
1129
+ redirect_uri: "https://cb",
1130
+ code_verifier: "verifier",
1131
+ }),
1132
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1133
+ });
1134
+
1135
+ const response = await tokenHandler({} as any, request, config, apiStub);
1136
+ expect(response.status).toBe(401);
1137
+ const body = await response.json();
1138
+ expect(body.error).toBe("invalid_client");
1139
+ expect(body.error_description).toBe("Unknown client");
1140
+ });
1141
+
1142
+ test("Authorize Handler: requires S256 code_challenge_method", async () => {
1143
+ const config: OAuthConfig = {
1144
+ privateKey: "dummy",
1145
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1146
+ siteUrl: "https://example.com",
1147
+ getUserId: async () => "user-1",
1148
+ };
1149
+ const apiStub: OAuthComponentAPI = {
1150
+ queries: {
1151
+ getClient: async () => ({
1152
+ clientId: "client",
1153
+ type: "public",
1154
+ redirectUris: ["https://cb"],
1155
+ allowedScopes: ["openid"],
1156
+ }),
1157
+ getRefreshToken: async () => null,
1158
+ getTokensByUser: async () => [],
1159
+ },
1160
+ mutations: {
1161
+ issueAuthorizationCode: async () => "code",
1162
+ consumeAuthCode: async () => ({
1163
+ userId: "u",
1164
+ scopes: [],
1165
+ codeChallenge: "",
1166
+ codeChallengeMethod: "plain",
1167
+ redirectUri: "https://cb",
1168
+ codeHash: "test-code-hash",
1169
+ }),
1170
+ saveTokens: async () => undefined,
1171
+ rotateRefreshToken: async () => undefined,
1172
+ upsertAuthorization: async () => "",
1173
+ updateAuthorizationLastUsed: async () => undefined,
1174
+ },
1175
+ clientManagement: {
1176
+ registerClient: async () => ({
1177
+ clientId: "client",
1178
+ clientIdIssuedAt: 0,
1179
+ }),
1180
+ verifyClientSecret: async () => true,
1181
+ },
1182
+ };
1183
+
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", {
1185
+ method: "GET",
1186
+ headers: {
1187
+ "Referer": "https://example.com/consent",
1188
+ },
1189
+ });
1190
+
1191
+ const response = await authorizeHandler({} as any, request, config, apiStub);
1192
+ expect(response.status).toBe(302);
1193
+ const location = response.headers.get("Location");
1194
+ expect(location).toBeTruthy();
1195
+ const redirect = new URL(location as string);
1196
+ expect(redirect.searchParams.get("error")).toBe("invalid_request");
1197
+ expect(redirect.searchParams.get("error_description")).toBe("code_challenge_method must be S256");
1198
+ expect(redirect.searchParams.get("state")).toBe("abc");
1199
+ });
1200
+
1201
+ test("Authorize Handler: denies consent from non-provider origin", async () => {
1202
+ const config: OAuthConfig = {
1203
+ privateKey: "dummy",
1204
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1205
+ siteUrl: "https://example.com",
1206
+ getUserId: async () => "user-1",
1207
+ };
1208
+ const apiStub: OAuthComponentAPI = {
1209
+ queries: {
1210
+ getClient: async () => ({
1211
+ clientId: "client",
1212
+ type: "public",
1213
+ redirectUris: ["https://cb"],
1214
+ allowedScopes: ["openid"],
1215
+ }),
1216
+ getRefreshToken: async () => null,
1217
+ getTokensByUser: async () => [],
1218
+ },
1219
+ mutations: {
1220
+ issueAuthorizationCode: async () => "code",
1221
+ consumeAuthCode: async () => ({
1222
+ userId: "u",
1223
+ scopes: [],
1224
+ codeChallenge: "",
1225
+ codeChallengeMethod: "plain",
1226
+ redirectUri: "https://cb",
1227
+ codeHash: "test-code-hash",
1228
+ }),
1229
+ saveTokens: async () => undefined,
1230
+ rotateRefreshToken: async () => undefined,
1231
+ upsertAuthorization: async () => "",
1232
+ updateAuthorizationLastUsed: async () => undefined,
1233
+ },
1234
+ clientManagement: {
1235
+ registerClient: async () => ({
1236
+ clientId: "client",
1237
+ clientIdIssuedAt: 0,
1238
+ }),
1239
+ verifyClientSecret: async () => true,
1240
+ },
1241
+ };
1242
+
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", {
1244
+ method: "GET",
1245
+ headers: {
1246
+ "Referer": "https://client.example.com/start",
1247
+ },
1248
+ });
1249
+
1250
+ const response = await authorizeHandler({} as any, request, config, apiStub);
1251
+ expect(response.status).toBe(302);
1252
+ const location = response.headers.get("Location");
1253
+ expect(location).toBeTruthy();
1254
+ const redirect = new URL(location as string);
1255
+ expect(redirect.searchParams.get("error")).toBe("access_denied");
1256
+ expect(redirect.searchParams.get("error_description")).toBe("User consent required");
1257
+ expect(redirect.searchParams.get("state")).toBe("abc");
1258
+ });
1259
+
1260
+ test("Register Handler: rejects non-POST requests", async () => {
1261
+ const config: OAuthConfig = {
1262
+ privateKey: "dummy",
1263
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1264
+ siteUrl: "https://example.com",
1265
+ allowDynamicClientRegistration: true,
1266
+ };
1267
+ const apiStub: OAuthComponentAPI = {
1268
+ queries: {
1269
+ getClient: async () => null,
1270
+ getRefreshToken: async () => null,
1271
+ getTokensByUser: async () => [],
1272
+ },
1273
+ mutations: {
1274
+ issueAuthorizationCode: async () => "",
1275
+ consumeAuthCode: async () => ({
1276
+ userId: "u",
1277
+ scopes: [],
1278
+ codeChallenge: "",
1279
+ codeChallengeMethod: "plain",
1280
+ redirectUri: "https://cb",
1281
+ codeHash: "test-code-hash",
1282
+ }),
1283
+ saveTokens: async () => undefined,
1284
+ rotateRefreshToken: async () => undefined,
1285
+ upsertAuthorization: async () => "",
1286
+ updateAuthorizationLastUsed: async () => undefined,
1287
+ },
1288
+ clientManagement: {
1289
+ registerClient: async () => ({
1290
+ clientId: "client",
1291
+ clientIdIssuedAt: 0,
1292
+ }),
1293
+ verifyClientSecret: async () => true,
1294
+ },
1295
+ };
1296
+
1297
+ const request = new Request("https://example.com/oauth/register", {
1298
+ method: "GET",
1299
+ });
1300
+
1301
+ const response = await registerHandler({} as any, request, config, apiStub);
1302
+ expect(response.status).toBe(405);
1303
+ expect(await response.text()).toBe("Method Not Allowed");
1304
+ });
1305
+
1306
+ test("Register Handler: rejects when DCR disabled", async () => {
1307
+ const config: OAuthConfig = {
1308
+ privateKey: "dummy",
1309
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1310
+ siteUrl: "https://example.com",
1311
+ allowDynamicClientRegistration: false,
1312
+ };
1313
+ const apiStub: OAuthComponentAPI = {
1314
+ queries: {
1315
+ getClient: async () => null,
1316
+ getRefreshToken: async () => null,
1317
+ getTokensByUser: async () => [],
1318
+ },
1319
+ mutations: {
1320
+ issueAuthorizationCode: async () => "",
1321
+ consumeAuthCode: async () => ({
1322
+ userId: "u",
1323
+ scopes: [],
1324
+ codeChallenge: "",
1325
+ codeChallengeMethod: "plain",
1326
+ redirectUri: "https://cb",
1327
+ codeHash: "test-code-hash",
1328
+ }),
1329
+ saveTokens: async () => undefined,
1330
+ rotateRefreshToken: async () => undefined,
1331
+ upsertAuthorization: async () => "",
1332
+ updateAuthorizationLastUsed: async () => undefined,
1333
+ },
1334
+ clientManagement: {
1335
+ registerClient: async () => ({
1336
+ clientId: "client",
1337
+ clientIdIssuedAt: 0,
1338
+ }),
1339
+ verifyClientSecret: async () => true,
1340
+ },
1341
+ };
1342
+
1343
+ const request = new Request("https://example.com/oauth/register", {
1344
+ method: "POST",
1345
+ body: JSON.stringify({
1346
+ redirect_uris: ["https://cb"],
1347
+ }),
1348
+ headers: { "Content-Type": "application/json" },
1349
+ });
1350
+
1351
+ const response = await registerHandler({} as any, request, config, apiStub);
1352
+ expect(response.status).toBe(403);
1353
+ const body = await response.json();
1354
+ expect(body.error).toBe("access_denied");
1355
+ expect(body.error_description).toContain("disabled");
1356
+ });
1357
+
1358
+ test("Register Handler: rejects invalid scopes", async () => {
1359
+ const config: OAuthConfig = {
1360
+ privateKey: "dummy",
1361
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1362
+ siteUrl: "https://example.com",
1363
+ allowDynamicClientRegistration: true,
1364
+ allowedScopes: ["openid", "profile"],
1365
+ };
1366
+ const apiStub: OAuthComponentAPI = {
1367
+ queries: {
1368
+ getClient: async () => null,
1369
+ getRefreshToken: async () => null,
1370
+ getTokensByUser: async () => [],
1371
+ },
1372
+ mutations: {
1373
+ issueAuthorizationCode: async () => "",
1374
+ consumeAuthCode: async () => ({
1375
+ userId: "u",
1376
+ scopes: [],
1377
+ codeChallenge: "",
1378
+ codeChallengeMethod: "plain",
1379
+ redirectUri: "https://cb",
1380
+ codeHash: "test-code-hash",
1381
+ }),
1382
+ saveTokens: async () => undefined,
1383
+ rotateRefreshToken: async () => undefined,
1384
+ upsertAuthorization: async () => "",
1385
+ updateAuthorizationLastUsed: async () => undefined,
1386
+ },
1387
+ clientManagement: {
1388
+ registerClient: async () => ({
1389
+ clientId: "client",
1390
+ clientIdIssuedAt: 0,
1391
+ }),
1392
+ verifyClientSecret: async () => true,
1393
+ },
1394
+ };
1395
+
1396
+ const request = new Request("https://example.com/oauth/register", {
1397
+ method: "POST",
1398
+ body: JSON.stringify({
1399
+ redirect_uris: ["https://cb"],
1400
+ scope: "openid admin",
1401
+ }),
1402
+ headers: { "Content-Type": "application/json" },
1403
+ });
1404
+
1405
+ const response = await registerHandler({} as any, request, config, apiStub);
1406
+ expect(response.status).toBe(400);
1407
+ const body = await response.json();
1408
+ expect(body.error).toBe("invalid_scope");
1409
+ expect(body.error_description).toContain("admin");
1410
+ });
1411
+
1412
+ test("Register Handler: rejects unsupported token_endpoint_auth_method", async () => {
1413
+ const config: OAuthConfig = {
1414
+ privateKey: "dummy",
1415
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1416
+ siteUrl: "https://example.com",
1417
+ allowDynamicClientRegistration: true,
1418
+ };
1419
+ const apiStub: OAuthComponentAPI = {
1420
+ queries: {
1421
+ getClient: async () => null,
1422
+ getRefreshToken: async () => null,
1423
+ getTokensByUser: async () => [],
1424
+ },
1425
+ mutations: {
1426
+ issueAuthorizationCode: async () => "",
1427
+ consumeAuthCode: async () => ({
1428
+ userId: "u",
1429
+ scopes: [],
1430
+ codeChallenge: "",
1431
+ codeChallengeMethod: "plain",
1432
+ redirectUri: "https://cb",
1433
+ codeHash: "test-code-hash",
1434
+ }),
1435
+ saveTokens: async () => undefined,
1436
+ rotateRefreshToken: async () => undefined,
1437
+ upsertAuthorization: async () => "",
1438
+ updateAuthorizationLastUsed: async () => undefined,
1439
+ },
1440
+ clientManagement: {
1441
+ registerClient: async () => ({
1442
+ clientId: "client",
1443
+ clientIdIssuedAt: 0,
1444
+ }),
1445
+ verifyClientSecret: async () => true,
1446
+ },
1447
+ };
1448
+
1449
+ const request = new Request("https://example.com/oauth/register", {
1450
+ method: "POST",
1451
+ body: JSON.stringify({
1452
+ redirect_uris: ["https://cb"],
1453
+ token_endpoint_auth_method: "client_secret_basic",
1454
+ }),
1455
+ headers: { "Content-Type": "application/json" },
1456
+ });
1457
+
1458
+ const response = await registerHandler({} as any, request, config, apiStub);
1459
+ expect(response.status).toBe(400);
1460
+ const body = await response.json();
1461
+ expect(body.error).toBe("invalid_client_metadata");
1462
+ });
1463
+
1464
+ test("Register Handler: rejects empty redirect_uris", async () => {
1465
+ const config: OAuthConfig = {
1466
+ privateKey: "dummy",
1467
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1468
+ siteUrl: "https://example.com",
1469
+ allowDynamicClientRegistration: true,
1470
+ };
1471
+ const apiStub: OAuthComponentAPI = {
1472
+ queries: {
1473
+ getClient: async () => null,
1474
+ getRefreshToken: async () => null,
1475
+ getTokensByUser: async () => [],
1476
+ },
1477
+ mutations: {
1478
+ issueAuthorizationCode: async () => "",
1479
+ consumeAuthCode: async () => ({
1480
+ userId: "u",
1481
+ scopes: [],
1482
+ codeChallenge: "",
1483
+ codeChallengeMethod: "plain",
1484
+ redirectUri: "https://cb",
1485
+ codeHash: "test-code-hash",
1486
+ }),
1487
+ saveTokens: async () => undefined,
1488
+ rotateRefreshToken: async () => undefined,
1489
+ upsertAuthorization: async () => "",
1490
+ updateAuthorizationLastUsed: async () => undefined,
1491
+ },
1492
+ clientManagement: {
1493
+ registerClient: async () => ({
1494
+ clientId: "client",
1495
+ clientIdIssuedAt: 0,
1496
+ }),
1497
+ verifyClientSecret: async () => true,
1498
+ },
1499
+ };
1500
+
1501
+ const request = new Request("https://example.com/oauth/register", {
1502
+ method: "POST",
1503
+ body: JSON.stringify({
1504
+ redirect_uris: [],
1505
+ }),
1506
+ headers: { "Content-Type": "application/json" },
1507
+ });
1508
+
1509
+ const response = await registerHandler({} as any, request, config, apiStub);
1510
+ expect(response.status).toBe(400);
1511
+ const body = await response.json();
1512
+ expect(body.error).toBe("invalid_request");
1513
+ expect(body.error_description).toContain("redirect_uris required");
1514
+ });
1515
+
1516
+ test("Register Handler: rejects invalid redirect_uri", async () => {
1517
+ const config: OAuthConfig = {
1518
+ privateKey: "dummy",
1519
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1520
+ siteUrl: "https://example.com",
1521
+ allowDynamicClientRegistration: true,
1522
+ };
1523
+ const apiStub: OAuthComponentAPI = {
1524
+ queries: {
1525
+ getClient: async () => null,
1526
+ getRefreshToken: async () => null,
1527
+ getTokensByUser: async () => [],
1528
+ },
1529
+ mutations: {
1530
+ issueAuthorizationCode: async () => "",
1531
+ consumeAuthCode: async () => ({
1532
+ userId: "u",
1533
+ scopes: [],
1534
+ codeChallenge: "",
1535
+ codeChallengeMethod: "plain",
1536
+ redirectUri: "https://cb",
1537
+ codeHash: "test-code-hash",
1538
+ }),
1539
+ saveTokens: async () => undefined,
1540
+ rotateRefreshToken: async () => undefined,
1541
+ upsertAuthorization: async () => "",
1542
+ updateAuthorizationLastUsed: async () => undefined,
1543
+ },
1544
+ clientManagement: {
1545
+ registerClient: async () => ({
1546
+ clientId: "client",
1547
+ clientIdIssuedAt: 0,
1548
+ }),
1549
+ verifyClientSecret: async () => true,
1550
+ },
1551
+ };
1552
+
1553
+ const request = new Request("https://example.com/oauth/register", {
1554
+ method: "POST",
1555
+ body: JSON.stringify({
1556
+ redirect_uris: ["http://example.com/callback#fragment"],
1557
+ }),
1558
+ headers: { "Content-Type": "application/json" },
1559
+ });
1560
+
1561
+ const response = await registerHandler({} as any, request, config, apiStub);
1562
+ expect(response.status).toBe(400);
1563
+ const body = await response.json();
1564
+ expect(body.error).toBe("invalid_request");
1565
+ expect(body.error_description).toContain("Invalid redirect_uri");
1566
+ });
1567
+
1568
+ test("Register Handler: succeeds with public client", async () => {
1569
+ const config: OAuthConfig = {
1570
+ privateKey: "dummy",
1571
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1572
+ siteUrl: "https://example.com",
1573
+ allowDynamicClientRegistration: true,
1574
+ };
1575
+ const apiStub: OAuthComponentAPI = {
1576
+ queries: {
1577
+ getClient: async () => null,
1578
+ getRefreshToken: async () => null,
1579
+ getTokensByUser: async () => [],
1580
+ },
1581
+ mutations: {
1582
+ issueAuthorizationCode: async () => "",
1583
+ consumeAuthCode: async () => ({
1584
+ userId: "u",
1585
+ scopes: [],
1586
+ codeChallenge: "",
1587
+ codeChallengeMethod: "plain",
1588
+ redirectUri: "https://cb",
1589
+ codeHash: "test-code-hash",
1590
+ }),
1591
+ saveTokens: async () => undefined,
1592
+ rotateRefreshToken: async () => undefined,
1593
+ upsertAuthorization: async () => "",
1594
+ updateAuthorizationLastUsed: async () => undefined,
1595
+ },
1596
+ clientManagement: {
1597
+ registerClient: async () => ({
1598
+ clientId: "public-client",
1599
+ clientIdIssuedAt: Date.now(),
1600
+ }),
1601
+ verifyClientSecret: async () => true,
1602
+ },
1603
+ };
1604
+
1605
+ const request = new Request("https://example.com/oauth/register", {
1606
+ method: "POST",
1607
+ body: JSON.stringify({
1608
+ redirect_uris: ["https://example.com/callback"],
1609
+ client_name: "Public Client",
1610
+ token_endpoint_auth_method: "none",
1611
+ }),
1612
+ headers: { "Content-Type": "application/json" },
1613
+ });
1614
+
1615
+ const response = await registerHandler({} as any, request, config, apiStub);
1616
+ expect(response.status).toBe(201);
1617
+ const body = await response.json();
1618
+ expect(body.client_id).toBe("public-client");
1619
+ expect(body.client_secret).toBeUndefined();
1620
+ expect(body.token_endpoint_auth_method).toBe("none");
1621
+ });
1622
+
1623
+ test("Register Handler: handles general errors", async () => {
1624
+ const config: OAuthConfig = {
1625
+ privateKey: "dummy",
1626
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1627
+ siteUrl: "https://example.com",
1628
+ allowDynamicClientRegistration: true,
1629
+ };
1630
+ const apiStub: OAuthComponentAPI = {
1631
+ queries: {
1632
+ getClient: async () => null,
1633
+ getRefreshToken: async () => null,
1634
+ getTokensByUser: async () => [],
1635
+ },
1636
+ mutations: {
1637
+ issueAuthorizationCode: async () => "",
1638
+ consumeAuthCode: async () => ({
1639
+ userId: "u",
1640
+ scopes: [],
1641
+ codeChallenge: "",
1642
+ codeChallengeMethod: "plain",
1643
+ redirectUri: "https://cb",
1644
+ codeHash: "test-code-hash",
1645
+ }),
1646
+ saveTokens: async () => undefined,
1647
+ rotateRefreshToken: async () => undefined,
1648
+ upsertAuthorization: async () => "",
1649
+ updateAuthorizationLastUsed: async () => undefined,
1650
+ },
1651
+ clientManagement: {
1652
+ registerClient: async () => {
1653
+ throw new Error("Database error");
1654
+ },
1655
+ verifyClientSecret: async () => true,
1656
+ },
1657
+ };
1658
+
1659
+ const request = new Request("https://example.com/oauth/register", {
1660
+ method: "POST",
1661
+ body: JSON.stringify({
1662
+ redirect_uris: ["https://example.com/callback"],
1663
+ }),
1664
+ headers: { "Content-Type": "application/json" },
1665
+ });
1666
+
1667
+ const response = await registerHandler({} as any, request, config, apiStub);
1668
+ expect(response.status).toBe(400);
1669
+ const body = await response.json();
1670
+ expect(body.error).toBe("invalid_request");
1671
+ expect(body.error_description).toContain("Database error");
1672
+ });
1673
+
1674
+ test("Register Handler: succeeds with confidential client", async () => {
1675
+ const config: OAuthConfig = {
1676
+ privateKey: "dummy",
1677
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1678
+ siteUrl: "https://example.com",
1679
+ allowDynamicClientRegistration: true,
1680
+ };
1681
+ const apiStub: OAuthComponentAPI = {
1682
+ queries: {
1683
+ getClient: async () => null,
1684
+ getRefreshToken: async () => null,
1685
+ getTokensByUser: async () => [],
1686
+ },
1687
+ mutations: {
1688
+ issueAuthorizationCode: async () => "",
1689
+ consumeAuthCode: async () => ({
1690
+ userId: "u",
1691
+ scopes: [],
1692
+ codeChallenge: "",
1693
+ codeChallengeMethod: "plain",
1694
+ redirectUri: "https://cb",
1695
+ codeHash: "test-code-hash",
1696
+ }),
1697
+ saveTokens: async () => undefined,
1698
+ rotateRefreshToken: async () => undefined,
1699
+ upsertAuthorization: async () => "",
1700
+ updateAuthorizationLastUsed: async () => undefined,
1701
+ },
1702
+ clientManagement: {
1703
+ registerClient: async () => ({
1704
+ clientId: "confidential-client",
1705
+ clientIdIssuedAt: Date.now(),
1706
+ clientSecret: "super-secret",
1707
+ }),
1708
+ verifyClientSecret: async () => true,
1709
+ },
1710
+ };
1711
+
1712
+ const request = new Request("https://example.com/oauth/register", {
1713
+ method: "POST",
1714
+ body: JSON.stringify({
1715
+ redirect_uris: ["https://example.com/callback"],
1716
+ client_name: "Confidential Client",
1717
+ token_endpoint_auth_method: "client_secret_post",
1718
+ }),
1719
+ headers: { "Content-Type": "application/json" },
1720
+ });
1721
+
1722
+ const response = await registerHandler({} as any, request, config, apiStub);
1723
+ expect(response.status).toBe(201);
1724
+ const body = await response.json();
1725
+ expect(body.client_id).toBe("confidential-client");
1726
+ expect(body.client_secret).toBe("super-secret");
1727
+ expect(body.client_secret_expires_at).toBe(0);
1728
+ expect(body.token_endpoint_auth_method).toBe("client_secret_post");
1729
+ });
1730
+
1731
+ test("Protected Resource Handler: returns metadata", async () => {
1732
+ const config: OAuthConfig = {
1733
+ privateKey: "dummy",
1734
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1735
+ siteUrl: "https://api.example.com",
1736
+ convexSiteUrl: "https://example.convex.site",
1737
+ };
1738
+
1739
+ const request = new Request("https://example.convex.site/.well-known/oauth-protected-resource", {
1740
+ method: "GET",
1741
+ });
1742
+
1743
+ const response = await oauthProtectedResourceHandler({} as any, request, config);
1744
+ expect(response.status).toBe(200);
1745
+ const body = await response.json();
1746
+ expect(body.resource).toBe("https://api.example.com");
1747
+ expect(body.authorization_servers).toEqual(["https://example.convex.site/oauth"]);
1748
+ expect(body.scopes_supported).toEqual(["openid", "profile", "email", "offline_access"]);
1749
+ });
1750
+
1751
+ test("Protected Resource Handler: handles custom scopes", async () => {
1752
+ const config: OAuthConfig = {
1753
+ privateKey: "dummy",
1754
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1755
+ siteUrl: "https://api.example.com",
1756
+ allowedScopes: ["read", "write", "admin"],
1757
+ };
1758
+
1759
+ const request = new Request("https://api.example.com/.well-known/oauth-protected-resource", {
1760
+ method: "GET",
1761
+ });
1762
+
1763
+ const response = await oauthProtectedResourceHandler({} as any, request, config);
1764
+ expect(response.status).toBe(200);
1765
+ const body = await response.json();
1766
+ expect(body.scopes_supported).toEqual(["read", "write", "admin"]);
1767
+ });
1768
+
1769
+ test("Protected Resource Handler: handles OPTIONS request", async () => {
1770
+ const config: OAuthConfig = {
1771
+ privateKey: "dummy",
1772
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1773
+ siteUrl: "https://api.example.com",
1774
+ };
1775
+
1776
+ const request = new Request("https://api.example.com/.well-known/oauth-protected-resource", {
1777
+ method: "OPTIONS",
1778
+ headers: { "Origin": "https://example.com" },
1779
+ });
1780
+
1781
+ const response = await oauthProtectedResourceHandler({} as any, request, config);
1782
+ expect(response.status).toBe(200);
1783
+ expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET");
1784
+ });
1785
+
1786
+ test("JWKS Handler: returns server_error on invalid JWKS", async () => {
1787
+ const config: OAuthConfig = {
1788
+ privateKey: "dummy",
1789
+ jwks: "invalid-json",
1790
+ siteUrl: "https://example.com",
1791
+ };
1792
+
1793
+ const request = new Request("https://example.com/.well-known/jwks.json", {
1794
+ method: "GET",
1795
+ });
1796
+
1797
+ const response = await jwksHandler({} as any, request, config);
1798
+ expect(response.status).toBe(500);
1799
+ const body = await response.json();
1800
+ expect(body.error).toBe("server_error");
1801
+ expect(body.error_description).toBe("Failed to get JWKS");
1802
+ });
1803
+
1804
+ test("JWKS Handler: handles OPTIONS request", async () => {
1805
+ const config: OAuthConfig = {
1806
+ privateKey: "dummy",
1807
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1808
+ siteUrl: "https://example.com",
1809
+ };
1810
+
1811
+ const request = new Request("https://example.com/.well-known/jwks.json", {
1812
+ method: "OPTIONS",
1813
+ headers: { "Origin": "https://example.com" },
1814
+ });
1815
+
1816
+ const response = await jwksHandler({} as any, request, config);
1817
+ expect(response.status).toBe(200);
1818
+ expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET");
1819
+ });
1820
+
1821
+ test("Token Handler: rejects unsupported_grant_type", async () => {
1822
+ const config: OAuthConfig = {
1823
+ privateKey: "dummy",
1824
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
1825
+ siteUrl: "https://example.com",
1826
+ };
1827
+ const apiStub: OAuthComponentAPI = {
1828
+ queries: {
1829
+ getClient: async () => ({
1830
+ clientId: "client",
1831
+ type: "public",
1832
+ redirectUris: ["https://cb"],
1833
+ allowedScopes: ["openid"],
1834
+ }),
1835
+ getRefreshToken: async () => null,
1836
+ getTokensByUser: async () => [],
1837
+ },
1838
+ mutations: {
1839
+ issueAuthorizationCode: async () => "",
1840
+ consumeAuthCode: async () => ({
1841
+ userId: "u",
1842
+ scopes: [],
1843
+ codeChallenge: "",
1844
+ codeChallengeMethod: "plain",
1845
+ redirectUri: "https://cb",
1846
+ codeHash: "test-code-hash",
1847
+ }),
1848
+ saveTokens: async () => undefined,
1849
+ rotateRefreshToken: async () => undefined,
1850
+ upsertAuthorization: async () => "",
1851
+ updateAuthorizationLastUsed: async () => undefined,
1852
+ },
1853
+ clientManagement: {
1854
+ registerClient: async () => ({
1855
+ clientId: "client",
1856
+ clientIdIssuedAt: 0,
1857
+ }),
1858
+ verifyClientSecret: async () => true,
1859
+ },
1860
+ };
1861
+
1862
+ const request = new Request("https://example.com/oauth/token", {
1863
+ method: "POST",
1864
+ body: new URLSearchParams({
1865
+ grant_type: "password",
1866
+ client_id: "client",
1867
+ }),
1868
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1869
+ });
1870
+
1871
+ const response = await tokenHandler({} as any, request, config, apiStub);
1872
+ expect(response.status).toBe(400);
1873
+ const body = await response.json();
1874
+ expect(body.error).toBe("unsupported_grant_type");
1875
+ expect(body.error_description).toBe("Grant type not supported");
1876
+ });
1877
+
1878
+ test("Token Handler: handles rotateRefreshToken error with invalid_grant", async () => {
1879
+ // Generate valid RSA key pair for JWT signing
1880
+ const { privateKey, publicKey } = await generateKeyPair("RS256");
1881
+ const privateKeyPem = await exportPKCS8(privateKey);
1882
+ const jwk = await exportJWK(publicKey);
1883
+ const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
1884
+
1885
+ const config: OAuthConfig = {
1886
+ privateKey: privateKeyPem,
1887
+ jwks,
1888
+ siteUrl: "https://example.com",
1889
+ };
1890
+ const apiStub: OAuthComponentAPI = {
1891
+ queries: {
1892
+ getClient: async () => ({
1893
+ clientId: "client",
1894
+ type: "public",
1895
+ redirectUris: ["https://cb"],
1896
+ allowedScopes: ["openid", "offline_access"],
1897
+ }),
1898
+ getRefreshToken: async () => ({
1899
+ refreshToken: "old-rt",
1900
+ clientId: "client",
1901
+ userId: "user-1",
1902
+ scopes: ["openid", "offline_access"],
1903
+ refreshTokenExpiresAt: Date.now() + 86400000,
1904
+ }),
1905
+ getTokensByUser: async () => [],
1906
+ },
1907
+ mutations: {
1908
+ issueAuthorizationCode: async () => "",
1909
+ consumeAuthCode: async () => ({
1910
+ userId: "u",
1911
+ scopes: [],
1912
+ codeChallenge: "",
1913
+ codeChallengeMethod: "plain",
1914
+ redirectUri: "https://cb",
1915
+ codeHash: "test-code-hash",
1916
+ }),
1917
+ saveTokens: async () => undefined,
1918
+ rotateRefreshToken: async () => {
1919
+ throw new Error("invalid_grant");
1920
+ },
1921
+ upsertAuthorization: async () => "",
1922
+ updateAuthorizationLastUsed: async () => undefined,
1923
+ },
1924
+ clientManagement: {
1925
+ registerClient: async () => ({
1926
+ clientId: "client",
1927
+ clientIdIssuedAt: 0,
1928
+ }),
1929
+ verifyClientSecret: async () => true,
1930
+ },
1931
+ };
1932
+
1933
+ const request = new Request("https://example.com/oauth/token", {
1934
+ method: "POST",
1935
+ body: new URLSearchParams({
1936
+ grant_type: "refresh_token",
1937
+ client_id: "client",
1938
+ refresh_token: "old-rt",
1939
+ }),
1940
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1941
+ });
1942
+
1943
+ const response = await tokenHandler({} as any, request, config, apiStub);
1944
+ expect(response.status).toBe(400);
1945
+ const body = await response.json();
1946
+ expect(body.error).toBe("invalid_grant");
1947
+ expect(body.error_description).toBe("Invalid refresh token (rotated?)");
1948
+ });
1949
+
1950
+ test("Token Handler: handles rotateRefreshToken error (non-invalid_grant)", async () => {
1951
+ // Generate valid RSA key pair for JWT signing
1952
+ const { privateKey, publicKey } = await generateKeyPair("RS256");
1953
+ const privateKeyPem = await exportPKCS8(privateKey);
1954
+ const jwk = await exportJWK(publicKey);
1955
+ const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
1956
+
1957
+ const config: OAuthConfig = {
1958
+ privateKey: privateKeyPem,
1959
+ jwks,
1960
+ siteUrl: "https://example.com",
1961
+ };
1962
+ const apiStub: OAuthComponentAPI = {
1963
+ queries: {
1964
+ getClient: async () => ({
1965
+ clientId: "client",
1966
+ type: "public",
1967
+ redirectUris: ["https://cb"],
1968
+ allowedScopes: ["openid", "offline_access"],
1969
+ }),
1970
+ getRefreshToken: async () => ({
1971
+ refreshToken: "old-rt",
1972
+ clientId: "client",
1973
+ userId: "user-1",
1974
+ scopes: ["openid", "offline_access"],
1975
+ refreshTokenExpiresAt: Date.now() + 86400000,
1976
+ }),
1977
+ getTokensByUser: async () => [],
1978
+ },
1979
+ mutations: {
1980
+ issueAuthorizationCode: async () => "",
1981
+ consumeAuthCode: async () => ({
1982
+ userId: "u",
1983
+ scopes: [],
1984
+ codeChallenge: "",
1985
+ codeChallengeMethod: "plain",
1986
+ redirectUri: "https://cb",
1987
+ codeHash: "test-code-hash",
1988
+ }),
1989
+ saveTokens: async () => undefined,
1990
+ rotateRefreshToken: async () => {
1991
+ throw new Error("Database error");
1992
+ },
1993
+ upsertAuthorization: async () => "",
1994
+ updateAuthorizationLastUsed: async () => undefined,
1995
+ },
1996
+ clientManagement: {
1997
+ registerClient: async () => ({
1998
+ clientId: "client",
1999
+ clientIdIssuedAt: 0,
2000
+ }),
2001
+ verifyClientSecret: async () => true,
2002
+ },
2003
+ };
2004
+
2005
+ const request = new Request("https://example.com/oauth/token", {
2006
+ method: "POST",
2007
+ body: new URLSearchParams({
2008
+ grant_type: "refresh_token",
2009
+ client_id: "client",
2010
+ refresh_token: "old-rt",
2011
+ }),
2012
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2013
+ });
2014
+
2015
+ const response = await tokenHandler({} as any, request, config, apiStub);
2016
+ expect(response.status).toBe(400);
2017
+ const body = await response.json();
2018
+ expect(body.error).toBe("invalid_request");
2019
+ expect(body.error_description).toBe("Database error");
2020
+ });
2021
+
2022
+ test("Authorize Handler: returns error when getUserId not configured", async () => {
2023
+ const config: OAuthConfig = {
2024
+ privateKey: "dummy",
2025
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
2026
+ siteUrl: "https://example.com",
2027
+ // getUserId not configured
2028
+ };
2029
+ const apiStub: OAuthComponentAPI = {
2030
+ queries: {
2031
+ getClient: async () => ({
2032
+ clientId: "client",
2033
+ type: "public",
2034
+ redirectUris: ["https://cb"],
2035
+ allowedScopes: ["openid"],
2036
+ }),
2037
+ getRefreshToken: async () => null,
2038
+ getTokensByUser: async () => [],
2039
+ },
2040
+ mutations: {
2041
+ issueAuthorizationCode: async () => "code",
2042
+ consumeAuthCode: async () => ({
2043
+ userId: "u",
2044
+ scopes: [],
2045
+ codeChallenge: "",
2046
+ codeChallengeMethod: "plain",
2047
+ redirectUri: "https://cb",
2048
+ codeHash: "test-code-hash",
2049
+ }),
2050
+ saveTokens: async () => undefined,
2051
+ rotateRefreshToken: async () => undefined,
2052
+ upsertAuthorization: async () => "",
2053
+ updateAuthorizationLastUsed: async () => undefined,
2054
+ },
2055
+ clientManagement: {
2056
+ registerClient: async () => ({
2057
+ clientId: "client",
2058
+ clientIdIssuedAt: 0,
2059
+ }),
2060
+ verifyClientSecret: async () => true,
2061
+ },
2062
+ };
2063
+
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", {
2065
+ method: "GET",
2066
+ headers: {
2067
+ "Referer": "https://example.com/consent",
2068
+ },
2069
+ });
2070
+
2071
+ const response = await authorizeHandler({} as any, request, config, apiStub);
2072
+ expect(response.status).toBe(500);
2073
+ const body = await response.json();
2074
+ expect(body.error).toBe("server_error");
2075
+ expect(body.error_description).toBe("getUserId is not configured");
2076
+ });
2077
+
2078
+ // ==========================================
2079
+ // Phase 2: Token Lifecycle
2080
+ // ==========================================
2081
+
2082
+ test("Authorization Code: Expiry", async () => {
2083
+ const userId = "test-user-id";
2084
+ const client = await t.mutation(api.clientManagement.registerClient, {
2085
+ name: "C",
2086
+ redirectUris: ["https://cb"],
2087
+ scopes: [],
2088
+ type: "public"
2089
+ });
2090
+
2091
+ await t.mutation(api.mutations.issueAuthorizationCode, {
2092
+ clientId: client.clientId,
2093
+ userId,
2094
+ redirectUri: "https://cb",
2095
+ scopes: [],
2096
+ codeChallenge: "c",
2097
+ codeChallengeMethod: "S256" // Changed from "plain" to "S256"
2098
+ });
2099
+
2100
+ const codeInDb = await t.run(async (ctx) => {
2101
+ return await ctx.db.query("oauthCodes").first();
2102
+ });
2103
+ expect(codeInDb?.expiresAt).toBeGreaterThan(Date.now());
2104
+ });
2105
+
2106
+ test("Refresh Token: Rotation (Atomic Swap)", async () => {
2107
+ const userId = "test-user-id";
2108
+ const client = await t.mutation(api.clientManagement.registerClient, {
2109
+ name: "C",
2110
+ redirectUris: ["https://cb"],
2111
+ scopes: ["openid"], // Added "openid" scope
2112
+ type: "public"
2113
+ });
2114
+
2115
+ const oldRefreshToken = "old_rt";
2116
+ const newRefreshToken = "new_rt";
2117
+ const accessToken = "at";
2118
+
2119
+ // 1. Initial State
2120
+ await t.mutation(api.mutations.saveTokens, {
2121
+ accessToken: "old_at",
2122
+ refreshToken: oldRefreshToken,
2123
+ clientId: client.clientId,
2124
+ userId,
2125
+ scopes: ["openid"],
2126
+ expiresAt: Date.now() + 3600000,
2127
+ refreshTokenExpiresAt: Date.now() + 864000000,
2128
+ });
2129
+
2130
+ // 2. Rotate
2131
+ await t.mutation(api.mutations.rotateRefreshToken, {
2132
+ oldRefreshToken: oldRefreshToken,
2133
+ accessToken,
2134
+ refreshToken: newRefreshToken,
2135
+ clientId: client.clientId,
2136
+ userId,
2137
+ scopes: ["openid"],
2138
+ expiresAt: Date.now() + 3600000,
2139
+ refreshTokenExpiresAt: Date.now() + 864000000,
2140
+ });
2141
+
2142
+ // 3. Verify Old Token Gone (tokens are stored as hashes)
2143
+ const oldTokenHash = await hashToken(oldRefreshToken);
2144
+ const oldTokenRecord = await t.run(async (ctx) => {
2145
+ return await ctx.db.query("oauthTokens")
2146
+ .filter(q => q.eq(q.field("refreshToken"), oldTokenHash))
2147
+ .first();
2148
+ });
2149
+ expect(oldTokenRecord).toBeNull();
2150
+
2151
+ // 4. Verify New Token Exists (stored as hash)
2152
+ const newTokenHash = await hashToken(newRefreshToken);
2153
+ const newTokenRecord = await t.run(async (ctx) => {
2154
+ return await ctx.db.query("oauthTokens")
2155
+ .filter(q => q.eq(q.field("refreshToken"), newTokenHash))
2156
+ .first();
2157
+ });
2158
+ expect(newTokenRecord).toBeDefined();
2159
+ // Verify accessToken is stored as hash, not plaintext
2160
+ expect(newTokenRecord?.accessToken).toBe(await hashToken(accessToken));
2161
+
2162
+ // 5. Replay Attack (Try to rotate old token again)
2163
+ await expect(t.mutation(api.mutations.rotateRefreshToken, {
2164
+ oldRefreshToken: oldRefreshToken, // Already used/deleted
2165
+ accessToken: "at2",
2166
+ refreshToken: "rt2",
2167
+ clientId: client.clientId,
2168
+ userId,
2169
+ scopes: ["openid"],
2170
+ expiresAt: Date.now() + 3600000,
2171
+ refreshTokenExpiresAt: Date.now() + 864000000,
2172
+ })).rejects.toThrow(); // Should fail "invalid_grant"
2173
+ });
2174
+
2175
+ // ==========================================
2176
+ // Phase 3: Client Management
2177
+ // ==========================================
2178
+
2179
+ test("Public Client Registration (No Secret)", async () => {
2180
+ const result = await t.mutation(api.clientManagement.registerClient, {
2181
+ name: "Public App",
2182
+ redirectUris: ["https://app.example.com/callback"],
2183
+ scopes: ["openid"],
2184
+ type: "public",
2185
+ });
2186
+
2187
+ expect(result.clientId).toBeDefined();
2188
+ expect(result.clientSecret).toBeUndefined();
2189
+
2190
+ const clientInDb = await t.query(api.queries.getClient, {
2191
+ clientId: result.clientId
2192
+ });
2193
+ expect(clientInDb?.clientSecret).toBeUndefined();
2194
+ });
2195
+
2196
+ test("Client Registration: rejects invalid redirect URIs", async () => {
2197
+ await expect(t.mutation(api.clientManagement.registerClient, {
2198
+ name: "Bad Redirect",
2199
+ redirectUris: ["http://example.com/callback"],
2200
+ scopes: ["openid"],
2201
+ type: "public",
2202
+ })).rejects.toThrow();
2203
+ });
2204
+
2205
+ test("Client Deletion", async () => {
2206
+ const result = await t.mutation(api.clientManagement.registerClient, {
2207
+ name: "To Delete",
2208
+ redirectUris: ["https://cb"],
2209
+ scopes: [],
2210
+ type: "public",
2211
+ });
2212
+
2213
+ // Verify exists
2214
+ const beforeDelete = await t.query(api.queries.getClient, {
2215
+ clientId: result.clientId
2216
+ });
2217
+ expect(beforeDelete).toBeDefined();
2218
+
2219
+ // Delete
2220
+ await t.mutation(api.mutations.deleteClient, {
2221
+ clientId: result.clientId
2222
+ });
2223
+
2224
+ // Verify gone
2225
+ const afterDelete = await t.query(api.queries.getClient, {
2226
+ clientId: result.clientId
2227
+ });
2228
+ expect(afterDelete).toBeNull();
2229
+ });
2230
+
2231
+ // ==========================================
2232
+ // Phase 4: Query Tests
2233
+ // ==========================================
2234
+
2235
+ test("Query: getRefreshToken", async () => {
2236
+ const userId = "user-1";
2237
+ const client = await t.mutation(api.clientManagement.registerClient, {
2238
+ name: "Client",
2239
+ redirectUris: ["https://cb"],
2240
+ scopes: [],
2241
+ type: "public"
2242
+ });
2243
+
2244
+ const refreshToken = "refresh_token_123";
2245
+ await t.mutation(api.mutations.saveTokens, {
2246
+ accessToken: "access_token",
2247
+ refreshToken,
2248
+ clientId: client.clientId,
2249
+ userId,
2250
+ scopes: ["openid"],
2251
+ expiresAt: Date.now() + 3600000,
2252
+ refreshTokenExpiresAt: Date.now() + 864000000,
2253
+ });
2254
+
2255
+ const token = await t.query(api.queries.getRefreshToken, {
2256
+ refreshToken
2257
+ });
2258
+ expect(token).toBeDefined();
2259
+ expect(token?.userId).toBe(userId);
2260
+ expect(token?.clientId).toBe(client.clientId);
2261
+ });
2262
+
2263
+ test("Query: listClients", async () => {
2264
+ await t.mutation(api.clientManagement.registerClient, {
2265
+ name: "Client 1",
2266
+ redirectUris: ["https://cb1"],
2267
+ scopes: [],
2268
+ type: "public"
2269
+ });
2270
+ await t.mutation(api.clientManagement.registerClient, {
2271
+ name: "Client 2",
2272
+ redirectUris: ["https://cb2"],
2273
+ scopes: [],
2274
+ type: "confidential"
2275
+ });
2276
+
2277
+ const clients = await t.query(api.queries.listClients, {});
2278
+ expect(clients.length).toBeGreaterThanOrEqual(2);
2279
+ });
2280
+
2281
+ test("Query: listTokensByUser", async () => {
2282
+ const userId = "user-1";
2283
+ const client = await t.mutation(api.clientManagement.registerClient, {
2284
+ name: "Client",
2285
+ redirectUris: ["https://cb"],
2286
+ scopes: [],
2287
+ type: "public"
2288
+ });
2289
+
2290
+ await t.mutation(api.mutations.saveTokens, {
2291
+ accessToken: "at1",
2292
+ refreshToken: "rt1",
2293
+ clientId: client.clientId,
2294
+ userId,
2295
+ scopes: ["openid"],
2296
+ expiresAt: Date.now() + 3600000,
2297
+ refreshTokenExpiresAt: Date.now() + 864000000,
2298
+ });
2299
+
2300
+ const tokens = await t.query(api.queries.getTokensByUser, {
2301
+ userId
2302
+ });
2303
+ expect(tokens.length).toBeGreaterThan(0);
2304
+ expect(tokens[0].userId).toBe(userId);
2305
+ });
2306
+
2307
+ test("Query: getAuthorization", async () => {
2308
+ const userId = "user-1";
2309
+ const client = await t.mutation(api.clientManagement.registerClient, {
2310
+ name: "Client",
2311
+ redirectUris: ["https://cb"],
2312
+ scopes: [],
2313
+ type: "public"
2314
+ });
2315
+
2316
+ await t.mutation(api.mutations.upsertAuthorization, {
2317
+ userId,
2318
+ clientId: client.clientId,
2319
+ scopes: ["openid", "profile"]
2320
+ });
2321
+
2322
+ const auth = await t.query(api.queries.getAuthorization, {
2323
+ userId,
2324
+ clientId: client.clientId
2325
+ });
2326
+ expect(auth).toBeDefined();
2327
+ expect(auth?.scopes).toContain("openid");
2328
+ expect(auth?.scopes).toContain("profile");
2329
+ });
2330
+
2331
+ test("Query: hasAuthorization", async () => {
2332
+ const userId = "user-1";
2333
+ const client = await t.mutation(api.clientManagement.registerClient, {
2334
+ name: "Client",
2335
+ redirectUris: ["https://cb"],
2336
+ scopes: [],
2337
+ type: "public"
2338
+ });
2339
+
2340
+ // Before authorization
2341
+ const hasBefore = await t.query(api.queries.hasAuthorization, {
2342
+ userId,
2343
+ clientId: client.clientId
2344
+ });
2345
+ expect(hasBefore).toBe(false);
2346
+
2347
+ // Create authorization
2348
+ await t.mutation(api.mutations.upsertAuthorization, {
2349
+ userId,
2350
+ clientId: client.clientId,
2351
+ scopes: ["openid"]
2352
+ });
2353
+
2354
+ // After authorization
2355
+ const hasAfter = await t.query(api.queries.hasAuthorization, {
2356
+ userId,
2357
+ clientId: client.clientId
2358
+ });
2359
+ expect(hasAfter).toBe(true);
2360
+ });
2361
+
2362
+ test("Query: hasAnyAuthorization", async () => {
2363
+ const userId = "user-1";
2364
+ const client = await t.mutation(api.clientManagement.registerClient, {
2365
+ name: "Client",
2366
+ redirectUris: ["https://cb"],
2367
+ scopes: [],
2368
+ type: "public"
2369
+ });
2370
+
2371
+ // No authorizations yet
2372
+ const hasNone = await t.query(api.queries.hasAnyAuthorization, {
2373
+ userId
2374
+ });
2375
+ expect(hasNone).toBe(false);
2376
+
2377
+ // Create authorization
2378
+ await t.mutation(api.mutations.upsertAuthorization, {
2379
+ userId,
2380
+ clientId: client.clientId,
2381
+ scopes: ["openid"]
2382
+ });
2383
+
2384
+ // Has authorization now
2385
+ const hasAny = await t.query(api.queries.hasAnyAuthorization, {
2386
+ userId
2387
+ });
2388
+ expect(hasAny).toBe(true);
2389
+ });
2390
+
2391
+ test("Query: listUserAuthorizations", async () => {
2392
+ const userId = "user-1";
2393
+ const client1 = await t.mutation(api.clientManagement.registerClient, {
2394
+ name: "Client 1",
2395
+ redirectUris: ["https://cb1"],
2396
+ scopes: [],
2397
+ type: "public"
2398
+ });
2399
+ const client2 = await t.mutation(api.clientManagement.registerClient, {
2400
+ name: "Client 2",
2401
+ redirectUris: ["https://cb2"],
2402
+ scopes: [],
2403
+ type: "public"
2404
+ });
2405
+
2406
+ await t.mutation(api.mutations.upsertAuthorization, {
2407
+ userId,
2408
+ clientId: client1.clientId,
2409
+ scopes: ["openid"]
2410
+ });
2411
+ await t.mutation(api.mutations.upsertAuthorization, {
2412
+ userId,
2413
+ clientId: client2.clientId,
2414
+ scopes: ["profile"]
2415
+ });
2416
+
2417
+ const auths = await t.query(api.queries.listUserAuthorizations, {
2418
+ userId
2419
+ });
2420
+ expect(auths.length).toBe(2);
2421
+ });
2422
+
2423
+ test("Query: listUserAuthorizations with client info", async () => {
2424
+ const userId = "user-1";
2425
+ const client = await t.mutation(api.clientManagement.registerClient, {
2426
+ name: "Test Client",
2427
+ redirectUris: ["https://cb"],
2428
+ scopes: [],
2429
+ type: "public",
2430
+ description: "Test Description",
2431
+ website: "https://example.com"
2432
+ });
2433
+
2434
+ await t.mutation(api.mutations.upsertAuthorization, {
2435
+ userId,
2436
+ clientId: client.clientId,
2437
+ scopes: ["openid"]
2438
+ });
2439
+
2440
+ const auths = await t.query(api.queries.listUserAuthorizations, {
2441
+ userId
2442
+ });
2443
+ expect(auths.length).toBe(1);
2444
+ expect(auths[0].clientName).toBe("Test Client");
2445
+ expect(auths[0].clientWebsite).toBe("https://example.com");
2446
+ });
2447
+
2448
+ test("Authorization: upsertAuthorization (merge scopes)", async () => {
2449
+ const userId = "user-1";
2450
+ const client = await t.mutation(api.clientManagement.registerClient, {
2451
+ name: "Client",
2452
+ redirectUris: ["https://cb"],
2453
+ scopes: [],
2454
+ type: "public"
2455
+ });
2456
+
2457
+ // First authorization
2458
+ await t.mutation(api.mutations.upsertAuthorization, {
2459
+ userId,
2460
+ clientId: client.clientId,
2461
+ scopes: ["openid"]
2462
+ });
2463
+
2464
+ // Second authorization (should merge scopes)
2465
+ await t.mutation(api.mutations.upsertAuthorization, {
2466
+ userId,
2467
+ clientId: client.clientId,
2468
+ scopes: ["profile"]
2469
+ });
2470
+
2471
+ const auth = await t.query(api.queries.getAuthorization, {
2472
+ userId,
2473
+ clientId: client.clientId
2474
+ });
2475
+ expect(auth?.scopes).toContain("openid");
2476
+ expect(auth?.scopes).toContain("profile");
2477
+ });
2478
+
2479
+ test("Authorization: updateAuthorizationLastUsed", async () => {
2480
+ const userId = "user-1";
2481
+ const client = await t.mutation(api.clientManagement.registerClient, {
2482
+ name: "Client",
2483
+ redirectUris: ["https://cb"],
2484
+ scopes: [],
2485
+ type: "public"
2486
+ });
2487
+
2488
+ await t.mutation(api.mutations.upsertAuthorization, {
2489
+ userId,
2490
+ clientId: client.clientId,
2491
+ scopes: ["openid"]
2492
+ });
2493
+
2494
+ const before = await t.query(api.queries.getAuthorization, {
2495
+ userId,
2496
+ clientId: client.clientId
2497
+ });
2498
+ const beforeTime = before?.lastUsedAt;
2499
+
2500
+ // Wait a bit
2501
+ await new Promise(resolve => setTimeout(resolve, 10));
2502
+
2503
+ await t.mutation(api.mutations.updateAuthorizationLastUsed, {
2504
+ userId,
2505
+ clientId: client.clientId
2506
+ });
2507
+
2508
+ const after = await t.query(api.queries.getAuthorization, {
2509
+ userId,
2510
+ clientId: client.clientId
2511
+ });
2512
+ expect(after?.lastUsedAt).toBeGreaterThan(beforeTime!);
2513
+ });
2514
+
2515
+ test("Authorization: revokeAuthorization", async () => {
2516
+ const userId = "user-1";
2517
+ const client = await t.mutation(api.clientManagement.registerClient, {
2518
+ name: "Client",
2519
+ redirectUris: ["https://cb"],
2520
+ scopes: [],
2521
+ type: "public"
2522
+ });
2523
+
2524
+ // Create authorization and tokens
2525
+ await t.mutation(api.mutations.upsertAuthorization, {
2526
+ userId,
2527
+ clientId: client.clientId,
2528
+ scopes: ["openid"]
2529
+ });
2530
+ await t.mutation(api.mutations.saveTokens, {
2531
+ accessToken: "at",
2532
+ refreshToken: "rt",
2533
+ clientId: client.clientId,
2534
+ userId,
2535
+ scopes: ["openid"],
2536
+ expiresAt: Date.now() + 3600000,
2537
+ refreshTokenExpiresAt: Date.now() + 864000000,
2538
+ });
2539
+
2540
+ // Revoke
2541
+ const result = await t.mutation(api.mutations.revokeAuthorization, {
2542
+ userId,
2543
+ clientId: client.clientId
2544
+ });
2545
+ expect(result.authorizationDeleted).toBe(true);
2546
+ expect(result.tokensDeleted).toBeGreaterThan(0);
2547
+
2548
+ // Verify authorization gone
2549
+ const auth = await t.query(api.queries.getAuthorization, {
2550
+ userId,
2551
+ clientId: client.clientId
2552
+ });
2553
+ expect(auth).toBeNull();
2554
+
2555
+ // Verify tokens gone
2556
+ const tokens = await t.query(api.queries.getTokensByUser, {
2557
+ userId
2558
+ });
2559
+ expect(tokens.length).toBe(0);
2560
+ });
2561
+
2562
+ async function createUserInfoFixture(
2563
+ scopes: string[],
2564
+ options: { clientId?: string } = {}
2565
+ ) {
2566
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
2567
+ const jwk = await exportJWK(publicKey);
2568
+ const jwks = JSON.stringify({
2569
+ keys: [{
2570
+ ...jwk,
2571
+ kid: "default-key",
2572
+ use: "sig",
2573
+ alg: "RS256",
2574
+ }],
2575
+ });
2576
+ const privateKeyPem = await exportPKCS8(privateKey);
2577
+ const config: OAuthConfig = {
2578
+ privateKey: privateKeyPem,
2579
+ jwks,
2580
+ siteUrl: "https://example.com",
2581
+ };
2582
+ const payload: Record<string, unknown> = {
2583
+ scp: scopes.join(" "),
2584
+ };
2585
+ if (options.clientId) {
2586
+ payload.cid = options.clientId;
2587
+ }
2588
+ const token = await new SignJWT({
2589
+ ...payload,
2590
+ })
2591
+ .setProtectedHeader({ alg: "RS256", kid: "default-key" })
2592
+ .setIssuedAt()
2593
+ .setSubject("user-1")
2594
+ .setAudience("convex")
2595
+ .setIssuer("https://example.com/oauth")
2596
+ .setExpirationTime("1h")
2597
+ .sign(privateKey);
2598
+
2599
+ return { config, token };
2600
+ }
2601
+
2602
+ test("UserInfo: revoked authorization returns invalid_token", async () => {
2603
+ const { config, token } = await createUserInfoFixture(["openid"], { clientId: "client-1" });
2604
+ const request = new Request("https://example.com/oauth/userinfo", {
2605
+ method: "GET",
2606
+ headers: { Authorization: `Bearer ${token}` },
2607
+ });
2608
+ const checkAuthorization = vi.fn(async () => false);
2609
+ const response = await userInfoHandler(
2610
+ {} as any,
2611
+ request,
2612
+ { ...config, checkAuthorization },
2613
+ async (userId) => ({ sub: userId })
2614
+ );
2615
+
2616
+ expect(response.status).toBe(401);
2617
+ const header = response.headers.get("WWW-Authenticate") ?? "";
2618
+ expect(header).toContain("invalid_token");
2619
+ expect(checkAuthorization).toHaveBeenCalledWith(expect.anything(), "user-1", "client-1");
2620
+ });
2621
+
2622
+ test("UserInfo: openid scope required", async () => {
2623
+ const { config, token } = await createUserInfoFixture(["profile"]);
2624
+ const request = new Request("https://example.com/oauth/userinfo", {
2625
+ method: "GET",
2626
+ headers: { Authorization: `Bearer ${token}` },
2627
+ });
2628
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2629
+ sub: userId,
2630
+ name: "Alice",
2631
+ email: "alice@example.com",
2632
+ picture: "https://example.com/avatar.png",
2633
+ email_verified: true,
2634
+ }));
2635
+
2636
+ expect(response.status).toBe(403);
2637
+ const header = response.headers.get("WWW-Authenticate") ?? "";
2638
+ expect(header).toContain("insufficient_scope");
2639
+ });
2640
+
2641
+ test("UserInfo: openid only returns sub", async () => {
2642
+ const { config, token } = await createUserInfoFixture(["openid"]);
2643
+ const request = new Request("https://example.com/oauth/userinfo", {
2644
+ method: "GET",
2645
+ headers: { Authorization: `Bearer ${token}` },
2646
+ });
2647
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2648
+ sub: userId,
2649
+ name: "Alice",
2650
+ email: "alice@example.com",
2651
+ picture: "https://example.com/avatar.png",
2652
+ email_verified: true,
2653
+ }));
2654
+
2655
+ expect(response.status).toBe(200);
2656
+ const body = await response.json();
2657
+ expect(body).toEqual({ sub: "user-1" });
2658
+ });
2659
+
2660
+ test("UserInfo: profile adds name and picture", async () => {
2661
+ const { config, token } = await createUserInfoFixture(["openid", "profile"]);
2662
+ const request = new Request("https://example.com/oauth/userinfo", {
2663
+ method: "GET",
2664
+ headers: { Authorization: `Bearer ${token}` },
2665
+ });
2666
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2667
+ sub: userId,
2668
+ name: "Alice",
2669
+ email: "alice@example.com",
2670
+ picture: "https://example.com/avatar.png",
2671
+ email_verified: true,
2672
+ }));
2673
+
2674
+ expect(response.status).toBe(200);
2675
+ const body = await response.json();
2676
+ expect(body).toEqual({
2677
+ sub: "user-1",
2678
+ name: "Alice",
2679
+ picture: "https://example.com/avatar.png",
2680
+ });
2681
+ });
2682
+
2683
+ test("UserInfo: email adds email and email_verified", async () => {
2684
+ const { config, token } = await createUserInfoFixture(["openid", "email"]);
2685
+ const request = new Request("https://example.com/oauth/userinfo", {
2686
+ method: "GET",
2687
+ headers: { Authorization: `Bearer ${token}` },
2688
+ });
2689
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2690
+ sub: userId,
2691
+ name: "Alice",
2692
+ email: "alice@example.com",
2693
+ picture: "https://example.com/avatar.png",
2694
+ email_verified: true,
2695
+ }));
2696
+
2697
+ expect(response.status).toBe(200);
2698
+ const body = await response.json();
2699
+ expect(body).toEqual({
2700
+ sub: "user-1",
2701
+ email: "alice@example.com",
2702
+ email_verified: true,
2703
+ });
2704
+ });
2705
+
2706
+ test("UserInfo: profile + email returns combined claims", async () => {
2707
+ const { config, token } = await createUserInfoFixture(["openid", "profile", "email"]);
2708
+ const request = new Request("https://example.com/oauth/userinfo", {
2709
+ method: "GET",
2710
+ headers: { Authorization: `Bearer ${token}` },
2711
+ });
2712
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2713
+ sub: userId,
2714
+ name: "Alice",
2715
+ email: "alice@example.com",
2716
+ picture: "https://example.com/avatar.png",
2717
+ email_verified: true,
2718
+ }));
2719
+
2720
+ expect(response.status).toBe(200);
2721
+ const body = await response.json();
2722
+ expect(body).toEqual({
2723
+ sub: "user-1",
2724
+ name: "Alice",
2725
+ picture: "https://example.com/avatar.png",
2726
+ email: "alice@example.com",
2727
+ email_verified: true,
2728
+ });
2729
+ });
2730
+
2731
+ test("UserInfo: returns 401 when getUserProfile returns null", async () => {
2732
+ const { config, token } = await createUserInfoFixture(["openid"]);
2733
+ const request = new Request("https://example.com/oauth/userinfo", {
2734
+ method: "GET",
2735
+ headers: { Authorization: `Bearer ${token}` },
2736
+ });
2737
+ const response = await userInfoHandler({} as any, request, config, async () => null);
2738
+
2739
+ expect(response.status).toBe(401);
2740
+ });
2741
+
2742
+ test("UserInfo: handles OPTIONS request", async () => {
2743
+ const { config } = await createUserInfoFixture(["openid"]);
2744
+ const request = new Request("https://example.com/oauth/userinfo", {
2745
+ method: "OPTIONS",
2746
+ headers: { "Origin": "https://example.com" },
2747
+ });
2748
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2749
+ sub: userId,
2750
+ }));
2751
+
2752
+ expect(response.status).toBe(200);
2753
+ expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET");
2754
+ });
2755
+
2756
+ test("UserInfo: returns 401 when Authorization header is missing", async () => {
2757
+ const { config } = await createUserInfoFixture(["openid"]);
2758
+ const request = new Request("https://example.com/oauth/userinfo", {
2759
+ method: "GET",
2760
+ // No Authorization header
2761
+ });
2762
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2763
+ sub: userId,
2764
+ }));
2765
+
2766
+ expect(response.status).toBe(401);
2767
+ const wwwAuth = response.headers.get("WWW-Authenticate");
2768
+ expect(wwwAuth).toContain("invalid_token");
2769
+ expect(wwwAuth).toContain("Missing bearer token");
2770
+ });
2771
+
2772
+ test("UserInfo: returns 401 when Authorization header is malformed", async () => {
2773
+ const { config } = await createUserInfoFixture(["openid"]);
2774
+ const request = new Request("https://example.com/oauth/userinfo", {
2775
+ method: "GET",
2776
+ headers: { Authorization: "Basic sometoken" }, // Not Bearer
2777
+ });
2778
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2779
+ sub: userId,
2780
+ }));
2781
+
2782
+ expect(response.status).toBe(401);
2783
+ const wwwAuth = response.headers.get("WWW-Authenticate");
2784
+ expect(wwwAuth).toContain("invalid_token");
2785
+ expect(wwwAuth).toContain("Missing bearer token");
2786
+ });
2787
+
2788
+ test("UserInfo: returns 401 when token verification fails", async () => {
2789
+ const { config } = await createUserInfoFixture(["openid"]);
2790
+ const request = new Request("https://example.com/oauth/userinfo", {
2791
+ method: "GET",
2792
+ headers: { Authorization: "Bearer invalid-token" },
2793
+ });
2794
+ const response = await userInfoHandler({} as any, request, config, async (userId) => ({
2795
+ sub: userId,
2796
+ }));
2797
+
2798
+ expect(response.status).toBe(401);
2799
+ const wwwAuth = response.headers.get("WWW-Authenticate");
2800
+ expect(wwwAuth).toContain("invalid_token");
2801
+ expect(wwwAuth).toContain("Token verification failed");
2802
+ });
2803
+
2804
+ // ==========================================
2805
+ // Phase: Refresh Token Grant Tests
2806
+ // ==========================================
2807
+
2808
+ // Removed: offline_access is no longer required for refresh_token grant (RFC non-compliant)
2809
+
2810
+ test("Token Handler: refresh_token grant rejects expired refresh token", async () => {
2811
+ const config: OAuthConfig = {
2812
+ privateKey: "dummy",
2813
+ jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
2814
+ siteUrl: "https://example.com",
2815
+ };
2816
+
2817
+ const apiStub: OAuthComponentAPI = {
2818
+ queries: {
2819
+ getClient: async () => ({
2820
+ clientId: "client",
2821
+ type: "public",
2822
+ redirectUris: ["https://cb"],
2823
+ allowedScopes: ["openid", "offline_access"],
2824
+ }),
2825
+ getRefreshToken: async () => ({
2826
+ userId: "user-1",
2827
+ clientId: "client",
2828
+ scopes: ["openid", "offline_access"],
2829
+ expiresAt: Date.now() + 3600000,
2830
+ refreshTokenExpiresAt: Date.now() - 1000, // Expired
2831
+ }),
2832
+ getTokensByUser: async () => [],
2833
+ },
2834
+ mutations: {
2835
+ issueAuthorizationCode: async () => "",
2836
+ consumeAuthCode: async () => ({ userId: "", scopes: [], codeChallenge: "", codeChallengeMethod: "", redirectUri: "", nonce: undefined,
2837
+ codeHash: "test-code-hash",
2838
+ }),
2839
+ saveTokens: async () => undefined,
2840
+ rotateRefreshToken: async () => undefined,
2841
+ upsertAuthorization: async () => "",
2842
+ updateAuthorizationLastUsed: async () => undefined,
2843
+ },
2844
+ clientManagement: {
2845
+ registerClient: async () => ({ clientId: "", clientIdIssuedAt: 0 }),
2846
+ verifyClientSecret: async () => false,
2847
+ },
2848
+ };
2849
+
2850
+ const request = new Request("https://example.com/oauth/token", {
2851
+ method: "POST",
2852
+ body: new URLSearchParams({
2853
+ grant_type: "refresh_token",
2854
+ client_id: "client",
2855
+ refresh_token: "test-refresh-token",
2856
+ }),
2857
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2858
+ });
2859
+
2860
+ const response = await tokenHandler({} as any, request, config, apiStub);
2861
+
2862
+ expect(response.status).toBe(400);
2863
+ const body = await response.json();
2864
+ expect(body.error).toBe("invalid_grant");
2865
+ expect(body.error_description).toBe("Refresh token expired");
2866
+ });
2867
+
2868
+ test("Token Handler: refresh_token grant succeeds with offline_access scope", async () => {
2869
+ const { privateKey, publicKey } = await generateKeyPair("RS256");
2870
+ const privateKeyPEM = await exportPKCS8(privateKey);
2871
+ const publicJWK = await exportJWK(publicKey);
2872
+
2873
+ const jwks = JSON.stringify({
2874
+ keys: [{ ...publicJWK, use: "sig", alg: "RS256", kid: "default-key" }],
2875
+ });
2876
+
2877
+ const config: OAuthConfig = {
2878
+ privateKey: privateKeyPEM,
2879
+ jwks,
2880
+ siteUrl: "https://example.com",
2881
+ };
2882
+
2883
+ const apiStub: OAuthComponentAPI = {
2884
+ queries: {
2885
+ getClient: async () => ({
2886
+ clientId: "client",
2887
+ type: "public",
2888
+ redirectUris: ["https://cb"],
2889
+ allowedScopes: ["openid", "profile", "offline_access"],
2890
+ }),
2891
+ getRefreshToken: async () => ({
2892
+ userId: "user-1",
2893
+ clientId: "client",
2894
+ scopes: ["openid", "profile", "offline_access"], // Has offline_access
2895
+ expiresAt: Date.now() + 3600000,
2896
+ refreshTokenExpiresAt: Date.now() + 864000000,
2897
+ }),
2898
+ getTokensByUser: async () => [],
2899
+ },
2900
+ mutations: {
2901
+ issueAuthorizationCode: async () => "",
2902
+ consumeAuthCode: async () => ({ userId: "", scopes: [], codeChallenge: "", codeChallengeMethod: "", redirectUri: "", nonce: undefined,
2903
+ codeHash: "test-code-hash",
2904
+ }),
2905
+ saveTokens: async () => undefined,
2906
+ rotateRefreshToken: async () => undefined,
2907
+ upsertAuthorization: async () => "",
2908
+ updateAuthorizationLastUsed: async () => undefined,
2909
+ },
2910
+ clientManagement: {
2911
+ registerClient: async () => ({ clientId: "", clientIdIssuedAt: 0 }),
2912
+ verifyClientSecret: async () => false,
2913
+ },
2914
+ };
2915
+
2916
+ const request = new Request("https://example.com/oauth/token", {
2917
+ method: "POST",
2918
+ body: new URLSearchParams({
2919
+ grant_type: "refresh_token",
2920
+ client_id: "client",
2921
+ refresh_token: "test-refresh-token",
2922
+ }),
2923
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2924
+ });
2925
+
2926
+ const response = await tokenHandler({} as any, request, config, apiStub);
2927
+
2928
+ expect(response.status).toBe(200);
2929
+ const body = await response.json();
2930
+ expect(body.access_token).toBeDefined();
2931
+ expect(body.refresh_token).toBeDefined(); // Should receive new refresh token
2932
+ expect(body.token_type).toBe("Bearer");
2933
+ });
2934
+
2935
+ // ==========================================
2936
+ // Phase: Mutations Coverage Tests
2937
+ // ==========================================
2938
+
2939
+ test("consumeAuthCode: rejects redirectUri mismatch", async () => {
2940
+ const userId = "test-user-id";
2941
+ const client = await t.mutation(api.clientManagement.registerClient, {
2942
+ name: "Test Client",
2943
+ redirectUris: ["https://cb"],
2944
+ scopes: ["openid"],
2945
+ type: "public"
2946
+ });
2947
+
2948
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
2949
+ clientId: client.clientId,
2950
+ userId,
2951
+ redirectUri: "https://cb",
2952
+ scopes: ["openid"],
2953
+ codeChallenge: "challenge",
2954
+ codeChallengeMethod: "S256"
2955
+ });
2956
+
2957
+ await expect(
2958
+ t.mutation(api.mutations.consumeAuthCode, {
2959
+ code,
2960
+ clientId: client.clientId,
2961
+ redirectUri: "https://wrong-redirect",
2962
+ codeVerifier: "verifier",
2963
+ })
2964
+ ).rejects.toThrow("redirect_uri_mismatch");
2965
+ });
2966
+
2967
+ test("consumeAuthCode: rejects S256 PKCE verification failure", async () => {
2968
+ const userId = "test-user-id";
2969
+ const client = await t.mutation(api.clientManagement.registerClient, {
2970
+ name: "Test Client",
2971
+ redirectUris: ["https://cb"],
2972
+ scopes: ["openid"],
2973
+ type: "public"
2974
+ });
2975
+
2976
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
2977
+ clientId: client.clientId,
2978
+ userId,
2979
+ redirectUri: "https://cb",
2980
+ scopes: ["openid"],
2981
+ codeChallenge: "correct-challenge",
2982
+ codeChallengeMethod: "S256"
2983
+ });
2984
+
2985
+ await expect(
2986
+ t.mutation(api.mutations.consumeAuthCode, {
2987
+ code,
2988
+ clientId: client.clientId,
2989
+ redirectUri: "https://cb",
2990
+ codeVerifier: "wrong-verifier",
2991
+ })
2992
+ ).rejects.toThrow("invalid_code_verifier");
2993
+ });
2994
+
2995
+ test("consumeAuthCode: rejects plain PKCE verification failure", async () => {
2996
+ const userId = "test-user-id";
2997
+ const client = await t.mutation(api.clientManagement.registerClient, {
2998
+ name: "Test Client",
2999
+ redirectUris: ["https://cb"],
3000
+ scopes: ["openid"],
3001
+ type: "public"
3002
+ });
3003
+
3004
+ // Create code with plain PKCE method directly in DB (since issueAuthorizationCode rejects plain)
3005
+ const code = "plain-pkce-code-123";
3006
+ await t.run(async (ctx) => {
3007
+ await ctx.db.insert("oauthCodes", {
3008
+ code,
3009
+ clientId: client.clientId,
3010
+ userId,
3011
+ redirectUri: "https://cb",
3012
+ scopes: ["openid"],
3013
+ codeChallenge: "correct-challenge",
3014
+ codeChallengeMethod: "plain",
3015
+ expiresAt: Date.now() + 600000,
3016
+ });
3017
+ });
3018
+
3019
+ await expect(
3020
+ t.mutation(api.mutations.consumeAuthCode, {
3021
+ code,
3022
+ clientId: client.clientId,
3023
+ redirectUri: "https://cb",
3024
+ codeVerifier: "wrong-verifier",
3025
+ })
3026
+ ).rejects.toThrow("invalid_code_verifier");
3027
+ });
3028
+
3029
+ test("consumeAuthCode: rejects expired code", async () => {
3030
+ const userId = "test-user-id";
3031
+ const client = await t.mutation(api.clientManagement.registerClient, {
3032
+ name: "Test Client",
3033
+ redirectUris: ["https://cb"],
3034
+ scopes: ["openid"],
3035
+ type: "public"
3036
+ });
3037
+
3038
+ // Create expired code directly in DB
3039
+ const code = "expired-code-123";
3040
+ await t.run(async (ctx) => {
3041
+ await ctx.db.insert("oauthCodes", {
3042
+ code,
3043
+ clientId: client.clientId,
3044
+ userId,
3045
+ redirectUri: "https://cb",
3046
+ scopes: ["openid"],
3047
+ codeChallenge: "challenge",
3048
+ codeChallengeMethod: "S256",
3049
+ expiresAt: Date.now() - 1000, // Expired
3050
+ });
3051
+ });
3052
+
3053
+ await expect(
3054
+ t.mutation(api.mutations.consumeAuthCode, {
3055
+ code,
3056
+ clientId: client.clientId,
3057
+ redirectUri: "https://cb",
3058
+ codeVerifier: "verifier",
3059
+ })
3060
+ ).rejects.toThrow("invalid_grant");
3061
+ });
3062
+
3063
+ test("consumeAuthCode: rejects invalid PKCE method", async () => {
3064
+ const userId = "test-user-id";
3065
+ const client = await t.mutation(api.clientManagement.registerClient, {
3066
+ name: "Test Client",
3067
+ redirectUris: ["https://cb"],
3068
+ scopes: ["openid"],
3069
+ type: "public"
3070
+ });
3071
+
3072
+ // Create code with invalid PKCE method directly in DB
3073
+ const code = "invalid-method-code-123";
3074
+ await t.run(async (ctx) => {
3075
+ await ctx.db.insert("oauthCodes", {
3076
+ code,
3077
+ clientId: client.clientId,
3078
+ userId,
3079
+ redirectUri: "https://cb",
3080
+ scopes: ["openid"],
3081
+ codeChallenge: "challenge",
3082
+ codeChallengeMethod: "MD5", // Invalid method
3083
+ expiresAt: Date.now() + 600000,
3084
+ });
3085
+ });
3086
+
3087
+ await expect(
3088
+ t.mutation(api.mutations.consumeAuthCode, {
3089
+ code,
3090
+ clientId: client.clientId,
3091
+ redirectUri: "https://cb",
3092
+ codeVerifier: "verifier",
3093
+ })
3094
+ ).rejects.toThrow("unsupported_code_challenge_method");
3095
+ });
3096
+
3097
+ test("rotateRefreshToken: rejects client mismatch", async () => {
3098
+ const userId = "test-user-id";
3099
+ const client1 = await t.mutation(api.clientManagement.registerClient, {
3100
+ name: "Client 1",
3101
+ redirectUris: ["https://cb"],
3102
+ scopes: ["openid"],
3103
+ type: "public"
3104
+ });
3105
+ const client2 = await t.mutation(api.clientManagement.registerClient, {
3106
+ name: "Client 2",
3107
+ redirectUris: ["https://cb"],
3108
+ scopes: ["openid"],
3109
+ type: "public"
3110
+ });
3111
+
3112
+ const oldRefreshToken = "old_rt";
3113
+ await t.mutation(api.mutations.saveTokens, {
3114
+ accessToken: "old_at",
3115
+ refreshToken: oldRefreshToken,
3116
+ clientId: client1.clientId,
3117
+ userId,
3118
+ scopes: ["openid"],
3119
+ expiresAt: Date.now() + 3600000,
3120
+ refreshTokenExpiresAt: Date.now() + 864000000,
3121
+ });
3122
+
3123
+ await expect(
3124
+ t.mutation(api.mutations.rotateRefreshToken, {
3125
+ oldRefreshToken,
3126
+ accessToken: "new_at",
3127
+ refreshToken: "new_rt",
3128
+ clientId: client2.clientId, // Wrong client
3129
+ userId,
3130
+ scopes: ["openid"],
3131
+ expiresAt: Date.now() + 3600000,
3132
+ refreshTokenExpiresAt: Date.now() + 864000000,
3133
+ })
3134
+ ).rejects.toThrow("invalid_grant");
3135
+ });
3136
+
3137
+ test("rotateRefreshToken: rejects user mismatch", async () => {
3138
+ const userId1 = "user-1";
3139
+ const userId2 = "user-2";
3140
+ const client = await t.mutation(api.clientManagement.registerClient, {
3141
+ name: "Test Client",
3142
+ redirectUris: ["https://cb"],
3143
+ scopes: ["openid"],
3144
+ type: "public"
3145
+ });
3146
+
3147
+ const oldRefreshToken = "old_rt";
3148
+ await t.mutation(api.mutations.saveTokens, {
3149
+ accessToken: "old_at",
3150
+ refreshToken: oldRefreshToken,
3151
+ clientId: client.clientId,
3152
+ userId: userId1,
3153
+ scopes: ["openid"],
3154
+ expiresAt: Date.now() + 3600000,
3155
+ refreshTokenExpiresAt: Date.now() + 864000000,
3156
+ });
3157
+
3158
+ await expect(
3159
+ t.mutation(api.mutations.rotateRefreshToken, {
3160
+ oldRefreshToken,
3161
+ accessToken: "new_at",
3162
+ refreshToken: "new_rt",
3163
+ clientId: client.clientId,
3164
+ userId: userId2, // Wrong user
3165
+ scopes: ["openid"],
3166
+ expiresAt: Date.now() + 3600000,
3167
+ refreshTokenExpiresAt: Date.now() + 864000000,
3168
+ })
3169
+ ).rejects.toThrow("invalid_grant");
3170
+ });
3171
+
3172
+ test("deleteClient: rejects when client not found", async () => {
3173
+ await expect(
3174
+ t.mutation(api.mutations.deleteClient, {
3175
+ clientId: "non-existent-client",
3176
+ })
3177
+ ).rejects.toThrow("Client not found");
3178
+ });
3179
+
3180
+ // ==========================================
3181
+ // Phase: Client Management Coverage Tests
3182
+ // ==========================================
3183
+
3184
+ test("registerClient: rejects empty redirect_uris", async () => {
3185
+ await expect(
3186
+ t.mutation(api.clientManagement.registerClient, {
3187
+ name: "Test Client",
3188
+ redirectUris: [],
3189
+ scopes: ["openid"],
3190
+ type: "public"
3191
+ })
3192
+ ).rejects.toThrow("redirect_uris required");
3193
+ });
3194
+
3195
+ test("registerClient: rejects invalid redirect_uri (unparseable)", async () => {
3196
+ await expect(
3197
+ t.mutation(api.clientManagement.registerClient, {
3198
+ name: "Test Client",
3199
+ redirectUris: ["not-a-valid-url"],
3200
+ scopes: ["openid"],
3201
+ type: "public"
3202
+ })
3203
+ ).rejects.toThrow("Invalid redirect_uri");
3204
+ });
3205
+
3206
+ test("verifyClientSecret: returns false when client not found", async () => {
3207
+ const result = await t.mutation(api.clientManagement.verifyClientSecret, {
3208
+ clientId: "non-existent-client",
3209
+ clientSecret: "secret"
3210
+ });
3211
+ expect(result).toBe(false);
3212
+ });
3213
+
3214
+ test("verifyClientSecret: returns false when client has no secret", async () => {
3215
+ const client = await t.mutation(api.clientManagement.registerClient, {
3216
+ name: "Public Client",
3217
+ redirectUris: ["https://cb"],
3218
+ scopes: ["openid"],
3219
+ type: "public"
3220
+ });
3221
+
3222
+ const result = await t.mutation(api.clientManagement.verifyClientSecret, {
3223
+ clientId: client.clientId,
3224
+ clientSecret: "any-secret"
3225
+ });
3226
+ expect(result).toBe(false);
3227
+ });
3228
+
3229
+ test("verifyClientSecret: returns false on bcrypt error", async () => {
3230
+ const client = await t.mutation(api.clientManagement.registerClient, {
3231
+ name: "Confidential Client",
3232
+ redirectUris: ["https://cb"],
3233
+ scopes: ["openid"],
3234
+ type: "confidential"
3235
+ });
3236
+
3237
+ // Corrupt the client secret in DB to trigger bcrypt error
3238
+ await t.run(async (ctx) => {
3239
+ const clientInDb = await ctx.db.query("oauthClients")
3240
+ .filter((q) => q.eq(q.field("clientId"), client.clientId))
3241
+ .unique();
3242
+ if (clientInDb) {
3243
+ await ctx.db.patch(clientInDb._id, {
3244
+ clientSecret: "invalid-bcrypt-hash"
3245
+ });
3246
+ }
3247
+ });
3248
+
3249
+ const result = await t.mutation(api.clientManagement.verifyClientSecret, {
3250
+ clientId: client.clientId,
3251
+ clientSecret: client.clientSecret!
3252
+ });
3253
+ expect(result).toBe(false);
3254
+ });
3255
+
3256
+ test("deleteClient: deletes client with all associated data", async () => {
3257
+ const userId = "test-user-id";
3258
+ const client = await t.mutation(api.clientManagement.registerClient, {
3259
+ name: "Test Client",
3260
+ redirectUris: ["https://cb"],
3261
+ scopes: ["openid"],
3262
+ type: "public"
3263
+ });
3264
+
3265
+ // Create associated data
3266
+ await t.mutation(api.mutations.issueAuthorizationCode, {
3267
+ clientId: client.clientId,
3268
+ userId,
3269
+ redirectUri: "https://cb",
3270
+ scopes: ["openid"],
3271
+ codeChallenge: "challenge",
3272
+ codeChallengeMethod: "S256"
3273
+ });
3274
+
3275
+ await t.mutation(api.mutations.saveTokens, {
3276
+ accessToken: "test-token",
3277
+ clientId: client.clientId,
3278
+ userId,
3279
+ scopes: ["openid"],
3280
+ expiresAt: Date.now() + 3600000,
3281
+ });
3282
+
3283
+ // Delete client
3284
+ const result = await t.mutation(api.clientManagement.deleteClient, {
3285
+ clientId: client.clientId,
3286
+ });
3287
+ expect(result.success).toBe(true);
3288
+
3289
+ // Verify client deleted
3290
+ const clientInDb = await t.query(api.queries.getClient, {
3291
+ clientId: client.clientId,
3292
+ });
3293
+ expect(clientInDb).toBeNull();
3294
+
3295
+ // Verify associated data deleted
3296
+ const tokens = await t.run(async (ctx) => {
3297
+ return await ctx.db.query("oauthTokens")
3298
+ .filter(q => q.eq(q.field("clientId"), client.clientId))
3299
+ .collect();
3300
+ });
3301
+ expect(tokens).toHaveLength(0);
3302
+
3303
+ const codes = await t.run(async (ctx) => {
3304
+ return await ctx.db.query("oauthCodes")
3305
+ .filter(q => q.eq(q.field("clientId"), client.clientId))
3306
+ .collect();
3307
+ });
3308
+ expect(codes).toHaveLength(0);
3309
+ });
3310
+ });