@codefox-inc/oauth-provider 0.4.0 → 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.
- package/README.md +28 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/clientManagement.d.ts.map +1 -1
- package/dist/component/clientManagement.js +9 -0
- package/dist/component/clientManagement.js.map +1 -1
- package/dist/component/handlers.d.ts +19 -1
- package/dist/component/handlers.d.ts.map +1 -1
- package/dist/component/handlers.js +79 -16
- package/dist/component/handlers.js.map +1 -1
- package/dist/component/mutations.d.ts +3 -1
- package/dist/component/mutations.d.ts.map +1 -1
- package/dist/component/mutations.js +113 -19
- package/dist/component/mutations.js.map +1 -1
- package/dist/component/queries.d.ts +7 -1
- package/dist/component/queries.d.ts.map +1 -1
- package/dist/component/queries.js +7 -1
- package/dist/component/queries.js.map +1 -1
- package/dist/component/schema.d.ts +7 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +3 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/lib/oauth.d.ts.map +1 -1
- package/dist/lib/oauth.js +26 -8
- package/dist/lib/oauth.js.map +1 -1
- package/package.json +1 -1
- package/src/client/__tests__/oauth-provider.test.ts +15 -0
- package/src/client/index.ts +6 -1
- package/src/component/__tests__/bugs.test.ts +1001 -0
- package/src/component/__tests__/handlers-protocol.test.ts +182 -0
- package/src/component/__tests__/oauth.test.ts +18 -15
- package/src/component/__tests__/rfc-compliance.test.ts +233 -0
- package/src/component/clientManagement.ts +11 -0
- package/src/component/handlers.ts +119 -19
- package/src/component/mutations.ts +159 -17
- package/src/component/queries.ts +6 -1
- package/src/component/schema.ts +3 -0
- package/src/lib/__tests__/oauth-jwt.test.ts +1 -1
- 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
|
+
});
|