@codefox-inc/oauth-provider 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +40 -14
  2. package/dist/client/index.d.ts +4 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +1 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +9 -0
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/clientManagement.d.ts +1 -0
  9. package/dist/component/clientManagement.d.ts.map +1 -1
  10. package/dist/component/clientManagement.js +24 -0
  11. package/dist/component/clientManagement.js.map +1 -1
  12. package/dist/component/handlers.d.ts +16 -0
  13. package/dist/component/handlers.d.ts.map +1 -1
  14. package/dist/component/handlers.js +278 -29
  15. package/dist/component/handlers.js.map +1 -1
  16. package/dist/component/mutations.d.ts +9 -0
  17. package/dist/component/mutations.d.ts.map +1 -1
  18. package/dist/component/mutations.js +112 -40
  19. package/dist/component/mutations.js.map +1 -1
  20. package/dist/component/queries.d.ts +8 -0
  21. package/dist/component/queries.d.ts.map +1 -1
  22. package/dist/component/schema.d.ts +18 -4
  23. package/dist/component/schema.d.ts.map +1 -1
  24. package/dist/component/schema.js +7 -0
  25. package/dist/component/schema.js.map +1 -1
  26. package/dist/lib/oauth.d.ts.map +1 -1
  27. package/dist/lib/oauth.js +5 -2
  28. package/dist/lib/oauth.js.map +1 -1
  29. package/package.json +39 -39
  30. package/src/client/__tests__/oauth-provider.test.ts +39 -0
  31. package/src/client/index.ts +4 -0
  32. package/src/component/__tests__/handlers-protocol.test.ts +914 -0
  33. package/src/component/__tests__/mutations-protocol.test.ts +448 -0
  34. package/src/component/__tests__/oauth.test.ts +32 -28
  35. package/src/component/__tests__/rfc-compliance.test.ts +79 -11
  36. package/src/component/_generated/component.ts +17 -1
  37. package/src/component/clientManagement.ts +31 -0
  38. package/src/component/handlers.ts +358 -32
  39. package/src/component/mutations.ts +133 -40
  40. package/src/component/schema.ts +11 -0
  41. package/src/lib/__tests__/oauth-jwt.test.ts +68 -0
  42. package/src/lib/oauth.ts +8 -4
@@ -0,0 +1,448 @@
1
+ import { convexTest } from "convex-test";
2
+ import { describe, expect, test } from "vitest";
3
+ import { api } from "../_generated/api";
4
+ import schema from "../schema";
5
+
6
+ const modules = import.meta.glob("../**/*.ts");
7
+
8
+ const validCodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
9
+ const validCodeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
10
+ const wrongCodeVerifier = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
11
+
12
+ async function issueCodeWithSavedToken() {
13
+ const t = convexTest(schema, modules);
14
+ const client = await t.mutation(api.clientManagement.registerClient, {
15
+ name: "Native Protocol Client",
16
+ type: "public",
17
+ redirectUris: ["https://example.com/callback"],
18
+ scopes: ["openid"],
19
+ });
20
+
21
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
22
+ userId: "user123",
23
+ clientId: client.clientId,
24
+ scopes: ["openid"],
25
+ redirectUri: "https://example.com/callback",
26
+ codeChallenge: validCodeChallenge,
27
+ codeChallengeMethod: "S256",
28
+ });
29
+
30
+ const codeData = await t.mutation(api.mutations.consumeAuthCode, {
31
+ code,
32
+ clientId: client.clientId,
33
+ redirectUri: "https://example.com/callback",
34
+ codeVerifier: validCodeVerifier,
35
+ });
36
+
37
+ await t.mutation(api.mutations.saveTokens, {
38
+ accessToken: "access-token",
39
+ refreshToken: "refresh-token",
40
+ clientId: client.clientId,
41
+ userId: "user123",
42
+ scopes: ["openid"],
43
+ expiresAt: Date.now() + 3600000,
44
+ refreshTokenExpiresAt: Date.now() + 2592000000,
45
+ authorizationCode: codeData.codeHash,
46
+ });
47
+
48
+ return { t, client, code, codeHash: codeData.codeHash };
49
+ }
50
+
51
+ async function getReplayState(
52
+ t: ReturnType<typeof convexTest>,
53
+ codeHash: string
54
+ ) {
55
+ return await t.run(async (ctx) => {
56
+ const db = ctx.db as any;
57
+ const code = await db
58
+ .query("oauthCodes")
59
+ .withIndex("by_code", (q: any) => q.eq("code", codeHash))
60
+ .unique();
61
+ const tokens = await db
62
+ .query("oauthTokens")
63
+ .withIndex("by_authorization_code", (q: any) =>
64
+ q.eq("authorizationCode", codeHash)
65
+ )
66
+ .collect();
67
+
68
+ return {
69
+ replayDetectedAt: code?.replayDetectedAt,
70
+ tokenCount: tokens.length,
71
+ };
72
+ });
73
+ }
74
+
75
+ describe("OAuth mutation protocol enforcement", () => {
76
+ test.each([
77
+ ["short code_challenge", "A".repeat(42)],
78
+ ["long code_challenge", "A".repeat(129)],
79
+ ["invalid code_challenge character", `${"A".repeat(42)}!`],
80
+ ])("issueAuthorizationCode rejects %s", async (_caseName, codeChallenge) => {
81
+ const t = convexTest(schema, modules);
82
+ const client = await t.mutation(api.clientManagement.registerClient, {
83
+ name: "PKCE Client",
84
+ type: "public",
85
+ redirectUris: ["https://example.com/callback"],
86
+ scopes: ["openid"],
87
+ });
88
+
89
+ await expect(
90
+ t.mutation(api.mutations.issueAuthorizationCode, {
91
+ userId: "user123",
92
+ clientId: client.clientId,
93
+ scopes: ["openid"],
94
+ redirectUri: "https://example.com/callback",
95
+ codeChallenge,
96
+ codeChallengeMethod: "S256",
97
+ })
98
+ ).rejects.toThrow("invalid_code_challenge");
99
+ });
100
+
101
+ test.each([
102
+ ["short code_verifier", "A".repeat(42)],
103
+ ["long code_verifier", "A".repeat(129)],
104
+ ["invalid code_verifier character", `${"A".repeat(42)}!`],
105
+ ])("consumeAuthCode rejects %s", async (_caseName, codeVerifier) => {
106
+ const t = convexTest(schema, modules);
107
+ const client = await t.mutation(api.clientManagement.registerClient, {
108
+ name: "PKCE Client",
109
+ type: "public",
110
+ redirectUris: ["https://example.com/callback"],
111
+ scopes: ["openid"],
112
+ });
113
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
114
+ userId: "user123",
115
+ clientId: client.clientId,
116
+ scopes: ["openid"],
117
+ redirectUri: "https://example.com/callback",
118
+ codeChallenge: validCodeChallenge,
119
+ codeChallengeMethod: "S256",
120
+ });
121
+
122
+ await expect(
123
+ t.mutation(api.mutations.consumeAuthCode, {
124
+ code,
125
+ clientId: client.clientId,
126
+ redirectUri: "https://example.com/callback",
127
+ codeVerifier,
128
+ })
129
+ ).rejects.toThrow("invalid_code_verifier");
130
+ });
131
+
132
+ test("https loopback redirect_uri requires exact port match", async () => {
133
+ const t = convexTest(schema, modules);
134
+ const client = await t.mutation(api.clientManagement.registerClient, {
135
+ name: "HTTPS Loopback Client",
136
+ type: "public",
137
+ redirectUris: ["https://127.0.0.1/callback"],
138
+ scopes: ["openid"],
139
+ });
140
+
141
+ await expect(
142
+ t.mutation(api.mutations.issueAuthorizationCode, {
143
+ userId: "user123",
144
+ clientId: client.clientId,
145
+ scopes: ["openid"],
146
+ redirectUri: "https://127.0.0.1:8080/callback",
147
+ codeChallenge: validCodeChallenge,
148
+ codeChallengeMethod: "S256",
149
+ })
150
+ ).rejects.toThrow("redirect_uri_mismatch");
151
+ });
152
+
153
+ test("authorization code resource is persisted and mismatched token resource is rejected before consumption", async () => {
154
+ const t = convexTest(schema, modules);
155
+ const client = await t.mutation(api.clientManagement.registerClient, {
156
+ name: "Resource Client",
157
+ type: "public",
158
+ redirectUris: ["https://example.com/callback"],
159
+ scopes: ["openid"],
160
+ });
161
+
162
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
163
+ userId: "user123",
164
+ clientId: client.clientId,
165
+ scopes: ["openid"],
166
+ redirectUri: "https://example.com/callback",
167
+ codeChallenge: validCodeChallenge,
168
+ codeChallengeMethod: "S256",
169
+ resource: "https://api.example.com/mcp",
170
+ });
171
+
172
+ await expect(
173
+ t.mutation(api.mutations.consumeAuthCode, {
174
+ code,
175
+ clientId: client.clientId,
176
+ redirectUri: "https://example.com/callback",
177
+ codeVerifier: validCodeVerifier,
178
+ resource: "https://api.example.com/other",
179
+ })
180
+ ).rejects.toThrow("invalid_target");
181
+
182
+ const codeData = await t.mutation(api.mutations.consumeAuthCode, {
183
+ code,
184
+ clientId: client.clientId,
185
+ redirectUri: "https://example.com/callback",
186
+ codeVerifier: validCodeVerifier,
187
+ resource: "https://api.example.com/mcp",
188
+ });
189
+
190
+ expect(codeData.resource).toBe("https://api.example.com/mcp");
191
+ });
192
+
193
+ test.each([
194
+ ["relative resource", "/mcp"],
195
+ ["fragment resource", "https://api.example.com/mcp#fragment"],
196
+ ["invalid URL resource", "not a url"],
197
+ ])("issueAuthorizationCode rejects %s", async (_caseName, resource) => {
198
+ const t = convexTest(schema, modules);
199
+ const client = await t.mutation(api.clientManagement.registerClient, {
200
+ name: "Resource Client",
201
+ type: "public",
202
+ redirectUris: ["https://example.com/callback"],
203
+ scopes: ["openid"],
204
+ });
205
+
206
+ await expect(
207
+ t.mutation(api.mutations.issueAuthorizationCode, {
208
+ userId: "user123",
209
+ clientId: client.clientId,
210
+ scopes: ["openid"],
211
+ redirectUri: "https://example.com/callback",
212
+ codeChallenge: validCodeChallenge,
213
+ codeChallengeMethod: "S256",
214
+ resource,
215
+ })
216
+ ).rejects.toThrow("invalid_target");
217
+ });
218
+
219
+ test("upsertAuthorization rejects invalid resource metadata", async () => {
220
+ const t = convexTest(schema, modules);
221
+
222
+ await expect(
223
+ t.mutation(api.mutations.upsertAuthorization, {
224
+ userId: "user123",
225
+ clientId: "client123",
226
+ scopes: ["openid"],
227
+ resource: "https://api.example.com/mcp#fragment",
228
+ })
229
+ ).rejects.toThrow("invalid_target");
230
+ });
231
+
232
+ test("consumeAuthCode allows redirect_uri omission and validates it only when supplied", async () => {
233
+ const t = convexTest(schema, modules);
234
+ const client = await t.mutation(api.clientManagement.registerClient, {
235
+ name: "OAuth 2.1 Client",
236
+ type: "public",
237
+ redirectUris: ["https://example.com/callback"],
238
+ scopes: ["openid"],
239
+ });
240
+
241
+ const omittedRedirectCode = await t.mutation(api.mutations.issueAuthorizationCode, {
242
+ userId: "user123",
243
+ clientId: client.clientId,
244
+ scopes: ["openid"],
245
+ redirectUri: "https://example.com/callback",
246
+ codeChallenge: validCodeChallenge,
247
+ codeChallengeMethod: "S256",
248
+ });
249
+
250
+ const omittedRedirectResult = await t.mutation(api.mutations.consumeAuthCode, {
251
+ code: omittedRedirectCode,
252
+ clientId: client.clientId,
253
+ codeVerifier: validCodeVerifier,
254
+ });
255
+
256
+ expect(omittedRedirectResult.redirectUri).toBe("https://example.com/callback");
257
+
258
+ const mismatchedRedirectCode = await t.mutation(api.mutations.issueAuthorizationCode, {
259
+ userId: "user123",
260
+ clientId: client.clientId,
261
+ scopes: ["openid"],
262
+ redirectUri: "https://example.com/callback",
263
+ codeChallenge: validCodeChallenge,
264
+ codeChallengeMethod: "S256",
265
+ });
266
+
267
+ await expect(
268
+ t.mutation(api.mutations.consumeAuthCode, {
269
+ code: mismatchedRedirectCode,
270
+ clientId: client.clientId,
271
+ redirectUri: "https://example.com/other",
272
+ codeVerifier: validCodeVerifier,
273
+ })
274
+ ).rejects.toThrow("redirect_uri_mismatch");
275
+ });
276
+
277
+ test("consumeAuthCode rejects token resource when authorization code has no resource binding", async () => {
278
+ const t = convexTest(schema, modules);
279
+ const client = await t.mutation(api.clientManagement.registerClient, {
280
+ name: "Unbound Resource Client",
281
+ type: "public",
282
+ redirectUris: ["https://example.com/callback"],
283
+ scopes: ["openid"],
284
+ });
285
+
286
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
287
+ userId: "user123",
288
+ clientId: client.clientId,
289
+ scopes: ["openid"],
290
+ redirectUri: "https://example.com/callback",
291
+ codeChallenge: validCodeChallenge,
292
+ codeChallengeMethod: "S256",
293
+ });
294
+
295
+ await expect(
296
+ t.mutation(api.mutations.consumeAuthCode, {
297
+ code,
298
+ clientId: client.clientId,
299
+ redirectUri: "https://example.com/callback",
300
+ codeVerifier: validCodeVerifier,
301
+ resource: "https://api.example.com/mcp",
302
+ })
303
+ ).rejects.toThrow("invalid_target");
304
+ });
305
+
306
+ test("issueAuthorizationCode persists auth_time for OIDC ID tokens", async () => {
307
+ const t = convexTest(schema, modules);
308
+ const client = await t.mutation(api.clientManagement.registerClient, {
309
+ name: "OIDC Client",
310
+ type: "public",
311
+ redirectUris: ["https://example.com/callback"],
312
+ scopes: ["openid"],
313
+ });
314
+
315
+ const code = await t.mutation(api.mutations.issueAuthorizationCode, {
316
+ userId: "user123",
317
+ clientId: client.clientId,
318
+ scopes: ["openid"],
319
+ redirectUri: "https://example.com/callback",
320
+ codeChallenge: validCodeChallenge,
321
+ codeChallengeMethod: "S256",
322
+ authTime: 1710000000,
323
+ });
324
+
325
+ const codeData = await t.mutation(api.mutations.consumeAuthCode, {
326
+ code,
327
+ clientId: client.clientId,
328
+ redirectUri: "https://example.com/callback",
329
+ codeVerifier: validCodeVerifier,
330
+ });
331
+
332
+ expect(codeData.authTime).toBe(1710000000);
333
+ });
334
+
335
+ test("refresh token rotation persists resource and default audience bindings", async () => {
336
+ const t = convexTest(schema, modules);
337
+ const client = await t.mutation(api.clientManagement.registerClient, {
338
+ name: "Audience Client",
339
+ type: "confidential",
340
+ redirectUris: ["https://example.com/callback"],
341
+ scopes: ["openid", "offline_access"],
342
+ });
343
+
344
+ await t.mutation(api.mutations.saveTokens, {
345
+ accessToken: "old-access-token",
346
+ refreshToken: "old-refresh-token",
347
+ clientId: client.clientId,
348
+ userId: "user123",
349
+ scopes: ["openid", "offline_access"],
350
+ expiresAt: Date.now() + 3600000,
351
+ refreshTokenExpiresAt: Date.now() + 2592000000,
352
+ audience: "default-audience",
353
+ });
354
+
355
+ await t.mutation(api.mutations.rotateRefreshToken, {
356
+ oldRefreshToken: "old-refresh-token",
357
+ accessToken: "new-access-token",
358
+ refreshToken: "new-refresh-token",
359
+ clientId: client.clientId,
360
+ userId: "user123",
361
+ scopes: ["openid", "offline_access"],
362
+ expiresAt: Date.now() + 3600000,
363
+ refreshTokenExpiresAt: Date.now() + 2592000000,
364
+ audience: "default-audience",
365
+ });
366
+
367
+ const newToken = await t.run(async (ctx) => {
368
+ const tokens = await ctx.db.query("oauthTokens").collect();
369
+ return tokens.find((token) => token.clientId === client.clientId);
370
+ });
371
+
372
+ expect(newToken?.resource).toBeUndefined();
373
+ expect(newToken?.audience).toBe("default-audience");
374
+ });
375
+
376
+ test("registerClient rejects client type and token_endpoint_auth_method contradictions", async () => {
377
+ const t = convexTest(schema, modules);
378
+
379
+ await expect(
380
+ t.mutation(api.clientManagement.registerClient, {
381
+ name: "Contradictory Public Client",
382
+ type: "public",
383
+ redirectUris: ["https://example.com/callback"],
384
+ scopes: ["openid"],
385
+ tokenEndpointAuthMethod: "client_secret_basic",
386
+ })
387
+ ).rejects.toThrow("invalid_client_metadata");
388
+
389
+ await expect(
390
+ t.mutation(api.clientManagement.registerClient, {
391
+ name: "Contradictory Confidential Client",
392
+ type: "confidential",
393
+ redirectUris: ["https://example.com/callback"],
394
+ scopes: ["openid"],
395
+ tokenEndpointAuthMethod: "none",
396
+ })
397
+ ).rejects.toThrow("invalid_client_metadata");
398
+ });
399
+
400
+ test.each([
401
+ [
402
+ "client_id mismatch",
403
+ {
404
+ clientId: "wrong-client",
405
+ redirectUri: "https://example.com/callback",
406
+ codeVerifier: validCodeVerifier,
407
+ error: "invalid_grant",
408
+ },
409
+ ],
410
+ [
411
+ "redirect_uri mismatch",
412
+ {
413
+ redirectUri: "https://example.com/other",
414
+ codeVerifier: validCodeVerifier,
415
+ error: "redirect_uri_mismatch",
416
+ },
417
+ ],
418
+ [
419
+ "invalid code_verifier",
420
+ {
421
+ redirectUri: "https://example.com/callback",
422
+ codeVerifier: wrongCodeVerifier,
423
+ error: "invalid_code_verifier",
424
+ },
425
+ ],
426
+ ])(
427
+ "used auth code with %s does not revoke tokens or tombstone the code",
428
+ async (_caseName, replayArgs) => {
429
+ const { t, client, code, codeHash } = await issueCodeWithSavedToken();
430
+
431
+ await expect(
432
+ t.mutation(api.mutations.consumeAuthCode, {
433
+ code,
434
+ clientId: "clientId" in replayArgs ? replayArgs.clientId : client.clientId,
435
+ redirectUri: replayArgs.redirectUri,
436
+ codeVerifier: replayArgs.codeVerifier,
437
+ })
438
+ ).rejects.toThrow(replayArgs.error);
439
+
440
+ await expect(
441
+ getReplayState(t, codeHash)
442
+ ).resolves.toEqual({
443
+ replayDetectedAt: undefined,
444
+ tokenCount: 1,
445
+ });
446
+ }
447
+ );
448
+ });