@codefox-inc/oauth-provider 0.3.2 → 0.4.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/README.md +40 -14
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +9 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/clientManagement.d.ts +1 -0
- package/dist/component/clientManagement.d.ts.map +1 -1
- package/dist/component/clientManagement.js +24 -0
- package/dist/component/clientManagement.js.map +1 -1
- package/dist/component/handlers.d.ts +16 -0
- package/dist/component/handlers.d.ts.map +1 -1
- package/dist/component/handlers.js +275 -28
- package/dist/component/handlers.js.map +1 -1
- package/dist/component/mutations.d.ts +9 -0
- package/dist/component/mutations.d.ts.map +1 -1
- package/dist/component/mutations.js +112 -40
- package/dist/component/mutations.js.map +1 -1
- package/dist/component/queries.d.ts +8 -0
- package/dist/component/queries.d.ts.map +1 -1
- package/dist/component/schema.d.ts +18 -4
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +7 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/lib/oauth.d.ts.map +1 -1
- package/dist/lib/oauth.js +5 -2
- package/dist/lib/oauth.js.map +1 -1
- package/package.json +39 -39
- package/src/client/__tests__/oauth-provider.test.ts +39 -0
- package/src/client/index.ts +4 -0
- package/src/component/__tests__/handlers-protocol.test.ts +880 -0
- package/src/component/__tests__/mutations-protocol.test.ts +448 -0
- package/src/component/__tests__/oauth.test.ts +32 -28
- package/src/component/__tests__/rfc-compliance.test.ts +79 -11
- package/src/component/_generated/component.ts +17 -1
- package/src/component/clientManagement.ts +31 -0
- package/src/component/handlers.ts +355 -31
- package/src/component/mutations.ts +133 -40
- package/src/component/schema.ts +11 -0
- package/src/lib/__tests__/oauth-jwt.test.ts +68 -0
- package/src/lib/oauth.ts +8 -4
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { decodeJwt, decodeProtectedHeader, exportJWK, exportPKCS8, generateKeyPair } from "jose";
|
|
3
|
+
import {
|
|
4
|
+
authorizeHandler,
|
|
5
|
+
openIdConfigurationHandler,
|
|
6
|
+
registerHandler,
|
|
7
|
+
tokenHandler,
|
|
8
|
+
userInfoHandler,
|
|
9
|
+
type OAuthComponentAPI,
|
|
10
|
+
} from "../handlers";
|
|
11
|
+
import type { OAuthConfig } from "../../lib/oauth";
|
|
12
|
+
|
|
13
|
+
const config: OAuthConfig = {
|
|
14
|
+
privateKey: "dummy",
|
|
15
|
+
jwks: "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"n\",\"e\":\"AQAB\"}]}",
|
|
16
|
+
siteUrl: "https://example.com",
|
|
17
|
+
allowDynamicClientRegistration: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function makeApi(overrides: Partial<OAuthComponentAPI> = {}): OAuthComponentAPI {
|
|
21
|
+
return {
|
|
22
|
+
queries: {
|
|
23
|
+
getClient: async (_ctx, { clientId }) => ({
|
|
24
|
+
clientId,
|
|
25
|
+
type: "confidential",
|
|
26
|
+
redirectUris: ["https://cb"],
|
|
27
|
+
allowedScopes: ["openid", "profile", "offline_access"],
|
|
28
|
+
}),
|
|
29
|
+
getRefreshToken: async () => null,
|
|
30
|
+
getTokensByUser: async () => [],
|
|
31
|
+
...overrides.queries,
|
|
32
|
+
},
|
|
33
|
+
mutations: {
|
|
34
|
+
issueAuthorizationCode: async () => "",
|
|
35
|
+
consumeAuthCode: async () => {
|
|
36
|
+
throw new Error("invalid_grant");
|
|
37
|
+
},
|
|
38
|
+
saveTokens: async () => undefined,
|
|
39
|
+
rotateRefreshToken: async () => undefined,
|
|
40
|
+
upsertAuthorization: async () => "",
|
|
41
|
+
updateAuthorizationLastUsed: async () => undefined,
|
|
42
|
+
...overrides.mutations,
|
|
43
|
+
},
|
|
44
|
+
clientManagement: {
|
|
45
|
+
registerClient: async () => ({
|
|
46
|
+
clientId: "client",
|
|
47
|
+
clientSecret: "secret",
|
|
48
|
+
clientIdIssuedAt: 0,
|
|
49
|
+
}),
|
|
50
|
+
verifyClientSecret: async () => true,
|
|
51
|
+
...overrides.clientManagement,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("OAuth handler protocol checks", () => {
|
|
57
|
+
test("authorization endpoint rejects duplicated singleton parameters", async () => {
|
|
58
|
+
const issueAuthorizationCode = vi.fn(async () => "code");
|
|
59
|
+
|
|
60
|
+
const response = await authorizeHandler(
|
|
61
|
+
{} as any,
|
|
62
|
+
new Request("https://example.com/oauth/authorize?response_type=code&response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&consent=approve", {
|
|
63
|
+
method: "GET",
|
|
64
|
+
headers: { Origin: "https://example.com" },
|
|
65
|
+
}),
|
|
66
|
+
{ ...config, getUserId: async () => "user" },
|
|
67
|
+
makeApi({ mutations: { issueAuthorizationCode } as any })
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(response.status).toBe(302);
|
|
71
|
+
const redirect = new URL(response.headers.get("Location") as string);
|
|
72
|
+
expect(redirect.searchParams.get("error")).toBe("invalid_request");
|
|
73
|
+
expect(redirect.searchParams.get("error_description")).toContain("Duplicate parameter");
|
|
74
|
+
expect(issueAuthorizationCode).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("authorization endpoint rejects resource values that are not absolute URI references without fragments", async () => {
|
|
78
|
+
const response = await authorizeHandler(
|
|
79
|
+
{} as any,
|
|
80
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&consent=approve&resource=https%3A%2F%2Fapi.example.com%2Fmcp%23frag", {
|
|
81
|
+
method: "GET",
|
|
82
|
+
headers: { Origin: "https://example.com" },
|
|
83
|
+
}),
|
|
84
|
+
{ ...config, getUserId: async () => "user" },
|
|
85
|
+
makeApi()
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(response.status).toBe(302);
|
|
89
|
+
const redirect = new URL(response.headers.get("Location") as string);
|
|
90
|
+
expect(redirect.searchParams.get("error")).toBe("invalid_target");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("authorization endpoint treats duplicate resource parameters as invalid_target", async () => {
|
|
94
|
+
const issueAuthorizationCode = vi.fn(async () => "code");
|
|
95
|
+
|
|
96
|
+
const response = await authorizeHandler(
|
|
97
|
+
{} as any,
|
|
98
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&consent=approve&resource=https%3A%2F%2Fapi.example.com%2Fone&resource=https%3A%2F%2Fapi.example.com%2Ftwo", {
|
|
99
|
+
method: "GET",
|
|
100
|
+
headers: { Origin: "https://example.com" },
|
|
101
|
+
}),
|
|
102
|
+
{ ...config, getUserId: async () => "user" },
|
|
103
|
+
makeApi({ mutations: { issueAuthorizationCode } as any })
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(response.status).toBe(302);
|
|
107
|
+
const redirect = new URL(response.headers.get("Location") as string);
|
|
108
|
+
expect(redirect.searchParams.get("error")).toBe("invalid_target");
|
|
109
|
+
expect(issueAuthorizationCode).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("authorization endpoint rejects code_challenge values outside PKCE ABNF", async () => {
|
|
113
|
+
const issueAuthorizationCode = vi.fn(async () => "code");
|
|
114
|
+
|
|
115
|
+
const response = await authorizeHandler(
|
|
116
|
+
{} as any,
|
|
117
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&code_challenge=short&code_challenge_method=S256&consent=approve", {
|
|
118
|
+
method: "GET",
|
|
119
|
+
headers: { Origin: "https://example.com" },
|
|
120
|
+
}),
|
|
121
|
+
{ ...config, getUserId: async () => "user" },
|
|
122
|
+
makeApi({ mutations: { issueAuthorizationCode } as any })
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(response.status).toBe(302);
|
|
126
|
+
const redirect = new URL(response.headers.get("Location") as string);
|
|
127
|
+
expect(redirect.searchParams.get("error")).toBe("invalid_request");
|
|
128
|
+
expect(redirect.searchParams.get("error_description")).toContain("code_challenge");
|
|
129
|
+
expect(issueAuthorizationCode).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("authorization endpoint keeps offline_access when prompt contains consent in a space-delimited list", async () => {
|
|
133
|
+
const issueAuthorizationCode = vi.fn(async () => "code");
|
|
134
|
+
|
|
135
|
+
const response = await authorizeHandler(
|
|
136
|
+
{} as any,
|
|
137
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20offline_access&prompt=login%20consent&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&consent=approve", {
|
|
138
|
+
method: "GET",
|
|
139
|
+
headers: { Origin: "https://example.com" },
|
|
140
|
+
}),
|
|
141
|
+
{ ...config, getUserId: async () => "user" },
|
|
142
|
+
makeApi({ mutations: { issueAuthorizationCode } as any })
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(response.status).toBe(302);
|
|
146
|
+
expect(issueAuthorizationCode).toHaveBeenCalledWith(
|
|
147
|
+
expect.anything(),
|
|
148
|
+
expect.objectContaining({ scopes: ["openid", "offline_access"] })
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("authorization endpoint returns login_required for max_age it cannot safely satisfy", async () => {
|
|
153
|
+
const issueAuthorizationCode = vi.fn(async () => "code");
|
|
154
|
+
|
|
155
|
+
const response = await authorizeHandler(
|
|
156
|
+
{} as any,
|
|
157
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&consent=approve&max_age=60", {
|
|
158
|
+
method: "GET",
|
|
159
|
+
headers: { Origin: "https://example.com" },
|
|
160
|
+
}),
|
|
161
|
+
{ ...config, getUserId: async () => "user" },
|
|
162
|
+
makeApi({ mutations: { issueAuthorizationCode } as any })
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(response.status).toBe(302);
|
|
166
|
+
const redirect = new URL(response.headers.get("Location") as string);
|
|
167
|
+
expect(redirect.searchParams.get("error")).toBe("login_required");
|
|
168
|
+
expect(issueAuthorizationCode).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("token endpoint rejects a resource request when the authorization code was not resource-bound", async () => {
|
|
172
|
+
const consumeAuthCode = vi.fn(async () => ({
|
|
173
|
+
userId: "user",
|
|
174
|
+
scopes: ["openid"],
|
|
175
|
+
codeChallenge: "challenge",
|
|
176
|
+
codeChallengeMethod: "S256",
|
|
177
|
+
redirectUri: "https://cb",
|
|
178
|
+
codeHash: "hash",
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
const response = await tokenHandler(
|
|
182
|
+
{} as any,
|
|
183
|
+
new Request("https://example.com/oauth/token", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
body: new URLSearchParams({
|
|
186
|
+
grant_type: "authorization_code",
|
|
187
|
+
client_id: "client",
|
|
188
|
+
code: "code",
|
|
189
|
+
redirect_uri: "https://cb",
|
|
190
|
+
code_verifier: "verifier",
|
|
191
|
+
client_secret: "secret",
|
|
192
|
+
resource: "https://api.example.com/mcp",
|
|
193
|
+
}),
|
|
194
|
+
}),
|
|
195
|
+
config,
|
|
196
|
+
makeApi({ mutations: { consumeAuthCode } as any })
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(response.status).toBe(400);
|
|
200
|
+
await expect(response.json()).resolves.toMatchObject({ error: "invalid_target" });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("token endpoint rejects a resource request when the refresh token was not resource-bound", async () => {
|
|
204
|
+
const response = await tokenHandler(
|
|
205
|
+
{} as any,
|
|
206
|
+
new Request("https://example.com/oauth/token", {
|
|
207
|
+
method: "POST",
|
|
208
|
+
body: new URLSearchParams({
|
|
209
|
+
grant_type: "refresh_token",
|
|
210
|
+
client_id: "client",
|
|
211
|
+
refresh_token: "rt",
|
|
212
|
+
client_secret: "secret",
|
|
213
|
+
resource: "https://api.example.com/mcp",
|
|
214
|
+
}),
|
|
215
|
+
}),
|
|
216
|
+
config,
|
|
217
|
+
makeApi({
|
|
218
|
+
queries: {
|
|
219
|
+
getRefreshToken: async () => ({
|
|
220
|
+
clientId: "client",
|
|
221
|
+
userId: "user",
|
|
222
|
+
scopes: ["openid", "offline_access"],
|
|
223
|
+
refreshTokenExpiresAt: Date.now() + 3600000,
|
|
224
|
+
}),
|
|
225
|
+
} as any,
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(response.status).toBe(400);
|
|
230
|
+
await expect(response.json()).resolves.toMatchObject({ error: "invalid_target" });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("token endpoint stores the default audience on refresh tokens when no resource is requested", async () => {
|
|
234
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
235
|
+
const privateKeyPem = await exportPKCS8(privateKey);
|
|
236
|
+
const jwk = await exportJWK(publicKey);
|
|
237
|
+
const saveTokens = vi.fn(async () => undefined);
|
|
238
|
+
|
|
239
|
+
const response = await tokenHandler(
|
|
240
|
+
{} as any,
|
|
241
|
+
new Request("https://example.com/oauth/token", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
body: new URLSearchParams({
|
|
244
|
+
grant_type: "authorization_code",
|
|
245
|
+
client_id: "client",
|
|
246
|
+
code: "code",
|
|
247
|
+
redirect_uri: "https://cb",
|
|
248
|
+
code_verifier: "verifier",
|
|
249
|
+
client_secret: "secret",
|
|
250
|
+
}),
|
|
251
|
+
}),
|
|
252
|
+
{
|
|
253
|
+
...config,
|
|
254
|
+
privateKey: privateKeyPem,
|
|
255
|
+
jwks: JSON.stringify({ keys: [{ ...jwk, kid: "test-key", alg: "RS256" }] }),
|
|
256
|
+
applicationID: "default-audience",
|
|
257
|
+
},
|
|
258
|
+
makeApi({
|
|
259
|
+
mutations: {
|
|
260
|
+
consumeAuthCode: async () => ({
|
|
261
|
+
userId: "user",
|
|
262
|
+
scopes: ["openid", "offline_access"],
|
|
263
|
+
codeChallenge: "challenge",
|
|
264
|
+
codeChallengeMethod: "S256",
|
|
265
|
+
redirectUri: "https://cb",
|
|
266
|
+
codeHash: "hash",
|
|
267
|
+
}),
|
|
268
|
+
saveTokens,
|
|
269
|
+
} as any,
|
|
270
|
+
})
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(response.status).toBe(200);
|
|
274
|
+
expect(saveTokens).toHaveBeenCalledWith(
|
|
275
|
+
expect.anything(),
|
|
276
|
+
expect.objectContaining({ audience: "default-audience", resource: undefined })
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("ID token includes auth_time from the authorization code", async () => {
|
|
281
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
282
|
+
const privateKeyPem = await exportPKCS8(privateKey);
|
|
283
|
+
const jwk = await exportJWK(publicKey);
|
|
284
|
+
|
|
285
|
+
const response = await tokenHandler(
|
|
286
|
+
{} as any,
|
|
287
|
+
new Request("https://example.com/oauth/token", {
|
|
288
|
+
method: "POST",
|
|
289
|
+
body: new URLSearchParams({
|
|
290
|
+
grant_type: "authorization_code",
|
|
291
|
+
client_id: "client",
|
|
292
|
+
code: "code",
|
|
293
|
+
redirect_uri: "https://cb",
|
|
294
|
+
code_verifier: "verifier",
|
|
295
|
+
client_secret: "secret",
|
|
296
|
+
}),
|
|
297
|
+
}),
|
|
298
|
+
{
|
|
299
|
+
...config,
|
|
300
|
+
privateKey: privateKeyPem,
|
|
301
|
+
jwks: JSON.stringify({ keys: [{ ...jwk, kid: "test-key", alg: "RS256" }] }),
|
|
302
|
+
},
|
|
303
|
+
makeApi({
|
|
304
|
+
mutations: {
|
|
305
|
+
consumeAuthCode: async () => ({
|
|
306
|
+
userId: "user",
|
|
307
|
+
scopes: ["openid"],
|
|
308
|
+
codeChallenge: "challenge",
|
|
309
|
+
codeChallengeMethod: "S256",
|
|
310
|
+
redirectUri: "https://cb",
|
|
311
|
+
codeHash: "hash",
|
|
312
|
+
authTime: 1710000000,
|
|
313
|
+
}),
|
|
314
|
+
} as any,
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(response.status).toBe(200);
|
|
319
|
+
const body = await response.json();
|
|
320
|
+
expect(decodeJwt(body.id_token)).toMatchObject({ auth_time: 1710000000 });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("authorization code resource becomes access token audience and token resource must match", async () => {
|
|
324
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
325
|
+
const privateKeyPem = await exportPKCS8(privateKey);
|
|
326
|
+
const jwk = await exportJWK(publicKey);
|
|
327
|
+
const jwtConfig: OAuthConfig = {
|
|
328
|
+
...config,
|
|
329
|
+
privateKey: privateKeyPem,
|
|
330
|
+
jwks: JSON.stringify({ keys: [{ ...jwk, kid: "test-key", alg: "RS256" }] }),
|
|
331
|
+
getUserId: async () => "user",
|
|
332
|
+
};
|
|
333
|
+
const issueAuthorizationCode = vi.fn(async () => "code-with-resource");
|
|
334
|
+
const consumeAuthCode = vi.fn(async (_ctx: unknown, args: { resource?: string }) => {
|
|
335
|
+
if (args.resource && args.resource !== "https://api.example.com/mcp") {
|
|
336
|
+
throw new Error("invalid_target");
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
userId: "user",
|
|
340
|
+
scopes: ["openid"],
|
|
341
|
+
codeChallenge: "challenge",
|
|
342
|
+
codeChallengeMethod: "S256",
|
|
343
|
+
redirectUri: "https://cb",
|
|
344
|
+
codeHash: "hash",
|
|
345
|
+
resource: "https://api.example.com/mcp",
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
const api = makeApi({
|
|
349
|
+
queries: {
|
|
350
|
+
getClient: async (_ctx: unknown, { clientId }: { clientId: string }) => ({
|
|
351
|
+
clientId,
|
|
352
|
+
type: "public" as const,
|
|
353
|
+
redirectUris: ["https://cb"],
|
|
354
|
+
allowedScopes: ["openid"],
|
|
355
|
+
tokenEndpointAuthMethod: "none" as const,
|
|
356
|
+
}),
|
|
357
|
+
} as any,
|
|
358
|
+
mutations: {
|
|
359
|
+
issueAuthorizationCode,
|
|
360
|
+
consumeAuthCode,
|
|
361
|
+
} as any,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await authorizeHandler(
|
|
365
|
+
{} as any,
|
|
366
|
+
new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&consent=approve&resource=https%3A%2F%2Fapi.example.com%2Fmcp", {
|
|
367
|
+
method: "GET",
|
|
368
|
+
headers: { Origin: "https://example.com" },
|
|
369
|
+
}),
|
|
370
|
+
jwtConfig,
|
|
371
|
+
api
|
|
372
|
+
);
|
|
373
|
+
expect(issueAuthorizationCode).toHaveBeenCalledWith(
|
|
374
|
+
expect.anything(),
|
|
375
|
+
expect.objectContaining({ resource: "https://api.example.com/mcp" })
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const mismatch = await tokenHandler(
|
|
379
|
+
{} as any,
|
|
380
|
+
new Request("https://example.com/oauth/token", {
|
|
381
|
+
method: "POST",
|
|
382
|
+
body: new URLSearchParams({
|
|
383
|
+
grant_type: "authorization_code",
|
|
384
|
+
client_id: "client",
|
|
385
|
+
code: "code-with-resource",
|
|
386
|
+
redirect_uri: "https://cb",
|
|
387
|
+
code_verifier: "verifier",
|
|
388
|
+
resource: "https://api.example.com/other",
|
|
389
|
+
}),
|
|
390
|
+
}),
|
|
391
|
+
jwtConfig,
|
|
392
|
+
api
|
|
393
|
+
);
|
|
394
|
+
expect(mismatch.status).toBe(400);
|
|
395
|
+
await expect(mismatch.json()).resolves.toMatchObject({ error: "invalid_target" });
|
|
396
|
+
|
|
397
|
+
const response = await tokenHandler(
|
|
398
|
+
{} as any,
|
|
399
|
+
new Request("https://example.com/oauth/token", {
|
|
400
|
+
method: "POST",
|
|
401
|
+
body: new URLSearchParams({
|
|
402
|
+
grant_type: "authorization_code",
|
|
403
|
+
client_id: "client",
|
|
404
|
+
code: "code-with-resource",
|
|
405
|
+
redirect_uri: "https://cb",
|
|
406
|
+
code_verifier: "verifier",
|
|
407
|
+
resource: "https://api.example.com/mcp",
|
|
408
|
+
}),
|
|
409
|
+
}),
|
|
410
|
+
jwtConfig,
|
|
411
|
+
api
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(response.status).toBe(200);
|
|
415
|
+
const body = await response.json();
|
|
416
|
+
expect(decodeProtectedHeader(body.access_token).typ).toBe("at+jwt");
|
|
417
|
+
expect(decodeJwt(body.access_token)).toMatchObject({
|
|
418
|
+
aud: "https://api.example.com/mcp",
|
|
419
|
+
client_id: "client",
|
|
420
|
+
scope: "openid",
|
|
421
|
+
cid: "client",
|
|
422
|
+
});
|
|
423
|
+
expect(decodeJwt(body.access_token).jti).toEqual(expect.any(String));
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("token endpoint enforces the registered DCR token endpoint auth method", async () => {
|
|
427
|
+
const response = await tokenHandler(
|
|
428
|
+
{} as any,
|
|
429
|
+
new Request("https://example.com/oauth/token", {
|
|
430
|
+
method: "POST",
|
|
431
|
+
body: new URLSearchParams({
|
|
432
|
+
grant_type: "authorization_code",
|
|
433
|
+
code: "code",
|
|
434
|
+
redirect_uri: "https://cb",
|
|
435
|
+
code_verifier: "verifier",
|
|
436
|
+
}),
|
|
437
|
+
headers: { Authorization: `Basic ${btoa("client:secret")}` },
|
|
438
|
+
}),
|
|
439
|
+
config,
|
|
440
|
+
makeApi({
|
|
441
|
+
queries: {
|
|
442
|
+
getClient: async (_ctx: unknown, { clientId }: { clientId: string }) => ({
|
|
443
|
+
clientId,
|
|
444
|
+
type: "confidential" as const,
|
|
445
|
+
redirectUris: ["https://cb"],
|
|
446
|
+
allowedScopes: ["openid"],
|
|
447
|
+
tokenEndpointAuthMethod: "client_secret_post" as const,
|
|
448
|
+
}),
|
|
449
|
+
} as any,
|
|
450
|
+
})
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
expect(response.status).toBe(401);
|
|
454
|
+
await expect(response.json()).resolves.toMatchObject({ error: "invalid_client" });
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("userinfo returns invalid_token challenge when a presented token maps to no user", async () => {
|
|
458
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
459
|
+
const privateKeyPem = await exportPKCS8(privateKey);
|
|
460
|
+
const jwk = await exportJWK(publicKey);
|
|
461
|
+
const jwtConfig: OAuthConfig = {
|
|
462
|
+
...config,
|
|
463
|
+
privateKey: privateKeyPem,
|
|
464
|
+
jwks: JSON.stringify({ keys: [{ ...jwk, kid: "test-key", alg: "RS256" }] }),
|
|
465
|
+
};
|
|
466
|
+
const tokenResponse = await tokenHandler(
|
|
467
|
+
{} as any,
|
|
468
|
+
new Request("https://example.com/oauth/token", {
|
|
469
|
+
method: "POST",
|
|
470
|
+
body: new URLSearchParams({
|
|
471
|
+
grant_type: "authorization_code",
|
|
472
|
+
client_id: "client",
|
|
473
|
+
code: "code",
|
|
474
|
+
redirect_uri: "https://cb",
|
|
475
|
+
code_verifier: "verifier",
|
|
476
|
+
client_secret: "secret",
|
|
477
|
+
}),
|
|
478
|
+
}),
|
|
479
|
+
jwtConfig,
|
|
480
|
+
makeApi({
|
|
481
|
+
mutations: {
|
|
482
|
+
consumeAuthCode: async () => ({
|
|
483
|
+
userId: "missing-user",
|
|
484
|
+
scopes: ["openid"],
|
|
485
|
+
codeChallenge: "challenge",
|
|
486
|
+
codeChallengeMethod: "S256",
|
|
487
|
+
redirectUri: "https://cb",
|
|
488
|
+
codeHash: "hash",
|
|
489
|
+
}),
|
|
490
|
+
} as any,
|
|
491
|
+
})
|
|
492
|
+
);
|
|
493
|
+
const { access_token } = await tokenResponse.json();
|
|
494
|
+
|
|
495
|
+
const response = await userInfoHandler(
|
|
496
|
+
{} as any,
|
|
497
|
+
new Request("https://example.com/oauth/userinfo", {
|
|
498
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
499
|
+
}),
|
|
500
|
+
jwtConfig,
|
|
501
|
+
async () => null
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
expect(response.status).toBe(401);
|
|
505
|
+
expect(response.headers.get("WWW-Authenticate")).toContain('error="invalid_token"');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("token endpoint rejects duplicated OAuth parameters", async () => {
|
|
509
|
+
const getClient = vi.fn();
|
|
510
|
+
const body = new URLSearchParams({
|
|
511
|
+
grant_type: "authorization_code",
|
|
512
|
+
client_id: "client",
|
|
513
|
+
code: "code-1",
|
|
514
|
+
redirect_uri: "https://cb",
|
|
515
|
+
code_verifier: "verifier",
|
|
516
|
+
client_secret: "secret",
|
|
517
|
+
});
|
|
518
|
+
body.append("code", "code-2");
|
|
519
|
+
|
|
520
|
+
const response = await tokenHandler(
|
|
521
|
+
{} as any,
|
|
522
|
+
new Request("https://example.com/oauth/token", {
|
|
523
|
+
method: "POST",
|
|
524
|
+
body,
|
|
525
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
526
|
+
}),
|
|
527
|
+
config,
|
|
528
|
+
makeApi({ queries: { getClient } as any })
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
expect(response.status).toBe(400);
|
|
532
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
533
|
+
error: "invalid_request",
|
|
534
|
+
});
|
|
535
|
+
expect(getClient).not.toHaveBeenCalled();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("token endpoint treats duplicate resource parameters as invalid_target", async () => {
|
|
539
|
+
const getClient = vi.fn();
|
|
540
|
+
const body = new URLSearchParams({
|
|
541
|
+
grant_type: "authorization_code",
|
|
542
|
+
client_id: "client",
|
|
543
|
+
code: "code-1",
|
|
544
|
+
redirect_uri: "https://cb",
|
|
545
|
+
code_verifier: "verifier",
|
|
546
|
+
client_secret: "secret",
|
|
547
|
+
resource: "https://api.example.com/one",
|
|
548
|
+
});
|
|
549
|
+
body.append("resource", "https://api.example.com/two");
|
|
550
|
+
|
|
551
|
+
const response = await tokenHandler(
|
|
552
|
+
{} as any,
|
|
553
|
+
new Request("https://example.com/oauth/token", {
|
|
554
|
+
method: "POST",
|
|
555
|
+
body,
|
|
556
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
557
|
+
}),
|
|
558
|
+
config,
|
|
559
|
+
makeApi({ queries: { getClient } as any })
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
expect(response.status).toBe(400);
|
|
563
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
564
|
+
error: "invalid_target",
|
|
565
|
+
});
|
|
566
|
+
expect(getClient).not.toHaveBeenCalled();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("token endpoint rejects Basic auth combined with client_secret_post", async () => {
|
|
570
|
+
const verifyClientSecret = vi.fn(async () => true);
|
|
571
|
+
const basic = btoa("client:basic-secret");
|
|
572
|
+
|
|
573
|
+
const response = await tokenHandler(
|
|
574
|
+
{} as any,
|
|
575
|
+
new Request("https://example.com/oauth/token", {
|
|
576
|
+
method: "POST",
|
|
577
|
+
body: new URLSearchParams({
|
|
578
|
+
grant_type: "authorization_code",
|
|
579
|
+
client_id: "client",
|
|
580
|
+
client_secret: "post-secret",
|
|
581
|
+
code: "code",
|
|
582
|
+
redirect_uri: "https://cb",
|
|
583
|
+
code_verifier: "verifier",
|
|
584
|
+
}),
|
|
585
|
+
headers: {
|
|
586
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
587
|
+
"Authorization": `Basic ${basic}`,
|
|
588
|
+
},
|
|
589
|
+
}),
|
|
590
|
+
config,
|
|
591
|
+
makeApi({ clientManagement: { verifyClientSecret } as any })
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
expect(response.status).toBe(400);
|
|
595
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
596
|
+
error: "invalid_request",
|
|
597
|
+
});
|
|
598
|
+
expect(verifyClientSecret).not.toHaveBeenCalled();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("token endpoint decodes Basic client credentials before validation", async () => {
|
|
602
|
+
const getClient = vi.fn(async (_ctx, { clientId }) => ({
|
|
603
|
+
clientId,
|
|
604
|
+
type: "confidential" as const,
|
|
605
|
+
redirectUris: ["https://cb"],
|
|
606
|
+
allowedScopes: ["openid"],
|
|
607
|
+
}));
|
|
608
|
+
const verifyClientSecret = vi.fn(async () => true);
|
|
609
|
+
const basic = btoa("client%3Aone:sec%3Aret%2F%2B");
|
|
610
|
+
|
|
611
|
+
const response = await tokenHandler(
|
|
612
|
+
{} as any,
|
|
613
|
+
new Request("https://example.com/oauth/token", {
|
|
614
|
+
method: "POST",
|
|
615
|
+
body: new URLSearchParams({
|
|
616
|
+
grant_type: "authorization_code",
|
|
617
|
+
code: "code",
|
|
618
|
+
redirect_uri: "https://cb",
|
|
619
|
+
code_verifier: "verifier",
|
|
620
|
+
}),
|
|
621
|
+
headers: {
|
|
622
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
623
|
+
"Authorization": `Basic ${basic}`,
|
|
624
|
+
},
|
|
625
|
+
}),
|
|
626
|
+
config,
|
|
627
|
+
makeApi({
|
|
628
|
+
queries: { getClient } as any,
|
|
629
|
+
clientManagement: { verifyClientSecret } as any,
|
|
630
|
+
})
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
expect(response.status).toBe(400);
|
|
634
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
635
|
+
error: "invalid_grant",
|
|
636
|
+
});
|
|
637
|
+
expect(getClient).toHaveBeenCalledWith(expect.anything(), { clientId: "client:one" });
|
|
638
|
+
expect(verifyClientSecret).toHaveBeenCalledWith(expect.anything(), {
|
|
639
|
+
clientId: "client:one",
|
|
640
|
+
clientSecret: "sec:ret/+",
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("token endpoint rejects client credentials for public clients", async () => {
|
|
645
|
+
const getClient = vi.fn(async (_ctx, { clientId }) => ({
|
|
646
|
+
clientId,
|
|
647
|
+
type: "public" as const,
|
|
648
|
+
redirectUris: ["https://cb"],
|
|
649
|
+
allowedScopes: ["openid"],
|
|
650
|
+
}));
|
|
651
|
+
const basic = btoa("public-client:unexpected-secret");
|
|
652
|
+
|
|
653
|
+
const response = await tokenHandler(
|
|
654
|
+
{} as any,
|
|
655
|
+
new Request("https://example.com/oauth/token", {
|
|
656
|
+
method: "POST",
|
|
657
|
+
body: new URLSearchParams({
|
|
658
|
+
grant_type: "authorization_code",
|
|
659
|
+
code: "code",
|
|
660
|
+
redirect_uri: "https://cb",
|
|
661
|
+
code_verifier: "verifier",
|
|
662
|
+
}),
|
|
663
|
+
headers: {
|
|
664
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
665
|
+
"Authorization": `Basic ${basic}`,
|
|
666
|
+
},
|
|
667
|
+
}),
|
|
668
|
+
config,
|
|
669
|
+
makeApi({ queries: { getClient } as any })
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
expect(response.status).toBe(401);
|
|
673
|
+
expect(response.headers.get("WWW-Authenticate")).toBe('Basic realm="oauth"');
|
|
674
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
675
|
+
error: "invalid_client",
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("discovery advertises all supported token endpoint auth methods", async () => {
|
|
680
|
+
const response = await openIdConfigurationHandler(
|
|
681
|
+
{} as any,
|
|
682
|
+
new Request("https://example.com/.well-known/openid-configuration"),
|
|
683
|
+
config
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
687
|
+
token_endpoint_auth_methods_supported: [
|
|
688
|
+
"client_secret_basic",
|
|
689
|
+
"client_secret_post",
|
|
690
|
+
"none",
|
|
691
|
+
],
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("DCR defaults token_endpoint_auth_method to client_secret_basic", async () => {
|
|
696
|
+
const registerClient = vi.fn(async () => ({
|
|
697
|
+
clientId: "client",
|
|
698
|
+
clientSecret: "secret",
|
|
699
|
+
clientIdIssuedAt: 0,
|
|
700
|
+
}));
|
|
701
|
+
|
|
702
|
+
const response = await registerHandler(
|
|
703
|
+
{} as any,
|
|
704
|
+
new Request("https://example.com/oauth/register", {
|
|
705
|
+
method: "POST",
|
|
706
|
+
body: JSON.stringify({
|
|
707
|
+
redirect_uris: ["https://client.example.com/cb"],
|
|
708
|
+
}),
|
|
709
|
+
headers: { "Content-Type": "application/json" },
|
|
710
|
+
}),
|
|
711
|
+
config,
|
|
712
|
+
makeApi({ clientManagement: { registerClient } as any })
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
expect(response.status).toBe(201);
|
|
716
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
717
|
+
token_endpoint_auth_method: "client_secret_basic",
|
|
718
|
+
client_secret: "secret",
|
|
719
|
+
});
|
|
720
|
+
expect(registerClient).toHaveBeenCalledWith(
|
|
721
|
+
expect.anything(),
|
|
722
|
+
expect.objectContaining({ type: "confidential" })
|
|
723
|
+
);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("DCR rejects unsafe metadata URLs before saving", async () => {
|
|
727
|
+
const registerClient = vi.fn();
|
|
728
|
+
|
|
729
|
+
const response = await registerHandler(
|
|
730
|
+
{} as any,
|
|
731
|
+
new Request("https://example.com/oauth/register", {
|
|
732
|
+
method: "POST",
|
|
733
|
+
body: JSON.stringify({
|
|
734
|
+
redirect_uris: ["https://client.example.com/cb"],
|
|
735
|
+
logo_uri: "javascript:alert(1)",
|
|
736
|
+
policy_uri: "https://client.example.com/policy#fragment",
|
|
737
|
+
}),
|
|
738
|
+
headers: { "Content-Type": "application/json" },
|
|
739
|
+
}),
|
|
740
|
+
config,
|
|
741
|
+
makeApi({ clientManagement: { registerClient } as any })
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
expect(response.status).toBe(400);
|
|
745
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
746
|
+
error: "invalid_client_metadata",
|
|
747
|
+
});
|
|
748
|
+
expect(registerClient).not.toHaveBeenCalled();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("DCR accepts https and loopback http metadata URLs", async () => {
|
|
752
|
+
const registerClient = vi.fn(async () => ({
|
|
753
|
+
clientId: "client",
|
|
754
|
+
clientSecret: "secret",
|
|
755
|
+
clientIdIssuedAt: 0,
|
|
756
|
+
}));
|
|
757
|
+
|
|
758
|
+
const response = await registerHandler(
|
|
759
|
+
{} as any,
|
|
760
|
+
new Request("https://example.com/oauth/register", {
|
|
761
|
+
method: "POST",
|
|
762
|
+
body: JSON.stringify({
|
|
763
|
+
redirect_uris: ["https://client.example.com/cb"],
|
|
764
|
+
logo_uri: "https://client.example.com/logo.png",
|
|
765
|
+
client_uri: "http://localhost:3000",
|
|
766
|
+
tos_uri: "http://127.0.0.1/tos",
|
|
767
|
+
policy_uri: "http://[::1]/policy",
|
|
768
|
+
}),
|
|
769
|
+
headers: { "Content-Type": "application/json" },
|
|
770
|
+
}),
|
|
771
|
+
config,
|
|
772
|
+
makeApi({ clientManagement: { registerClient } as any })
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
expect(response.status).toBe(201);
|
|
776
|
+
expect(registerClient).toHaveBeenCalledWith(
|
|
777
|
+
expect.anything(),
|
|
778
|
+
expect.objectContaining({
|
|
779
|
+
logoUrl: "https://client.example.com/logo.png",
|
|
780
|
+
website: "http://localhost:3000",
|
|
781
|
+
tosUrl: "http://127.0.0.1/tos",
|
|
782
|
+
policyUrl: "http://[::1]/policy",
|
|
783
|
+
})
|
|
784
|
+
);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("DCR rejects invalid redirect URIs with invalid_redirect_uri", async () => {
|
|
788
|
+
const registerClient = vi.fn();
|
|
789
|
+
|
|
790
|
+
const response = await registerHandler(
|
|
791
|
+
{} as any,
|
|
792
|
+
new Request("https://example.com/oauth/register", {
|
|
793
|
+
method: "POST",
|
|
794
|
+
body: JSON.stringify({
|
|
795
|
+
redirect_uris: ["http://client.example.com/cb"],
|
|
796
|
+
}),
|
|
797
|
+
headers: { "Content-Type": "application/json" },
|
|
798
|
+
}),
|
|
799
|
+
config,
|
|
800
|
+
makeApi({ clientManagement: { registerClient } as any })
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
expect(response.status).toBe(400);
|
|
804
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
805
|
+
error: "invalid_redirect_uri",
|
|
806
|
+
});
|
|
807
|
+
expect(registerClient).not.toHaveBeenCalled();
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("DCR accepts RFC8252 private-use redirect URIs and rejects unsafe variants", async () => {
|
|
811
|
+
const registerClient = vi.fn(async () => ({
|
|
812
|
+
clientId: "client",
|
|
813
|
+
clientIdIssuedAt: 0,
|
|
814
|
+
}));
|
|
815
|
+
|
|
816
|
+
const accepted = await registerHandler(
|
|
817
|
+
{} as any,
|
|
818
|
+
new Request("https://example.com/oauth/register", {
|
|
819
|
+
method: "POST",
|
|
820
|
+
body: JSON.stringify({
|
|
821
|
+
redirect_uris: ["com.example.app:/oauth2redirect"],
|
|
822
|
+
token_endpoint_auth_method: "none",
|
|
823
|
+
}),
|
|
824
|
+
headers: { "Content-Type": "application/json" },
|
|
825
|
+
}),
|
|
826
|
+
config,
|
|
827
|
+
makeApi({ clientManagement: { registerClient } as any })
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
expect(accepted.status).toBe(201);
|
|
831
|
+
expect(registerClient).toHaveBeenCalledWith(
|
|
832
|
+
expect.anything(),
|
|
833
|
+
expect.objectContaining({
|
|
834
|
+
redirectUris: ["com.example.app:/oauth2redirect"],
|
|
835
|
+
type: "public",
|
|
836
|
+
})
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
for (const redirectUri of [
|
|
840
|
+
"myapp:/oauth2redirect",
|
|
841
|
+
"com.example.app://oauth2redirect",
|
|
842
|
+
"com.example.app:/oauth2redirect#fragment",
|
|
843
|
+
]) {
|
|
844
|
+
const rejected = await registerHandler(
|
|
845
|
+
{} as any,
|
|
846
|
+
new Request("https://example.com/oauth/register", {
|
|
847
|
+
method: "POST",
|
|
848
|
+
body: JSON.stringify({
|
|
849
|
+
redirect_uris: [redirectUri],
|
|
850
|
+
token_endpoint_auth_method: "none",
|
|
851
|
+
}),
|
|
852
|
+
headers: { "Content-Type": "application/json" },
|
|
853
|
+
}),
|
|
854
|
+
config,
|
|
855
|
+
makeApi({ clientManagement: { registerClient } as any })
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
expect(rejected.status).toBe(400);
|
|
859
|
+
await expect(rejected.json()).resolves.toMatchObject({
|
|
860
|
+
error: "invalid_redirect_uri",
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("userinfo missing or non-Bearer credentials challenge without OAuth error", async () => {
|
|
866
|
+
const headerCases: HeadersInit[] = [new Headers(), { Authorization: "Basic abc" }];
|
|
867
|
+
for (const headers of headerCases) {
|
|
868
|
+
const response = await userInfoHandler(
|
|
869
|
+
{} as any,
|
|
870
|
+
new Request("https://example.com/oauth/userinfo", { headers }),
|
|
871
|
+
config,
|
|
872
|
+
async () => null
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
expect(response.status).toBe(401);
|
|
876
|
+
const challenge = response.headers.get("WWW-Authenticate");
|
|
877
|
+
expect(challenge).toBe('Bearer realm="userinfo"');
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
});
|