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