@codefox-inc/oauth-provider 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +28 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +5 -1
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/clientManagement.d.ts.map +1 -1
  6. package/dist/component/clientManagement.js +9 -0
  7. package/dist/component/clientManagement.js.map +1 -1
  8. package/dist/component/handlers.d.ts +19 -1
  9. package/dist/component/handlers.d.ts.map +1 -1
  10. package/dist/component/handlers.js +76 -15
  11. package/dist/component/handlers.js.map +1 -1
  12. package/dist/component/mutations.d.ts +3 -1
  13. package/dist/component/mutations.d.ts.map +1 -1
  14. package/dist/component/mutations.js +113 -19
  15. package/dist/component/mutations.js.map +1 -1
  16. package/dist/component/queries.d.ts +7 -1
  17. package/dist/component/queries.d.ts.map +1 -1
  18. package/dist/component/queries.js +7 -1
  19. package/dist/component/queries.js.map +1 -1
  20. package/dist/component/schema.d.ts +7 -1
  21. package/dist/component/schema.d.ts.map +1 -1
  22. package/dist/component/schema.js +3 -0
  23. package/dist/component/schema.js.map +1 -1
  24. package/dist/lib/oauth.d.ts.map +1 -1
  25. package/dist/lib/oauth.js +26 -8
  26. package/dist/lib/oauth.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/client/__tests__/oauth-provider.test.ts +15 -0
  29. package/src/client/index.ts +6 -1
  30. package/src/component/__tests__/bugs.test.ts +1001 -0
  31. package/src/component/__tests__/handlers-protocol.test.ts +148 -0
  32. package/src/component/__tests__/oauth.test.ts +18 -15
  33. package/src/component/__tests__/rfc-compliance.test.ts +233 -0
  34. package/src/component/clientManagement.ts +11 -0
  35. package/src/component/handlers.ts +116 -18
  36. package/src/component/mutations.ts +159 -17
  37. package/src/component/queries.ts +6 -1
  38. package/src/component/schema.ts +3 -0
  39. package/src/lib/__tests__/oauth-jwt.test.ts +1 -1
  40. package/src/lib/oauth.ts +28 -8
@@ -0,0 +1,1001 @@
1
+ /**
2
+ * Bug-hunting tests (RED tests demonstrating each critical bug).
3
+ *
4
+ * Each test in this file documents an outstanding bug.
5
+ * They are expected to FAIL until the bug is fixed.
6
+ */
7
+
8
+ import { describe, expect, test, vi } from "vitest";
9
+ import { convexTest } from "convex-test";
10
+ import { isLoopbackRedirectUri, matchRedirectUri } from "../mutations";
11
+ import schema from "../schema";
12
+ import { api, internal } from "../_generated/api";
13
+ import { generateClientSecret, getJWKS, getIssuerUrl, getSigningKeyId } from "../../lib/oauth";
14
+ import { hashToken } from "../token_security";
15
+ import { decodeJwt, exportJWK, exportPKCS8, generateKeyPair } from "jose";
16
+ import { tokenHandler, userInfoHandler, registerHandler, authorizeHandler, openIdConfigurationHandler, type OAuthComponentAPI } from "../handlers";
17
+ import type { OAuthConfig } from "../../lib/oauth";
18
+
19
+ const modules = import.meta.glob("../**/*.ts");
20
+
21
+ const validCodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
22
+
23
+ describe("Bug 1: isLoopbackRedirectUri ignores RFC-compliant IPv6 loopback URIs with brackets", () => {
24
+ // `URL.hostname` returns "[::1]" for IPv6 loopback URIs (per WHATWG URL spec).
25
+ // But mutations.ts compares against the bracketless "::1", so loopback variable-port
26
+ // exception (RFC 8252 §7.3) is never granted for IPv6 native apps.
27
+ test("http://[::1]/cb must be recognized as a loopback redirect URI", () => {
28
+ expect(isLoopbackRedirectUri("http://[::1]/cb")).toBe(true);
29
+ });
30
+
31
+ test("matchRedirectUri must permit variable IPv6 loopback ports per RFC 8252", () => {
32
+ // Registered with no port, request with explicit port — should match.
33
+ expect(matchRedirectUri("http://[::1]:43210/cb", ["http://[::1]/cb"])).toBe(
34
+ true,
35
+ );
36
+ });
37
+ });
38
+
39
+ describe("Bug 3: getJWKS does not surface use/alg on keys that omit them", () => {
40
+ // JWKS responses MUST advertise the key usage and algorithm so resource servers can
41
+ // pick the right verification primitive. The deprecated `getPublicJWK` adds them
42
+ // explicitly; the new `getJWKS` keeps whatever the JWKS already contains and never
43
+ // adds defaults, breaking interoperability with the typical RSA signing setup.
44
+ test("getJWKS must default use='sig' and alg='RS256' for an RSA key without those hints", async () => {
45
+ const jwks = await getJWKS({
46
+ jwks: JSON.stringify({
47
+ keys: [{ kty: "RSA", n: "abc", e: "AQAB", kid: "k1" }],
48
+ }),
49
+ privateKey: "",
50
+ siteUrl: "https://example.com",
51
+ });
52
+ expect(jwks.keys[0]).toMatchObject({ use: "sig", alg: "RS256" });
53
+ });
54
+ });
55
+
56
+ describe("Bug 4: verifyPkce accepts the OAuth-2.1-prohibited 'plain' code_challenge_method", () => {
57
+ // OAuth 2.1 §4.1.1 makes S256 mandatory ("plain" MUST NOT be supported).
58
+ // `issueAuthorizationCode` rejects "plain", but the actual PKCE verifier
59
+ // (`verifyPkce` inside `mutations.ts`) still has a "plain" branch that
60
+ // succeeds when verifier === challenge. That branch must be unreachable —
61
+ // otherwise any legacy / smuggled "plain" code can still mint a token.
62
+ test("consumeAuthCode must reject a stored authorization code that carries codeChallengeMethod='plain'", async () => {
63
+ const t = convexTest(schema, modules);
64
+
65
+ const client = await t.mutation(api.clientManagement.registerClient, {
66
+ name: "Plain-PKCE Client",
67
+ type: "public",
68
+ redirectUris: ["https://cb"],
69
+ scopes: ["openid"],
70
+ });
71
+
72
+ // Insert an authorization code directly into the DB with the prohibited
73
+ // "plain" method to simulate either legacy data or a bypass of the
74
+ // issuance-time guard.
75
+ const codeVerifier = "ABCDEFGHIJKLMNOPQRSTUVWXYZ_-.~0123456789abcd"; // 44 chars, ABNF-valid
76
+ const plaintextCode = "plain-pkce-code-1234567890";
77
+ const codeHash = await hashToken(plaintextCode);
78
+ await t.run(async (ctx) => {
79
+ await ctx.db.insert("oauthCodes", {
80
+ code: codeHash,
81
+ clientId: client.clientId,
82
+ userId: "user123",
83
+ scopes: ["openid"],
84
+ redirectUri: "https://cb",
85
+ codeChallenge: codeVerifier, // plain method: challenge == verifier
86
+ codeChallengeMethod: "plain",
87
+ expiresAt: Date.now() + 60_000,
88
+ });
89
+ });
90
+
91
+ await expect(
92
+ t.mutation(api.mutations.consumeAuthCode, {
93
+ code: plaintextCode,
94
+ clientId: client.clientId,
95
+ codeVerifier,
96
+ redirectUri: "https://cb",
97
+ }),
98
+ ).rejects.toThrow(/plain|unsupported|S256/i);
99
+ });
100
+ });
101
+
102
+ async function makeJwtConfig(extra: Partial<OAuthConfig> = {}): Promise<OAuthConfig> {
103
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
104
+ const privateKeyPem = await exportPKCS8(privateKey);
105
+ const jwk = await exportJWK(publicKey);
106
+ return {
107
+ privateKey: privateKeyPem,
108
+ jwks: JSON.stringify({ keys: [{ ...jwk, kid: "test-key", alg: "RS256", use: "sig" }] }),
109
+ siteUrl: "https://example.com",
110
+ allowDynamicClientRegistration: true,
111
+ ...extra,
112
+ };
113
+ }
114
+
115
+ function makeApi(overrides: Partial<OAuthComponentAPI> = {}): OAuthComponentAPI {
116
+ return {
117
+ queries: {
118
+ getClient: async (_ctx: any, { clientId }: { clientId: string }) => ({
119
+ clientId,
120
+ type: "confidential" as const,
121
+ redirectUris: ["https://cb"],
122
+ allowedScopes: ["openid", "profile", "offline_access"],
123
+ }),
124
+ getRefreshToken: async () => null,
125
+ getTokensByUser: async () => [],
126
+ ...overrides.queries,
127
+ } as any,
128
+ mutations: {
129
+ issueAuthorizationCode: async () => "",
130
+ consumeAuthCode: async () => {
131
+ throw new Error("invalid_grant");
132
+ },
133
+ saveTokens: async () => undefined,
134
+ rotateRefreshToken: async () => undefined,
135
+ upsertAuthorization: async () => "",
136
+ updateAuthorizationLastUsed: async () => undefined,
137
+ ...overrides.mutations,
138
+ } as any,
139
+ clientManagement: {
140
+ registerClient: async () => ({
141
+ clientId: "client",
142
+ clientSecret: "secret",
143
+ clientIdIssuedAt: 0,
144
+ }),
145
+ verifyClientSecret: async () => true,
146
+ ...overrides.clientManagement,
147
+ } as any,
148
+ };
149
+ }
150
+
151
+ describe("Bug 5: refresh-token-issued ID token omits auth_time even when one was set in the original authentication", () => {
152
+ // OIDC Core §12.2: "if there is an auth_time Claim in the original ID Token,
153
+ // it MUST be present in the new ID Token". This implementation does not preserve
154
+ // auth_time across refresh.
155
+ test("ID token issued via refresh_token must carry the original auth_time", async () => {
156
+ const jwtConfig = await makeJwtConfig();
157
+
158
+ const response = await tokenHandler(
159
+ {} as any,
160
+ new Request("https://example.com/oauth/token", {
161
+ method: "POST",
162
+ body: new URLSearchParams({
163
+ grant_type: "refresh_token",
164
+ client_id: "client",
165
+ refresh_token: "rt",
166
+ client_secret: "secret",
167
+ }),
168
+ }),
169
+ jwtConfig,
170
+ makeApi({
171
+ queries: {
172
+ getRefreshToken: async () => ({
173
+ clientId: "client",
174
+ userId: "user",
175
+ scopes: ["openid", "offline_access"],
176
+ refreshTokenExpiresAt: Date.now() + 3600 * 1000,
177
+ // Note: schema currently does not even surface authTime here.
178
+ // The bug is the loss; not having a field to read it from is part of the bug.
179
+ authTime: 1_700_000_000,
180
+ }) as any,
181
+ } as any,
182
+ }),
183
+ );
184
+
185
+ expect(response.status).toBe(200);
186
+ const body = await response.json();
187
+ expect(body.id_token).toBeDefined();
188
+ const claims = decodeJwt(body.id_token);
189
+ expect(claims.auth_time).toBe(1_700_000_000);
190
+ });
191
+ });
192
+
193
+ describe("Bug 6: replay detection misses descendants because rotateRefreshToken loses the authorizationCode link", () => {
194
+ // OAuth 2.1 (draft-ietf-oauth-v2-1) §4.1.4: when authorization code replay is detected,
195
+ // "the authorization server SHOULD revoke all tokens (access tokens, refresh tokens, ...
196
+ // and other credentials) that were issued based on that authorization".
197
+ // The current implementation only revokes the *first* token row, because
198
+ // `rotateRefreshToken` does not propagate `authorizationCode`. After one
199
+ // refresh, the chain is invisible to the replay-revocation query.
200
+ test("rotateRefreshToken must propagate the originating authorizationCode", async () => {
201
+ const t = convexTest(schema, modules);
202
+
203
+ const client = await t.mutation(api.clientManagement.registerClient, {
204
+ name: "Replay-chain Client",
205
+ type: "confidential",
206
+ redirectUris: ["https://cb"],
207
+ scopes: ["openid", "offline_access"],
208
+ });
209
+
210
+ const codeHash = "a".repeat(64); // pretend hash, only used as the link key
211
+ await t.mutation(api.mutations.saveTokens, {
212
+ accessToken: "initial-access",
213
+ refreshToken: "initial-refresh",
214
+ clientId: client.clientId,
215
+ userId: "user123",
216
+ scopes: ["openid", "offline_access"],
217
+ expiresAt: Date.now() + 3_600_000,
218
+ refreshTokenExpiresAt: Date.now() + 30 * 24 * 3_600_000,
219
+ authorizationCode: codeHash,
220
+ });
221
+
222
+ await t.mutation(api.mutations.rotateRefreshToken, {
223
+ oldRefreshToken: "initial-refresh",
224
+ accessToken: "rotated-access",
225
+ refreshToken: "rotated-refresh",
226
+ clientId: client.clientId,
227
+ userId: "user123",
228
+ scopes: ["openid", "offline_access"],
229
+ expiresAt: Date.now() + 3_600_000,
230
+ refreshTokenExpiresAt: Date.now() + 30 * 24 * 3_600_000,
231
+ });
232
+
233
+ const rotated = await t.run(async (ctx) => {
234
+ return await ctx.db
235
+ .query("oauthTokens")
236
+ .withIndex("by_authorization_code", (q) =>
237
+ q.eq("authorizationCode", codeHash),
238
+ )
239
+ .collect();
240
+ });
241
+
242
+ // After rotation, the descendant token MUST still be reachable from the
243
+ // original authorization-code link so that replay revocation can find it.
244
+ expect(rotated.length).toBeGreaterThanOrEqual(1);
245
+ });
246
+ });
247
+
248
+ describe("Bug 7: token endpoint silently overrides form client_id with the Basic-auth client_id when they disagree", () => {
249
+ // OAuth 2.1 §2.4 and RFC 6749 require unambiguous client identification.
250
+ // If both Basic credentials and a form client_id are supplied, they MUST be
251
+ // identical and the server MUST reject otherwise. The current handler
252
+ // silently chooses the Basic-auth value (`clientId = basicCredentials.clientId`)
253
+ // and discards the form value, so mixed-up callers are not warned and the
254
+ // client-confusion attack surface stays open.
255
+ test("token endpoint must reject conflicting client_id between Basic auth and form body", async () => {
256
+ const getClient = vi.fn(async (_ctx: any, { clientId }: { clientId: string }) => ({
257
+ clientId,
258
+ type: "confidential" as const,
259
+ redirectUris: ["https://cb"],
260
+ allowedScopes: ["openid"],
261
+ tokenEndpointAuthMethod: "client_secret_basic" as const,
262
+ }));
263
+
264
+ const response = await tokenHandler(
265
+ {} as any,
266
+ new Request("https://example.com/oauth/token", {
267
+ method: "POST",
268
+ body: new URLSearchParams({
269
+ grant_type: "authorization_code",
270
+ client_id: "clientB-from-form",
271
+ code: "code",
272
+ redirect_uri: "https://cb",
273
+ code_verifier: "verifier",
274
+ }),
275
+ headers: {
276
+ "Content-Type": "application/x-www-form-urlencoded",
277
+ Authorization: `Basic ${btoa("clientA-from-basic:secret")}`,
278
+ },
279
+ }),
280
+ await makeJwtConfig(),
281
+ makeApi({ queries: { getClient } as any }),
282
+ );
283
+
284
+ expect(response.status).toBe(400);
285
+ const body = await response.json();
286
+ expect(body.error).toBe("invalid_request");
287
+ });
288
+ });
289
+
290
+ describe("Bug 8: bcrypt truncates the 128-char client secret at 72 bytes, so half the secret carries no entropy", () => {
291
+ // `OAUTH_CONSTANTS.CLIENT_SECRET_LENGTH = 64` random bytes are formatted as 128
292
+ // hex characters. bcrypt (including bcryptjs) silently truncates input above 72
293
+ // bytes, so the last 56 hex chars of every issued secret are ignored by
294
+ // verification. Two secrets that match in the first 72 chars are
295
+ // indistinguishable — i.e. the system claims 64 random bytes of entropy but only
296
+ // protects 36.
297
+ test("generated client secrets must fit within bcrypt's 72-byte input limit", () => {
298
+ expect(generateClientSecret(64)).toHaveLength(64);
299
+ });
300
+
301
+ test("verifyClientSecret must keep accepting existing long secrets for patch-release compatibility", async () => {
302
+ const t = convexTest(schema, modules);
303
+ const bcrypt = await import("bcryptjs");
304
+
305
+ const real = "a".repeat(128);
306
+ await t.run(async (ctx) => {
307
+ await ctx.db.insert("oauthClients", {
308
+ name: "Legacy Long Secret Client",
309
+ clientId: "legacy-long-secret-client",
310
+ clientSecret: bcrypt.hashSync(real, 4),
311
+ type: "confidential",
312
+ redirectUris: ["https://cb"],
313
+ allowedScopes: ["openid"],
314
+ createdAt: Date.now(),
315
+ tokenEndpointAuthMethod: "client_secret_basic",
316
+ });
317
+ });
318
+
319
+ await expect(
320
+ t.mutation(api.clientManagement.verifyClientSecret, {
321
+ clientId: "legacy-long-secret-client",
322
+ clientSecret: real,
323
+ }),
324
+ ).resolves.toBe(true);
325
+ });
326
+
327
+ test("verifyClientSecret must reject a tampered generated secret", async () => {
328
+ const t = convexTest(schema, modules);
329
+
330
+ const result = await t.mutation(api.clientManagement.registerClient, {
331
+ name: "Truncation Client",
332
+ type: "confidential",
333
+ redirectUris: ["https://cb"],
334
+ scopes: ["openid"],
335
+ });
336
+ const real = result.clientSecret as string;
337
+ const tampered = real.slice(0, -1) + (real.charAt(real.length - 1) === "0" ? "1" : "0");
338
+
339
+ const realOk = await t.mutation(api.clientManagement.verifyClientSecret, {
340
+ clientId: result.clientId,
341
+ clientSecret: real,
342
+ });
343
+ expect(realOk).toBe(true);
344
+
345
+ const tamperedOk = await t.mutation(api.clientManagement.verifyClientSecret, {
346
+ clientId: result.clientId,
347
+ clientSecret: tampered,
348
+ });
349
+ expect(tamperedOk).toBe(false);
350
+ });
351
+ });
352
+
353
+ describe("Bug 9: userInfoHandler treats the Bearer auth scheme as case-sensitive", () => {
354
+ // RFC 7235 §2.1 (and RFC 6750 §2.1) make HTTP auth-scheme names case-insensitive.
355
+ // `authHeader.startsWith("Bearer ")` rejects valid 'bearer ' / 'BEARER ' values
356
+ // outright, surfacing the missing-credentials challenge instead of attempting JWT
357
+ // validation.
358
+ test("userInfoHandler must accept 'bearer' / 'BEARER' as Bearer-scheme credentials", async () => {
359
+ const jwtConfig = await makeJwtConfig();
360
+
361
+ // Mint a valid access token via the token endpoint so we have a real JWT.
362
+ const tokenResponse = await tokenHandler(
363
+ {} as any,
364
+ new Request("https://example.com/oauth/token", {
365
+ method: "POST",
366
+ body: new URLSearchParams({
367
+ grant_type: "authorization_code",
368
+ client_id: "client",
369
+ code: "code",
370
+ redirect_uri: "https://cb",
371
+ code_verifier: "verifier",
372
+ client_secret: "secret",
373
+ }),
374
+ }),
375
+ jwtConfig,
376
+ makeApi({
377
+ mutations: {
378
+ consumeAuthCode: async () => ({
379
+ userId: "user",
380
+ scopes: ["openid"],
381
+ codeChallenge: "challenge",
382
+ codeChallengeMethod: "S256",
383
+ redirectUri: "https://cb",
384
+ codeHash: "hash",
385
+ }),
386
+ } as any,
387
+ }),
388
+ );
389
+ const { access_token } = await tokenResponse.json();
390
+
391
+ for (const scheme of ["bearer", "BEARER", "Bearer"]) {
392
+ const response = await userInfoHandler(
393
+ {} as any,
394
+ new Request("https://example.com/oauth/userinfo", {
395
+ headers: { Authorization: `${scheme} ${access_token}` },
396
+ }),
397
+ jwtConfig,
398
+ async () => ({ sub: "user" }),
399
+ );
400
+ expect(response.status, `scheme=${scheme}`).toBe(200);
401
+ }
402
+ });
403
+ });
404
+
405
+ describe("Bug 10: revokeAuthorization leaves pending authorization codes intact, so users can still mint tokens after revoking", () => {
406
+ // A user (or admin) that revokes an authorization expects the action to invalidate
407
+ // every credential issued under it. `revokeAuthorization` only clears the
408
+ // authorization record and existing tokens — it does not delete the (up-to-10-min)
409
+ // window of unconsumed auth codes. A racing client can still trade an in-flight
410
+ // code for new tokens after the user has clicked "Disconnect".
411
+ test("an authorization code that pre-existed the revoke call must be unusable afterwards", async () => {
412
+ const t = convexTest(schema, modules);
413
+
414
+ const client = await t.mutation(api.clientManagement.registerClient, {
415
+ name: "Revoke Race Client",
416
+ type: "public",
417
+ redirectUris: ["https://cb"],
418
+ scopes: ["openid"],
419
+ });
420
+
421
+ // Pretend the user has been through consent once (authorization record exists).
422
+ await t.mutation(api.mutations.upsertAuthorization, {
423
+ userId: "user123",
424
+ clientId: client.clientId,
425
+ scopes: ["openid"],
426
+ });
427
+
428
+ // A second /authorize click issues a fresh code that has not yet been exchanged.
429
+ const pendingCode = await t.mutation(api.mutations.issueAuthorizationCode, {
430
+ userId: "user123",
431
+ clientId: client.clientId,
432
+ scopes: ["openid"],
433
+ redirectUri: "https://cb",
434
+ codeChallenge: validCodeChallenge,
435
+ codeChallengeMethod: "S256",
436
+ });
437
+
438
+ // The user now revokes the authorization from the management UI.
439
+ await t.mutation(api.mutations.revokeAuthorization, {
440
+ userId: "user123",
441
+ clientId: client.clientId,
442
+ });
443
+
444
+ // Despite the explicit revoke, the still-pending auth code is happily consumable.
445
+ await expect(
446
+ t.mutation(api.mutations.consumeAuthCode, {
447
+ code: pendingCode,
448
+ clientId: client.clientId,
449
+ redirectUri: "https://cb",
450
+ codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
451
+ }),
452
+ ).rejects.toThrow();
453
+ });
454
+ });
455
+
456
+ describe("Bug 11: cleanupExpired deletes used auth codes prematurely, defeating replay revocation", () => {
457
+ // Replay revocation (oauthCodes.replayDetectedAt + the by_authorization_code link)
458
+ // is only effective while the originating auth code row still exists. Codes expire
459
+ // at `Date.now() + 10min` (CODE_EXPIRY_MS) regardless of `usedAt`, so as soon as
460
+ // 10 minutes pass after issuance the code is dropped — yet the tokens minted from
461
+ // it still live for up to 1 hour (access) / 30 days (refresh). After cleanup, any
462
+ // replay attempt simply 404s on the code lookup, and `consumeAuthCode` never
463
+ // reaches the token-revocation branch.
464
+ test("a used auth code must survive `cleanupExpired` as long as tokens it issued may still be live", async () => {
465
+ const t = convexTest(schema, modules);
466
+
467
+ await t.run(async (ctx) => {
468
+ await ctx.db.insert("oauthCodes", {
469
+ code: "a".repeat(64),
470
+ clientId: "c1",
471
+ userId: "u1",
472
+ scopes: ["openid"],
473
+ redirectUri: "https://cb",
474
+ codeChallenge: validCodeChallenge,
475
+ codeChallengeMethod: "S256",
476
+ expiresAt: Date.now() - 1_000, // expired 1s ago (auth-code TTL is 10min)
477
+ usedAt: Date.now() - 2_000, // was used 2s ago — replay tombstone
478
+ });
479
+ });
480
+
481
+ await t.mutation(internal.mutations.cleanupExpired, {});
482
+
483
+ const remaining = await t.run(async (ctx) => {
484
+ return await ctx.db
485
+ .query("oauthCodes")
486
+ .withIndex("by_code", (q) => q.eq("code", "a".repeat(64)))
487
+ .unique();
488
+ });
489
+
490
+ // The replay-tombstone row must outlive the 10-min auth-code TTL so that
491
+ // subsequent replays can still trigger revocation. Today it does not.
492
+ expect(remaining).not.toBeNull();
493
+ });
494
+ });
495
+
496
+ describe("Bug 12: deleteClient leaves oauthAuthorizations rows orphaned", () => {
497
+ // `clientManagement.deleteClient` already wipes tokens and codes for the deleted
498
+ // client, but it never touches `oauthAuthorizations`. Every user that ever
499
+ // consented to the client is left with a dangling consent row pointing at a
500
+ // non-existent client. UI helpers such as `listUserAuthorizations` then surface
501
+ // "Unknown App" rows and continue to behave as if the user is still authorized.
502
+ test("deleting a client must also remove its authorization records", async () => {
503
+ const t = convexTest(schema, modules);
504
+
505
+ const client = await t.mutation(api.clientManagement.registerClient, {
506
+ name: "Soon-to-be-deleted Client",
507
+ type: "public",
508
+ redirectUris: ["https://cb"],
509
+ scopes: ["openid"],
510
+ });
511
+
512
+ await t.mutation(api.mutations.upsertAuthorization, {
513
+ userId: "user123",
514
+ clientId: client.clientId,
515
+ scopes: ["openid"],
516
+ });
517
+
518
+ await t.mutation(api.clientManagement.deleteClient, {
519
+ clientId: client.clientId,
520
+ });
521
+
522
+ const remaining = await t.run(async (ctx) => {
523
+ return await ctx.db
524
+ .query("oauthAuthorizations")
525
+ .withIndex("by_user_client", (q) =>
526
+ q.eq("userId", "user123").eq("clientId", client.clientId),
527
+ )
528
+ .unique();
529
+ });
530
+
531
+ // Currently the row is still there, pointing at a deleted client.
532
+ expect(remaining).toBeNull();
533
+ });
534
+ });
535
+
536
+ describe("Bug 13: api.mutations.deleteClient leaves tokens and codes orphaned (separate from clientManagement.deleteClient)", () => {
537
+ // The component exports **two** `deleteClient` mutations:
538
+ // * `api.clientManagement.deleteClient` (the documented path) — at least removes
539
+ // tokens and codes (still leaves authorizations — see Bug 12).
540
+ // * `api.mutations.deleteClient` — only removes the client row.
541
+ // Any host that wires up the latter (or copies it via codegen) is left with
542
+ // every issued access token and refresh token still verifying against a JWKS
543
+ // that no longer represents an active client; the JWT-side revocation hook
544
+ // (`checkAuthorization`) won't fire either because the consent record is
545
+ // intact. Two functions with the same name and dramatically different
546
+ // safety levels is the bug.
547
+ test("deleting a client must invalidate the credentials it issued, regardless of which deleteClient is called", async () => {
548
+ const t = convexTest(schema, modules);
549
+
550
+ const client = await t.mutation(api.clientManagement.registerClient, {
551
+ name: "Two-API Client",
552
+ type: "public",
553
+ redirectUris: ["https://cb"],
554
+ scopes: ["openid"],
555
+ });
556
+
557
+ await t.mutation(api.mutations.saveTokens, {
558
+ accessToken: "at",
559
+ refreshToken: "rt",
560
+ clientId: client.clientId,
561
+ userId: "user123",
562
+ scopes: ["openid"],
563
+ expiresAt: Date.now() + 3_600_000,
564
+ refreshTokenExpiresAt: Date.now() + 30 * 86_400_000,
565
+ });
566
+
567
+ await t.mutation(api.mutations.deleteClient, { clientId: client.clientId });
568
+
569
+ const leftover = await t.run(async (ctx) => {
570
+ return await ctx.db
571
+ .query("oauthTokens")
572
+ .withIndex("by_user", (q) => q.eq("userId", "user123"))
573
+ .collect();
574
+ });
575
+
576
+ // Currently `api.mutations.deleteClient` does NOT delete tokens.
577
+ expect(leftover.filter((token) => token.clientId === client.clientId)).toHaveLength(0);
578
+ });
579
+ });
580
+
581
+ describe("Bug 14: getIssuerUrl produces a double slash when convexSiteUrl ends with '/'", () => {
582
+ // `normalizePrefix` strips the prefix's trailing slash but doesn't trim the
583
+ // *issuer base URL* itself. A perfectly common config value like
584
+ // `CONVEX_SITE_URL=https://example.com/` therefore yields the issuer
585
+ // `https://example.com//oauth`. That string is what gets baked into the
586
+ // `iss` claim of every JWT and into the OIDC discovery document — and
587
+ // strict verifiers (PyJWT strict, Auth0 SDK, etc.) will reject it as not
588
+ // matching the canonical URL.
589
+ test("issuer URL must not contain consecutive slashes", () => {
590
+ const url = getIssuerUrl({
591
+ convexSiteUrl: "https://example.com/",
592
+ siteUrl: "https://example.com/",
593
+ privateKey: "",
594
+ jwks: "{}",
595
+ prefix: "/oauth",
596
+ });
597
+ expect(url).not.toMatch(/(^https?:\/\/[^/]+)\/\//);
598
+ expect(url).toBe("https://example.com/oauth");
599
+ });
600
+ });
601
+
602
+ describe("Bug 15: tokenHandler leaks internal error messages into the OAuth error_description body", () => {
603
+ // The token endpoint catches everything, maps a known error-message prefix list
604
+ // to OAuth error codes, and otherwise falls through to `new OAuthError(
605
+ // "invalid_request", message)` where `message` is the raw `Error.message`.
606
+ // OAuth 2.1 §10.4 / OWASP "don't leak internals" — but here any internal
607
+ // failure inside a query/mutation surfaces directly to the requesting client
608
+ // (and any logs the client forwards).
609
+ test("an unhandled internal error must not echo its message back to the client", async () => {
610
+ const getClient = vi.fn(async () => {
611
+ // Simulate an unexpected internal failure (e.g. DB connectivity issue).
612
+ throw new Error("Internal DB stack trace: /Users/fshindo/secret/path/file.ts:42");
613
+ });
614
+
615
+ const response = await tokenHandler(
616
+ {} as any,
617
+ new Request("https://example.com/oauth/token", {
618
+ method: "POST",
619
+ body: new URLSearchParams({
620
+ grant_type: "authorization_code",
621
+ client_id: "client",
622
+ code: "code",
623
+ redirect_uri: "https://cb",
624
+ code_verifier: "verifier",
625
+ client_secret: "secret",
626
+ }),
627
+ }),
628
+ await makeJwtConfig(),
629
+ makeApi({ queries: { getClient } as any }),
630
+ );
631
+
632
+ const body = await response.json();
633
+ // The error_description must not contain the raw internal error string.
634
+ expect(body.error_description ?? "").not.toContain("Internal DB stack trace");
635
+ expect(body.error_description ?? "").not.toMatch(/\/Users\//);
636
+ });
637
+ });
638
+
639
+ describe("Bug 16: registerHandler (DCR) also echoes raw internal error messages to anonymous callers", () => {
640
+ // Same shape as Bug 15, but the DCR endpoint is open to the **entire internet**
641
+ // when `allowDynamicClientRegistration` is on. Any anonymous client can
642
+ // submit a request that triggers an unhandled exception inside the
643
+ // component and receive the internal error text directly.
644
+ test("DCR must not echo internal error details to anonymous callers", async () => {
645
+ const registerClient = vi.fn(async () => {
646
+ throw new Error("Internal: secret SQL params /tmp/run/12345");
647
+ });
648
+
649
+ const response = await registerHandler(
650
+ {} as any,
651
+ new Request("https://example.com/oauth/register", {
652
+ method: "POST",
653
+ body: JSON.stringify({
654
+ redirect_uris: ["https://client.example.com/cb"],
655
+ }),
656
+ headers: { "Content-Type": "application/json" },
657
+ }),
658
+ await makeJwtConfig({ allowDynamicClientRegistration: true }),
659
+ makeApi({ clientManagement: { registerClient } as any }),
660
+ );
661
+
662
+ const body = await response.json();
663
+ expect(body.error_description ?? "").not.toContain("Internal:");
664
+ expect(body.error_description ?? "").not.toMatch(/\/tmp\//);
665
+ });
666
+ });
667
+
668
+ describe("Bug 17: userInfoHandler success response omits Cache-Control: no-store", () => {
669
+ // OAuth 2.0 §5.1 (and RFC 7235 best practice) require that responses carrying
670
+ // bearer-token-protected user info include `Cache-Control: no-store`.
671
+ // `userInfoHandler` returns the user profile with only the CORS headers; no
672
+ // Cache-Control is ever set. Intermediate caches and well-meaning browser
673
+ // back/forward caches can therefore retain the user's identity payload.
674
+ test("userinfo 200 response must carry Cache-Control: no-store", async () => {
675
+ const jwtConfig = await makeJwtConfig();
676
+
677
+ // Mint a real access token for the test.
678
+ const tokenResponse = await tokenHandler(
679
+ {} as any,
680
+ new Request("https://example.com/oauth/token", {
681
+ method: "POST",
682
+ body: new URLSearchParams({
683
+ grant_type: "authorization_code",
684
+ client_id: "client",
685
+ code: "code",
686
+ redirect_uri: "https://cb",
687
+ code_verifier: "verifier",
688
+ client_secret: "secret",
689
+ }),
690
+ }),
691
+ jwtConfig,
692
+ makeApi({
693
+ mutations: {
694
+ consumeAuthCode: async () => ({
695
+ userId: "user",
696
+ scopes: ["openid", "profile"],
697
+ codeChallenge: "challenge",
698
+ codeChallengeMethod: "S256",
699
+ redirectUri: "https://cb",
700
+ codeHash: "hash",
701
+ }),
702
+ } as any,
703
+ }),
704
+ );
705
+ const { access_token } = await tokenResponse.json();
706
+
707
+ const response = await userInfoHandler(
708
+ {} as any,
709
+ new Request("https://example.com/oauth/userinfo", {
710
+ headers: { Authorization: `Bearer ${access_token}` },
711
+ }),
712
+ jwtConfig,
713
+ async () => ({ sub: "user", name: "Real User" }),
714
+ );
715
+
716
+ expect(response.status).toBe(200);
717
+ expect(response.headers.get("Cache-Control") ?? "").toContain("no-store");
718
+ });
719
+ });
720
+
721
+ describe("Bug 18: upsertAuthorization merges scopes monotonically — narrowed consent is silently widened back", () => {
722
+ // The "skip consent" path checks the stored authorization scope list via
723
+ // `OAuthProvider.hasAuthorization`. Because `upsertAuthorization` unions the
724
+ // new scopes into whatever the user previously granted, a user that narrows
725
+ // their consent (e.g. removes `email`) continues to look fully consented to
726
+ // every scope they EVER consented to. The "Skip consent" gate then accepts
727
+ // calls for scopes the user has explicitly walked back.
728
+ test("a follow-up consent narrowing must shrink the stored scopes, not union with the prior set", async () => {
729
+ const t = convexTest(schema, modules);
730
+
731
+ await t.mutation(api.mutations.upsertAuthorization, {
732
+ userId: "user1",
733
+ clientId: "client1",
734
+ scopes: ["openid", "profile", "email"],
735
+ });
736
+
737
+ // Same user re-consents to a narrower scope set.
738
+ await t.mutation(api.mutations.upsertAuthorization, {
739
+ userId: "user1",
740
+ clientId: "client1",
741
+ scopes: ["openid"],
742
+ });
743
+
744
+ const auth = await t.query(api.queries.getAuthorization, {
745
+ userId: "user1",
746
+ clientId: "client1",
747
+ });
748
+
749
+ // Stored scopes must reflect the most recent grant, otherwise the
750
+ // "user consented to email" claim continues to be true forever.
751
+ expect(auth?.scopes.sort()).toEqual(["openid"]);
752
+ });
753
+ });
754
+
755
+ describe("Bug 19: cleanupExpired deletes whole oauthTokens rows by access-token expiry, taking the still-valid refresh token with them", () => {
756
+ // `oauthTokens` rows carry both an access-token hash and a refresh-token hash.
757
+ // Access tokens live 1h; refresh tokens live 30 days. `cleanupExpired` deletes
758
+ // any row whose `expiresAt` (= access-token expiry) is in the past — without
759
+ // looking at `refreshTokenExpiresAt`. As soon as the 1h access window closes
760
+ // (and cleanup runs), the still-valid refresh token is wiped along with it.
761
+ // The next refresh request returns `invalid_grant`, forcing the user back
762
+ // through the full authorization flow once every 1h instead of every 30 days.
763
+ test("cleanupExpired must preserve rows whose refresh token is still within its own expiry", async () => {
764
+ const t = convexTest(schema, modules);
765
+
766
+ await t.run(async (ctx) => {
767
+ await ctx.db.insert("oauthTokens", {
768
+ accessToken: "x".repeat(64),
769
+ refreshToken: "y".repeat(64),
770
+ clientId: "client",
771
+ userId: "user",
772
+ scopes: ["openid", "offline_access"],
773
+ expiresAt: Date.now() - 1_000, // access token expired
774
+ refreshTokenExpiresAt: Date.now() + 30 * 86_400_000, // refresh still valid
775
+ });
776
+ });
777
+
778
+ await t.mutation(internal.mutations.cleanupExpired, {});
779
+
780
+ const remaining = await t.run(async (ctx) => {
781
+ return await ctx.db.query("oauthTokens").collect();
782
+ });
783
+
784
+ expect(remaining.length).toBeGreaterThanOrEqual(1);
785
+ });
786
+ });
787
+
788
+ describe("Bug 20: tokenHandler returns unsupported_grant_type for a missing grant_type (should be invalid_request)", () => {
789
+ // RFC 6749 §5.2 distinguishes the two:
790
+ // * invalid_request — "missing a required parameter"
791
+ // * unsupported_grant_type — "grant type ... is not supported"
792
+ // Today the handler falls through to `unsupported_grant_type` whether the
793
+ // client OMITS grant_type entirely or supplies an unsupported value. The
794
+ // former should surface as `invalid_request` so a misconfigured client can
795
+ // distinguish "you forgot grant_type" from "I do not implement that grant".
796
+ test("missing grant_type must surface as invalid_request", async () => {
797
+ const response = await tokenHandler(
798
+ {} as any,
799
+ new Request("https://example.com/oauth/token", {
800
+ method: "POST",
801
+ body: new URLSearchParams({
802
+ // grant_type intentionally omitted
803
+ client_id: "client",
804
+ client_secret: "secret",
805
+ }),
806
+ }),
807
+ await makeJwtConfig(),
808
+ makeApi(),
809
+ );
810
+
811
+ expect(response.status).toBe(400);
812
+ const body = await response.json();
813
+ expect(body.error).toBe("invalid_request");
814
+ });
815
+ });
816
+
817
+ describe("Bug 21: api.queries.getClient leaks the bcrypt-hashed clientSecret", () => {
818
+ // `listClients` carefully strips `clientSecret` before returning, but the
819
+ // companion `getClient` query returns the raw `oauthClients` row — including
820
+ // the bcrypt hash. Hosts that wire this query up to the frontend (the SDK
821
+ // exposes it directly via `OAuthProvider.getClient`) ship the hash to the
822
+ // browser. The hash is bcrypt(10) so it's not catastrophic, but it leaks
823
+ // information about secret strength and lets a determined attacker mount an
824
+ // offline bcrypt attack while only being able to interact with the consent
825
+ // page.
826
+ test("getClient response must not include the stored clientSecret", async () => {
827
+ const t = convexTest(schema, modules);
828
+
829
+ const result = await t.mutation(api.clientManagement.registerClient, {
830
+ name: "Leaky Client",
831
+ type: "confidential",
832
+ redirectUris: ["https://cb"],
833
+ scopes: ["openid"],
834
+ });
835
+
836
+ const client = await t.query(api.queries.getClient, {
837
+ clientId: result.clientId,
838
+ });
839
+
840
+ expect(client).not.toBeNull();
841
+ // The hashed secret must not be exposed through the read API; `listClients`
842
+ // already strips it for the very same reason.
843
+ expect((client as any)?.clientSecret).toBeUndefined();
844
+ });
845
+ });
846
+
847
+ describe("Bug 22: authorizeHandler does not honor prompt=none semantics (returns access_denied instead of login_required/consent_required)", () => {
848
+ // OIDC Core §3.1.2.1 defines:
849
+ // * login_required — prompt=none AND end-user not authenticated
850
+ // * consent_required — prompt=none AND end-user authenticated but consent missing
851
+ // * interaction_required — generic interaction-required fallback
852
+ // The handler returns the catch-all `access_denied` in both cases instead.
853
+ // Relying-party SDKs that follow the spec (Auth0, NextAuth, openid-client) use
854
+ // these specific error codes to distinguish "show login" from "show consent"
855
+ // from "user refused". With access_denied, they cannot tell the difference.
856
+ test("prompt=none without authenticated user must redirect with error=login_required", async () => {
857
+ const response = await authorizeHandler(
858
+ {} as any,
859
+ new Request(
860
+ "https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&prompt=none&consent=approve",
861
+ {
862
+ method: "GET",
863
+ headers: { Origin: "https://example.com" },
864
+ },
865
+ ),
866
+ {
867
+ ...(await makeJwtConfig()),
868
+ getUserId: async () => null, // not authenticated
869
+ },
870
+ makeApi({
871
+ queries: {
872
+ getClient: async (_ctx: any, { clientId }: { clientId: string }) => ({
873
+ clientId,
874
+ type: "public" as const,
875
+ redirectUris: ["https://cb"],
876
+ allowedScopes: ["openid"],
877
+ tokenEndpointAuthMethod: "none" as const,
878
+ }),
879
+ } as any,
880
+ }),
881
+ );
882
+
883
+ expect(response.status).toBe(302);
884
+ const redirect = new URL(response.headers.get("Location") as string);
885
+ expect(redirect.searchParams.get("error")).toBe("login_required");
886
+ });
887
+ });
888
+
889
+ describe("Bug 23: getSigningKeyId trusts config.keyId without checking the JWKS, producing JWTs that cannot be verified", () => {
890
+ // `getSigningKeyId` returns `config.keyId` verbatim when it is set.
891
+ // `getJWKS` keeps each key's existing `kid` unchanged. If those two
892
+ // sources disagree (config says "xyz", JWKS publishes "abc"), every
893
+ // freshly minted JWT carries `kid: "xyz"`, but the JWKS endpoint only
894
+ // advertises `kid: "abc"`. A standards-conformant verifier looks up by
895
+ // `kid` and rejects the token — the provider becomes a write-only key
896
+ // factory.
897
+ test("getSigningKeyId must refuse a keyId that isn't actually published in the JWKS", () => {
898
+ const config: OAuthConfig = {
899
+ privateKey: "",
900
+ jwks: JSON.stringify({
901
+ keys: [{ kty: "RSA", n: "abc", e: "AQAB", kid: "published-key" }],
902
+ }),
903
+ siteUrl: "https://example.com",
904
+ keyId: "config-says-something-else", // mismatch with JWKS
905
+ };
906
+ expect(() => getSigningKeyId(config)).toThrow(/not present in JWKS/);
907
+ });
908
+ });
909
+
910
+ describe("Bug 24: refresh_token grant leaks token state via resource/expiry errors before validating client ownership", () => {
911
+ // Order of checks inside `tokenHandler`'s refresh-token branch:
912
+ // 1. Token exists? (returns "invalid_grant: Invalid refresh token")
913
+ // 2. Resource binding matches? (returns "invalid_target")
914
+ // 3. Expiry not reached? (returns "invalid_grant: Refresh token expired")
915
+ // 4. clientId matches oldToken? (returns "invalid_grant: Client mismatch")
916
+ //
917
+ // An attacker who *steals* a refresh token but doesn't know the originating
918
+ // client can authenticate as a different (own) client and probe the token's
919
+ // state by varying the `resource` parameter:
920
+ // - "invalid_target" → the token IS bound to a specific resource (≠ what I sent)
921
+ // - "invalid_grant: Client mismatch" → token has no resource binding
922
+ // - "invalid_grant: Refresh token expired" → token is past its expiry window
923
+ // The right ordering is to validate `oldToken.clientId === clientId` FIRST and
924
+ // surface a uniform "invalid_grant" for everything else.
925
+ test("refresh_token grant must surface a uniform invalid_grant when clientId does not match oldToken", async () => {
926
+ const jwtConfig = await makeJwtConfig();
927
+
928
+ const response = await tokenHandler(
929
+ {} as any,
930
+ new Request("https://example.com/oauth/token", {
931
+ method: "POST",
932
+ body: new URLSearchParams({
933
+ grant_type: "refresh_token",
934
+ client_id: "attacker-client",
935
+ refresh_token: "stolen",
936
+ client_secret: "attacker-secret",
937
+ resource: "https://api.attacker.com/anything",
938
+ }),
939
+ }),
940
+ jwtConfig,
941
+ makeApi({
942
+ queries: {
943
+ // Token belongs to a different client, but the attacker authenticated as their own client.
944
+ getRefreshToken: async () => ({
945
+ clientId: "victim-client",
946
+ userId: "victim-user",
947
+ scopes: ["openid", "offline_access"],
948
+ refreshTokenExpiresAt: Date.now() + 3_600_000,
949
+ resource: "https://api.victim.com/private", // bound to a different resource
950
+ }) as any,
951
+ } as any,
952
+ }),
953
+ );
954
+
955
+ const body = await response.json();
956
+ // Today the attacker learns "invalid_target" (so they know the token is resource-bound)
957
+ // BEFORE the clientId mismatch is ever checked.
958
+ expect(body.error).toBe("invalid_grant");
959
+ });
960
+ });
961
+
962
+ describe("Bug 25: OIDC discovery omits request_uri_parameter_supported, leaving its spec-defined default (=true) lying about behavior", () => {
963
+ // OIDC Discovery 1.0 §3 lists the parameter defaults:
964
+ // request_uri_parameter_supported default true
965
+ // request_parameter_supported default false
966
+ // claims_parameter_supported default false
967
+ //
968
+ // Our implementation does NOT process `request_uri` — \`authorizeHandler\` simply
969
+ // ignores any \`request_uri=…\` query parameter. But the discovery response omits
970
+ // `request_uri_parameter_supported`, so spec-conformant clients (OIDC certified
971
+ // SDKs) read the default (true) and believe we honor it. They then send a
972
+ // \`request_uri\` reference that we silently drop, taking them off the documented
973
+ // request-object path with no warning.
974
+ test("discovery response must explicitly publish request_uri_parameter_supported=false (or implement the parameter)", async () => {
975
+ const response = await openIdConfigurationHandler(
976
+ {} as any,
977
+ new Request("https://example.com/.well-known/openid-configuration"),
978
+ await makeJwtConfig(),
979
+ );
980
+
981
+ const body = await response.json();
982
+ expect(body.request_uri_parameter_supported).toBe(false);
983
+ });
984
+ });
985
+
986
+ describe("Bug 2: clientManagement.registerClient rejects IPv6 loopback http://[::1]/cb redirect_uri", () => {
987
+ // The IsValidRedirectUri helper inside clientManagement also misses the bracketed form,
988
+ // so DCR (and direct client registration) cannot persist a redirect URI built from
989
+ // the IPv6 loopback literal — even though RFC 8252 explicitly authorises it.
990
+ test("registerClient must accept http://[::1]:8080/cb as a valid redirect URI", async () => {
991
+ const t = convexTest(schema, modules);
992
+ await expect(
993
+ t.mutation(api.clientManagement.registerClient, {
994
+ name: "IPv6 Native App",
995
+ type: "public",
996
+ redirectUris: ["http://[::1]:8080/cb"],
997
+ scopes: ["openid"],
998
+ }),
999
+ ).resolves.toMatchObject({ clientId: expect.any(String) });
1000
+ });
1001
+ });