@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
|
@@ -109,6 +109,27 @@ describe("OAuth handler protocol checks", () => {
|
|
|
109
109
|
expect(issueAuthorizationCode).not.toHaveBeenCalled();
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
test("authorization endpoint rejects unsupported request object parameters", async () => {
|
|
113
|
+
const issueAuthorizationCode = vi.fn(async () => "code");
|
|
114
|
+
|
|
115
|
+
for (const parameter of ["request", "request_uri"]) {
|
|
116
|
+
const response = await authorizeHandler(
|
|
117
|
+
{} as any,
|
|
118
|
+
new Request(`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&consent=approve&${parameter}=unsupported`, {
|
|
119
|
+
method: "GET",
|
|
120
|
+
headers: { Origin: "https://example.com" },
|
|
121
|
+
}),
|
|
122
|
+
{ ...config, getUserId: async () => "user" },
|
|
123
|
+
makeApi({ mutations: { issueAuthorizationCode } as any })
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(response.status).toBe(302);
|
|
127
|
+
const redirect = new URL(response.headers.get("Location") as string);
|
|
128
|
+
expect(redirect.searchParams.get("error")).toBe("invalid_request");
|
|
129
|
+
}
|
|
130
|
+
expect(issueAuthorizationCode).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
112
133
|
test("authorization endpoint rejects code_challenge values outside PKCE ABNF", async () => {
|
|
113
134
|
const issueAuthorizationCode = vi.fn(async () => "code");
|
|
114
135
|
|
|
@@ -149,6 +170,88 @@ describe("OAuth handler protocol checks", () => {
|
|
|
149
170
|
);
|
|
150
171
|
});
|
|
151
172
|
|
|
173
|
+
test("authorization endpoint allows prompt=none when existing consent covers the request", async () => {
|
|
174
|
+
const issueAuthorizationCode = vi.fn(async () => "silent-code");
|
|
175
|
+
|
|
176
|
+
const response = await authorizeHandler(
|
|
177
|
+
{} as any,
|
|
178
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20profile&prompt=none&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
179
|
+
method: "GET",
|
|
180
|
+
headers: { Origin: "https://example.com" },
|
|
181
|
+
}),
|
|
182
|
+
{ ...config, getUserId: async () => "user" },
|
|
183
|
+
makeApi({
|
|
184
|
+
queries: {
|
|
185
|
+
getAuthorization: async () => ({
|
|
186
|
+
userId: "user",
|
|
187
|
+
clientId: "client",
|
|
188
|
+
scopes: ["openid", "profile", "email"],
|
|
189
|
+
}),
|
|
190
|
+
} as any,
|
|
191
|
+
mutations: { issueAuthorizationCode } as any,
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(response.status).toBe(302);
|
|
196
|
+
const redirect = new URL(response.headers.get("Location") as string);
|
|
197
|
+
expect(redirect.searchParams.get("code")).toBe("silent-code");
|
|
198
|
+
expect(issueAuthorizationCode).toHaveBeenCalledWith(
|
|
199
|
+
expect.anything(),
|
|
200
|
+
expect.objectContaining({ scopes: ["openid", "profile"] })
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("authorization endpoint preserves offline_access for prompt=none when prior consent covers it", async () => {
|
|
205
|
+
const issueAuthorizationCode = vi.fn(async () => "silent-code");
|
|
206
|
+
|
|
207
|
+
const response = await authorizeHandler(
|
|
208
|
+
{} as any,
|
|
209
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20offline_access&prompt=none&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
210
|
+
method: "GET",
|
|
211
|
+
headers: { Origin: "https://example.com" },
|
|
212
|
+
}),
|
|
213
|
+
{ ...config, getUserId: async () => "user" },
|
|
214
|
+
makeApi({
|
|
215
|
+
queries: {
|
|
216
|
+
getAuthorization: async () => ({
|
|
217
|
+
userId: "user",
|
|
218
|
+
clientId: "client",
|
|
219
|
+
scopes: ["openid", "offline_access"],
|
|
220
|
+
}),
|
|
221
|
+
} as any,
|
|
222
|
+
mutations: { issueAuthorizationCode } as any,
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(response.status).toBe(302);
|
|
227
|
+
expect(issueAuthorizationCode).toHaveBeenCalledWith(
|
|
228
|
+
expect.anything(),
|
|
229
|
+
expect.objectContaining({ scopes: ["openid", "offline_access"] })
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("authorization endpoint returns server_error for prompt=none when component API is missing getAuthorization", async () => {
|
|
234
|
+
const issueAuthorizationCode = vi.fn(async () => "silent-code");
|
|
235
|
+
|
|
236
|
+
const response = await authorizeHandler(
|
|
237
|
+
{} as any,
|
|
238
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&prompt=none&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
239
|
+
method: "GET",
|
|
240
|
+
headers: { Origin: "https://example.com" },
|
|
241
|
+
}),
|
|
242
|
+
{ ...config, getUserId: async () => "user" },
|
|
243
|
+
makeApi({
|
|
244
|
+
queries: { getAuthorization: undefined } as any,
|
|
245
|
+
mutations: { issueAuthorizationCode } as any,
|
|
246
|
+
})
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(response.status).toBe(302);
|
|
250
|
+
const redirect = new URL(response.headers.get("Location") as string);
|
|
251
|
+
expect(redirect.searchParams.get("error")).toBe("server_error");
|
|
252
|
+
expect(issueAuthorizationCode).not.toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
|
|
152
255
|
test("authorization endpoint returns login_required for max_age it cannot safely satisfy", async () => {
|
|
153
256
|
const issueAuthorizationCode = vi.fn(async () => "code");
|
|
154
257
|
|
|
@@ -230,6 +333,51 @@ describe("OAuth handler protocol checks", () => {
|
|
|
230
333
|
await expect(response.json()).resolves.toMatchObject({ error: "invalid_target" });
|
|
231
334
|
});
|
|
232
335
|
|
|
336
|
+
test("token endpoint delegates refresh-token reuse detection to rotation without a family revocation API", async () => {
|
|
337
|
+
const { privateKey } = await generateKeyPair("RS256", { extractable: true });
|
|
338
|
+
const privateKeyPem = await exportPKCS8(privateKey);
|
|
339
|
+
const rotateRefreshToken = vi.fn(async () => ({
|
|
340
|
+
error: "refresh_token_reuse_detected",
|
|
341
|
+
revokedTokens: 2,
|
|
342
|
+
authorizationDeleted: true,
|
|
343
|
+
}));
|
|
344
|
+
const updateAuthorizationLastUsed = vi.fn(async () => undefined);
|
|
345
|
+
|
|
346
|
+
const response = await tokenHandler(
|
|
347
|
+
{} as any,
|
|
348
|
+
new Request("https://example.com/oauth/token", {
|
|
349
|
+
method: "POST",
|
|
350
|
+
body: new URLSearchParams({
|
|
351
|
+
grant_type: "refresh_token",
|
|
352
|
+
client_id: "client",
|
|
353
|
+
refresh_token: "rotated-rt",
|
|
354
|
+
client_secret: "secret",
|
|
355
|
+
}),
|
|
356
|
+
}),
|
|
357
|
+
{ ...config, privateKey: privateKeyPem },
|
|
358
|
+
makeApi({
|
|
359
|
+
queries: {
|
|
360
|
+
getRefreshToken: async () => ({
|
|
361
|
+
clientId: "client",
|
|
362
|
+
userId: "user",
|
|
363
|
+
scopes: ["openid", "offline_access"],
|
|
364
|
+
refreshTokenExpiresAt: Date.now() + 3600000,
|
|
365
|
+
refreshTokenRotatedAt: Date.now() - 1000,
|
|
366
|
+
refreshTokenFamilyId: "family",
|
|
367
|
+
authorizationCode: "code",
|
|
368
|
+
}),
|
|
369
|
+
} as any,
|
|
370
|
+
mutations: { rotateRefreshToken, updateAuthorizationLastUsed } as any,
|
|
371
|
+
})
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(rotateRefreshToken).toHaveBeenCalledOnce();
|
|
375
|
+
expect(updateAuthorizationLastUsed).not.toHaveBeenCalled();
|
|
376
|
+
expect(response.status).toBe(400);
|
|
377
|
+
const body = await response.json();
|
|
378
|
+
expect(body.error).toBe("invalid_grant");
|
|
379
|
+
});
|
|
380
|
+
|
|
233
381
|
test("token endpoint stores the default audience on refresh tokens when no resource is requested", async () => {
|
|
234
382
|
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
235
383
|
const privateKeyPem = await exportPKCS8(privateKey);
|
|
@@ -723,6 +871,40 @@ describe("OAuth handler protocol checks", () => {
|
|
|
723
871
|
);
|
|
724
872
|
});
|
|
725
873
|
|
|
874
|
+
test("DCR preserves provider-supported offline_access for later authorization requests", async () => {
|
|
875
|
+
const registerClient = vi.fn(async () => ({
|
|
876
|
+
clientId: "client",
|
|
877
|
+
clientSecret: "secret",
|
|
878
|
+
clientIdIssuedAt: 0,
|
|
879
|
+
}));
|
|
880
|
+
|
|
881
|
+
const response = await registerHandler(
|
|
882
|
+
{} as any,
|
|
883
|
+
new Request("https://example.com/oauth/register", {
|
|
884
|
+
method: "POST",
|
|
885
|
+
body: JSON.stringify({
|
|
886
|
+
redirect_uris: ["https://client.example.com/cb"],
|
|
887
|
+
scope: "openid profile email",
|
|
888
|
+
token_endpoint_auth_method: "none",
|
|
889
|
+
}),
|
|
890
|
+
headers: { "Content-Type": "application/json" },
|
|
891
|
+
}),
|
|
892
|
+
config,
|
|
893
|
+
makeApi({ clientManagement: { registerClient } as any })
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
expect(response.status).toBe(201);
|
|
897
|
+
expect(registerClient).toHaveBeenCalledWith(
|
|
898
|
+
expect.anything(),
|
|
899
|
+
expect.objectContaining({
|
|
900
|
+
scopes: ["openid", "profile", "email", "offline_access"],
|
|
901
|
+
})
|
|
902
|
+
);
|
|
903
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
904
|
+
scope: "openid profile email offline_access",
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
726
908
|
test("DCR rejects unsafe metadata URLs before saving", async () => {
|
|
727
909
|
const registerClient = vi.fn();
|
|
728
910
|
|
|
@@ -37,8 +37,11 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
37
37
|
expect(result.clientSecret).toBeDefined();
|
|
38
38
|
|
|
39
39
|
// Check DB for Hash
|
|
40
|
-
const clientInDb = await t.
|
|
41
|
-
|
|
40
|
+
const clientInDb = await t.run(async (ctx: any) => {
|
|
41
|
+
return await ctx.db
|
|
42
|
+
.query("oauthClients")
|
|
43
|
+
.withIndex("by_client_id", (q: any) => q.eq("clientId", result.clientId))
|
|
44
|
+
.unique();
|
|
42
45
|
});
|
|
43
46
|
expect(clientInDb).toBeDefined();
|
|
44
47
|
// Secret in DB should NOT be the plain secret returned
|
|
@@ -1671,7 +1674,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
1671
1674
|
expect(response.status).toBe(400);
|
|
1672
1675
|
const body = await response.json();
|
|
1673
1676
|
expect(body.error).toBe("invalid_request");
|
|
1674
|
-
expect(body.error_description).
|
|
1677
|
+
expect(body.error_description).toBe("Invalid client metadata");
|
|
1675
1678
|
});
|
|
1676
1679
|
|
|
1677
1680
|
test("Register Handler: succeeds with confidential client", async () => {
|
|
@@ -2019,7 +2022,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2019
2022
|
expect(response.status).toBe(400);
|
|
2020
2023
|
const body = await response.json();
|
|
2021
2024
|
expect(body.error).toBe("invalid_request");
|
|
2022
|
-
expect(body.error_description).toBe("
|
|
2025
|
+
expect(body.error_description).toBe("Invalid request");
|
|
2023
2026
|
});
|
|
2024
2027
|
|
|
2025
2028
|
test("Authorize Handler: returns error when getUserId not configured", async () => {
|
|
@@ -2142,14 +2145,14 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2142
2145
|
refreshTokenExpiresAt: Date.now() + 864000000,
|
|
2143
2146
|
});
|
|
2144
2147
|
|
|
2145
|
-
// 3. Verify Old Token
|
|
2148
|
+
// 3. Verify Old Token Kept as Rotation Tombstone (tokens are stored as hashes)
|
|
2146
2149
|
const oldTokenHash = await hashToken(oldRefreshToken);
|
|
2147
2150
|
const oldTokenRecord = await t.run(async (ctx) => {
|
|
2148
2151
|
return await ctx.db.query("oauthTokens")
|
|
2149
2152
|
.filter(q => q.eq(q.field("refreshToken"), oldTokenHash))
|
|
2150
2153
|
.first();
|
|
2151
2154
|
});
|
|
2152
|
-
expect(oldTokenRecord).
|
|
2155
|
+
expect(oldTokenRecord?.refreshTokenRotatedAt).toBeDefined();
|
|
2153
2156
|
|
|
2154
2157
|
// 4. Verify New Token Exists (stored as hash)
|
|
2155
2158
|
const newTokenHash = await hashToken(newRefreshToken);
|
|
@@ -2163,8 +2166,8 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2163
2166
|
expect(newTokenRecord?.accessToken).toBe(await hashToken(accessToken));
|
|
2164
2167
|
|
|
2165
2168
|
// 5. Replay Attack (Try to rotate old token again)
|
|
2166
|
-
await
|
|
2167
|
-
oldRefreshToken: oldRefreshToken, // Already used/
|
|
2169
|
+
const replayResult: any = await t.mutation(api.mutations.rotateRefreshToken, {
|
|
2170
|
+
oldRefreshToken: oldRefreshToken, // Already used/rotated
|
|
2168
2171
|
accessToken: "at2",
|
|
2169
2172
|
refreshToken: "rt2",
|
|
2170
2173
|
clientId: client.clientId,
|
|
@@ -2172,7 +2175,8 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2172
2175
|
scopes: ["openid"],
|
|
2173
2176
|
expiresAt: Date.now() + 3600000,
|
|
2174
2177
|
refreshTokenExpiresAt: Date.now() + 864000000,
|
|
2175
|
-
})
|
|
2178
|
+
});
|
|
2179
|
+
expect(replayResult.error).toBe("refresh_token_reuse_detected");
|
|
2176
2180
|
});
|
|
2177
2181
|
|
|
2178
2182
|
// ==========================================
|
|
@@ -2448,7 +2452,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2448
2452
|
expect(auths[0].clientWebsite).toBe("https://example.com");
|
|
2449
2453
|
});
|
|
2450
2454
|
|
|
2451
|
-
test("Authorization: upsertAuthorization
|
|
2455
|
+
test("Authorization: upsertAuthorization replaces scopes with the latest consent", async () => {
|
|
2452
2456
|
const userId = "user-1";
|
|
2453
2457
|
const client = await t.mutation(api.clientManagement.registerClient, {
|
|
2454
2458
|
name: "Client",
|
|
@@ -2464,7 +2468,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2464
2468
|
scopes: ["openid"]
|
|
2465
2469
|
});
|
|
2466
2470
|
|
|
2467
|
-
// Second authorization
|
|
2471
|
+
// Second authorization narrows/replaces the stored grant.
|
|
2468
2472
|
await t.mutation(api.mutations.upsertAuthorization, {
|
|
2469
2473
|
userId,
|
|
2470
2474
|
clientId: client.clientId,
|
|
@@ -2475,8 +2479,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2475
2479
|
userId,
|
|
2476
2480
|
clientId: client.clientId
|
|
2477
2481
|
});
|
|
2478
|
-
expect(auth?.scopes).
|
|
2479
|
-
expect(auth?.scopes).toContain("profile");
|
|
2482
|
+
expect(auth?.scopes).toEqual(["profile"]);
|
|
2480
2483
|
});
|
|
2481
2484
|
|
|
2482
2485
|
test("Authorization: updateAuthorizationLastUsed", async () => {
|
|
@@ -2996,7 +2999,7 @@ codeHash: "test-code-hash",
|
|
|
2996
2999
|
).rejects.toThrow("invalid_code_verifier");
|
|
2997
3000
|
});
|
|
2998
3001
|
|
|
2999
|
-
test("consumeAuthCode: rejects plain PKCE
|
|
3002
|
+
test("consumeAuthCode: rejects stored plain PKCE method", async () => {
|
|
3000
3003
|
const userId = "test-user-id";
|
|
3001
3004
|
const client = await t.mutation(api.clientManagement.registerClient, {
|
|
3002
3005
|
name: "Test Client",
|
|
@@ -3027,7 +3030,7 @@ codeHash: "test-code-hash",
|
|
|
3027
3030
|
redirectUri: "https://cb",
|
|
3028
3031
|
codeVerifier: "wrong-verifier",
|
|
3029
3032
|
})
|
|
3030
|
-
).rejects.toThrow("
|
|
3033
|
+
).rejects.toThrow("unsupported_code_challenge_method");
|
|
3031
3034
|
});
|
|
3032
3035
|
|
|
3033
3036
|
test("consumeAuthCode: rejects expired code", async () => {
|
|
@@ -510,6 +510,189 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
510
510
|
expect(result1.userId).toBeDefined();
|
|
511
511
|
// Note: Refresh token rotation is tested at handler level where tokens are issued
|
|
512
512
|
});
|
|
513
|
+
|
|
514
|
+
test("refresh token reuse revokes the active token family and authorization", async () => {
|
|
515
|
+
const t = convexTest(schema, modules);
|
|
516
|
+
|
|
517
|
+
const client = await t.mutation(api.clientManagement.registerClient, {
|
|
518
|
+
name: "Reuse Detection Client",
|
|
519
|
+
type: "public",
|
|
520
|
+
redirectUris: ["https://example.com/callback"],
|
|
521
|
+
scopes: ["openid", "offline_access"],
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
await t.mutation(api.mutations.upsertAuthorization, {
|
|
525
|
+
userId: "user123",
|
|
526
|
+
clientId: client.clientId,
|
|
527
|
+
scopes: ["openid", "offline_access"],
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
await t.mutation(api.mutations.saveTokens, {
|
|
531
|
+
accessToken: "access-token-1",
|
|
532
|
+
refreshToken: "refresh-token-1",
|
|
533
|
+
clientId: client.clientId,
|
|
534
|
+
userId: "user123",
|
|
535
|
+
scopes: ["openid", "offline_access"],
|
|
536
|
+
expiresAt: Date.now() + 3600000,
|
|
537
|
+
refreshTokenExpiresAt: Date.now() + 2592000000,
|
|
538
|
+
authorizationCode: "authorization-code-family",
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
await t.mutation(api.mutations.rotateRefreshToken, {
|
|
542
|
+
oldRefreshToken: "refresh-token-1",
|
|
543
|
+
accessToken: "access-token-2",
|
|
544
|
+
refreshToken: "refresh-token-2",
|
|
545
|
+
clientId: client.clientId,
|
|
546
|
+
userId: "user123",
|
|
547
|
+
scopes: ["openid", "offline_access"],
|
|
548
|
+
expiresAt: Date.now() + 3600000,
|
|
549
|
+
refreshTokenExpiresAt: Date.now() + 2592000000,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const reuseResult: any = await t.mutation(api.mutations.rotateRefreshToken, {
|
|
553
|
+
oldRefreshToken: "refresh-token-1",
|
|
554
|
+
accessToken: "access-token-3",
|
|
555
|
+
refreshToken: "refresh-token-3",
|
|
556
|
+
clientId: client.clientId,
|
|
557
|
+
userId: "user123",
|
|
558
|
+
scopes: ["openid", "offline_access"],
|
|
559
|
+
expiresAt: Date.now() + 3600000,
|
|
560
|
+
refreshTokenExpiresAt: Date.now() + 2592000000,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
expect(reuseResult.error).toBe("refresh_token_reuse_detected");
|
|
564
|
+
|
|
565
|
+
const remainingTokens = await t.run(async (ctx) => {
|
|
566
|
+
return await ctx.db
|
|
567
|
+
.query("oauthTokens")
|
|
568
|
+
.filter((q) => q.eq(q.field("clientId"), client.clientId))
|
|
569
|
+
.collect();
|
|
570
|
+
});
|
|
571
|
+
expect(remainingTokens).toHaveLength(0);
|
|
572
|
+
|
|
573
|
+
const authorization = await t.query(api.queries.getAuthorization, {
|
|
574
|
+
userId: "user123",
|
|
575
|
+
clientId: client.clientId,
|
|
576
|
+
});
|
|
577
|
+
expect(authorization).toBeNull();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("refresh token reuse after multiple rotations revokes the whole token family", async () => {
|
|
581
|
+
const t = convexTest(schema, modules);
|
|
582
|
+
|
|
583
|
+
const client = await t.mutation(api.clientManagement.registerClient, {
|
|
584
|
+
name: "Multi Rotate Reuse Client",
|
|
585
|
+
type: "public",
|
|
586
|
+
redirectUris: ["https://example.com/callback"],
|
|
587
|
+
scopes: ["openid", "offline_access"],
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await t.mutation(api.mutations.upsertAuthorization, {
|
|
591
|
+
userId: "user123",
|
|
592
|
+
clientId: client.clientId,
|
|
593
|
+
scopes: ["openid", "offline_access"],
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
await t.mutation(api.mutations.saveTokens, {
|
|
597
|
+
accessToken: "access-token-1",
|
|
598
|
+
refreshToken: "refresh-token-1",
|
|
599
|
+
clientId: client.clientId,
|
|
600
|
+
userId: "user123",
|
|
601
|
+
scopes: ["openid", "offline_access"],
|
|
602
|
+
expiresAt: Date.now() + 3600000,
|
|
603
|
+
refreshTokenExpiresAt: Date.now() + 2592000000,
|
|
604
|
+
authorizationCode: "authorization-code-family",
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
await t.mutation(api.mutations.rotateRefreshToken, {
|
|
608
|
+
oldRefreshToken: "refresh-token-1",
|
|
609
|
+
accessToken: "access-token-2",
|
|
610
|
+
refreshToken: "refresh-token-2",
|
|
611
|
+
clientId: client.clientId,
|
|
612
|
+
userId: "user123",
|
|
613
|
+
scopes: ["openid", "offline_access"],
|
|
614
|
+
expiresAt: Date.now() + 3600000,
|
|
615
|
+
refreshTokenExpiresAt: Date.now() + 2592000000,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
await t.mutation(api.mutations.rotateRefreshToken, {
|
|
619
|
+
oldRefreshToken: "refresh-token-2",
|
|
620
|
+
accessToken: "access-token-3",
|
|
621
|
+
refreshToken: "refresh-token-3",
|
|
622
|
+
clientId: client.clientId,
|
|
623
|
+
userId: "user123",
|
|
624
|
+
scopes: ["openid", "offline_access"],
|
|
625
|
+
expiresAt: Date.now() + 3600000,
|
|
626
|
+
refreshTokenExpiresAt: Date.now() + 2592000000,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const reuseResult: any = await t.mutation(api.mutations.rotateRefreshToken, {
|
|
630
|
+
oldRefreshToken: "refresh-token-2",
|
|
631
|
+
accessToken: "access-token-4",
|
|
632
|
+
refreshToken: "refresh-token-4",
|
|
633
|
+
clientId: client.clientId,
|
|
634
|
+
userId: "user123",
|
|
635
|
+
scopes: ["openid", "offline_access"],
|
|
636
|
+
expiresAt: Date.now() + 3600000,
|
|
637
|
+
refreshTokenExpiresAt: Date.now() + 2592000000,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
expect(reuseResult.error).toBe("refresh_token_reuse_detected");
|
|
641
|
+
|
|
642
|
+
const remainingTokens = await t.run(async (ctx) => {
|
|
643
|
+
return await ctx.db
|
|
644
|
+
.query("oauthTokens")
|
|
645
|
+
.filter((q) => q.eq(q.field("clientId"), client.clientId))
|
|
646
|
+
.collect();
|
|
647
|
+
});
|
|
648
|
+
expect(remainingTokens).toHaveLength(0);
|
|
649
|
+
|
|
650
|
+
const authorization = await t.query(api.queries.getAuthorization, {
|
|
651
|
+
userId: "user123",
|
|
652
|
+
clientId: client.clientId,
|
|
653
|
+
});
|
|
654
|
+
expect(authorization).toBeNull();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("cleanupExpired preserves rotated refresh-token tombstones while descendants are active", async () => {
|
|
658
|
+
const t = convexTest(schema, modules);
|
|
659
|
+
|
|
660
|
+
await t.run(async (ctx) => {
|
|
661
|
+
await ctx.db.insert("oauthTokens", {
|
|
662
|
+
accessToken: "a".repeat(64),
|
|
663
|
+
refreshToken: "b".repeat(64),
|
|
664
|
+
clientId: "client",
|
|
665
|
+
userId: "user",
|
|
666
|
+
scopes: ["openid", "offline_access"],
|
|
667
|
+
expiresAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
|
|
668
|
+
refreshTokenExpiresAt: Date.now() - 1000,
|
|
669
|
+
authorizationCode: "authorization-code-family",
|
|
670
|
+
refreshTokenFamilyId: "family",
|
|
671
|
+
refreshTokenRotatedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
672
|
+
});
|
|
673
|
+
await ctx.db.insert("oauthTokens", {
|
|
674
|
+
accessToken: "c".repeat(64),
|
|
675
|
+
refreshToken: "d".repeat(64),
|
|
676
|
+
clientId: "client",
|
|
677
|
+
userId: "user",
|
|
678
|
+
scopes: ["openid", "offline_access"],
|
|
679
|
+
expiresAt: Date.now() - 1000,
|
|
680
|
+
refreshTokenExpiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
|
681
|
+
authorizationCode: "authorization-code-family",
|
|
682
|
+
refreshTokenFamilyId: "family",
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
await t.mutation(internal.mutations.cleanupExpired, {});
|
|
687
|
+
|
|
688
|
+
const tombstone = await t.run(async (ctx) => {
|
|
689
|
+
return await ctx.db
|
|
690
|
+
.query("oauthTokens")
|
|
691
|
+
.withIndex("by_refresh_token", (q) => q.eq("refreshToken", "b".repeat(64)))
|
|
692
|
+
.unique();
|
|
693
|
+
});
|
|
694
|
+
expect(tombstone?.refreshTokenRotatedAt).toBeDefined();
|
|
695
|
+
});
|
|
513
696
|
});
|
|
514
697
|
|
|
515
698
|
describe("Section 5.1 - Bearer Token Usage", () => {
|
|
@@ -739,6 +922,12 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
739
922
|
expect(codeData.userId).toBe("user123");
|
|
740
923
|
expect(codeData.codeHash).toBeDefined(); // Verify codeHash is returned
|
|
741
924
|
|
|
925
|
+
await t.mutation(api.mutations.upsertAuthorization, {
|
|
926
|
+
userId: "user123",
|
|
927
|
+
clientId: client.clientId,
|
|
928
|
+
scopes: ["openid", "profile"],
|
|
929
|
+
});
|
|
930
|
+
|
|
742
931
|
// Save tokens using the returned codeHash
|
|
743
932
|
await t.mutation(api.mutations.saveTokens, {
|
|
744
933
|
accessToken: "test-access-token",
|
|
@@ -796,6 +985,12 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
796
985
|
|
|
797
986
|
expect(tokensAfter.length).toBe(0); // All tokens should be deleted
|
|
798
987
|
|
|
988
|
+
const authorizationAfter = await t.query(api.queries.getAuthorization, {
|
|
989
|
+
userId: "user123",
|
|
990
|
+
clientId: client.clientId,
|
|
991
|
+
});
|
|
992
|
+
expect(authorizationAfter).toBeNull();
|
|
993
|
+
|
|
799
994
|
await expect(
|
|
800
995
|
t.mutation(api.mutations.saveTokens, {
|
|
801
996
|
accessToken: "replayed-access-token",
|
|
@@ -810,6 +1005,44 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
810
1005
|
).rejects.toThrow("authorization_code_reuse_detected");
|
|
811
1006
|
});
|
|
812
1007
|
|
|
1008
|
+
test("used auth code tombstone survives while descendant refresh tokens are still valid", async () => {
|
|
1009
|
+
const t = convexTest(schema, modules);
|
|
1010
|
+
|
|
1011
|
+
await t.run(async (ctx) => {
|
|
1012
|
+
await ctx.db.insert("oauthCodes", {
|
|
1013
|
+
code: "b".repeat(64),
|
|
1014
|
+
clientId: "client",
|
|
1015
|
+
userId: "user",
|
|
1016
|
+
scopes: ["openid", "offline_access"],
|
|
1017
|
+
redirectUri: "https://cb",
|
|
1018
|
+
codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
1019
|
+
codeChallengeMethod: "S256",
|
|
1020
|
+
expiresAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
|
|
1021
|
+
usedAt: Date.now() - 31 * 24 * 60 * 60 * 1000,
|
|
1022
|
+
});
|
|
1023
|
+
await ctx.db.insert("oauthTokens", {
|
|
1024
|
+
accessToken: "a".repeat(64),
|
|
1025
|
+
refreshToken: "c".repeat(64),
|
|
1026
|
+
clientId: "client",
|
|
1027
|
+
userId: "user",
|
|
1028
|
+
scopes: ["openid", "offline_access"],
|
|
1029
|
+
expiresAt: Date.now() - 1000,
|
|
1030
|
+
refreshTokenExpiresAt: Date.now() + 29 * 24 * 60 * 60 * 1000,
|
|
1031
|
+
authorizationCode: "b".repeat(64),
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
await t.mutation(internal.mutations.cleanupExpired, {});
|
|
1036
|
+
|
|
1037
|
+
const remaining = await t.run(async (ctx) => {
|
|
1038
|
+
return await ctx.db
|
|
1039
|
+
.query("oauthCodes")
|
|
1040
|
+
.withIndex("by_code", (q) => q.eq("code", "b".repeat(64)))
|
|
1041
|
+
.unique();
|
|
1042
|
+
});
|
|
1043
|
+
expect(remaining).not.toBeNull();
|
|
1044
|
+
});
|
|
1045
|
+
|
|
813
1046
|
test("RFC Line 1136: Single use enforcement - code is marked as used", async () => {
|
|
814
1047
|
const t = convexTest(schema, modules);
|
|
815
1048
|
|
|
@@ -26,6 +26,7 @@ function isValidRedirectUri(uri: string): boolean {
|
|
|
26
26
|
const isLoopback =
|
|
27
27
|
host === "localhost" ||
|
|
28
28
|
host === "127.0.0.1" ||
|
|
29
|
+
host === "[::1]" ||
|
|
29
30
|
host === "::1";
|
|
30
31
|
|
|
31
32
|
if (parsed.protocol === "https:") return true;
|
|
@@ -212,6 +213,16 @@ export const deleteClient = mutation({
|
|
|
212
213
|
await ctx.db.delete(code._id);
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
// Delete all authorization records for this client
|
|
217
|
+
const authorizations = await ctx.db
|
|
218
|
+
.query("oauthAuthorizations")
|
|
219
|
+
.filter(q => q.eq(q.field("clientId"), args.clientId))
|
|
220
|
+
.collect();
|
|
221
|
+
|
|
222
|
+
for (const authorization of authorizations) {
|
|
223
|
+
await ctx.db.delete(authorization._id);
|
|
224
|
+
}
|
|
225
|
+
|
|
215
226
|
// Delete the client
|
|
216
227
|
await ctx.db.delete(client._id);
|
|
217
228
|
|