@ever-works/oidc-identity 1.0.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/dist/index.cjs +3303 -0
- package/dist/index.d.cts +1468 -0
- package/dist/index.d.ts +1468 -0
- package/dist/index.js +3244 -0
- package/dist/testing/fake-oidc-provider.cjs +651 -0
- package/dist/testing/fake-oidc-provider.d.cts +321 -0
- package/dist/testing/fake-oidc-provider.d.ts +321 -0
- package/dist/testing/fake-oidc-provider.js +622 -0
- package/package.json +88 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// src/testing/fake-oidc-provider.ts
|
|
2
|
+
import { createHash, createSign, generateKeyPairSync, randomBytes } from "crypto";
|
|
3
|
+
import { createServer } from "http";
|
|
4
|
+
var FAKE_BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
5
|
+
var FAKE_DEFAULT_LOCAL_CLIENT_ID = "ever-works-cli";
|
|
6
|
+
var FAKE_DEFAULT_USER = {
|
|
7
|
+
subject: "ever-id-subject-1",
|
|
8
|
+
email: "person@example.com",
|
|
9
|
+
emailVerified: true,
|
|
10
|
+
name: "A Person"
|
|
11
|
+
};
|
|
12
|
+
var FakeOidcProvider = class _FakeOidcProvider {
|
|
13
|
+
constructor(options) {
|
|
14
|
+
/** Every request, in order — method, path, query, form and headers. */
|
|
15
|
+
this.calls = [];
|
|
16
|
+
this.keys = [];
|
|
17
|
+
this.codes = /* @__PURE__ */ new Map();
|
|
18
|
+
this.devices = /* @__PURE__ */ new Map();
|
|
19
|
+
this.server = null;
|
|
20
|
+
this.issuerUrl = "";
|
|
21
|
+
this.keySequence = 0;
|
|
22
|
+
/** The person approved from now on, when a spec changed it after start (see {@link setUser}). */
|
|
23
|
+
this.userOverride = null;
|
|
24
|
+
/** Extra claims every ID token minted from now on carries (see {@link setIdTokenClaims}). */
|
|
25
|
+
this.extraIdTokenClaims = {};
|
|
26
|
+
/** The provider session id (`sid`) the next tokens carry (see {@link setSessionId}). */
|
|
27
|
+
this.sessionId = "ever-id-session-1";
|
|
28
|
+
this.options = options;
|
|
29
|
+
}
|
|
30
|
+
/** Bind a port and answer the discovery document a relying party will read. */
|
|
31
|
+
static async start(options = {}) {
|
|
32
|
+
const provider = new _FakeOidcProvider(options);
|
|
33
|
+
provider.rotateKeysSync({ keepPrevious: false });
|
|
34
|
+
await provider.listen();
|
|
35
|
+
return provider;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bind an ephemeral loopback port and answer `/…/.well-known/openid-configuration`.
|
|
39
|
+
*
|
|
40
|
+
* Kept additive and public so a caller that wants a provider on a **fixed** port
|
|
41
|
+
* (a Playwright lane that must tell the API where to find it before the process
|
|
42
|
+
* starts) can build one; `start()` is the ordinary entry point.
|
|
43
|
+
*/
|
|
44
|
+
async listen(port = 0) {
|
|
45
|
+
if (this.server !== null) throw new Error("FakeOidcProvider is already listening.");
|
|
46
|
+
const server = createServer((request, response) => {
|
|
47
|
+
void this.handle(request, response);
|
|
48
|
+
});
|
|
49
|
+
await new Promise((resolve, reject) => {
|
|
50
|
+
server.once("error", reject);
|
|
51
|
+
server.listen(port, "127.0.0.1", () => {
|
|
52
|
+
server.removeListener("error", reject);
|
|
53
|
+
resolve();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
this.server = server;
|
|
57
|
+
const address = server.address();
|
|
58
|
+
this.issuerUrl = `http://localhost:${typeof address === "object" && address !== null ? address.port : port}`;
|
|
59
|
+
}
|
|
60
|
+
/** The issuer the discovery document advertises, and the settings' `issuerUrl`. */
|
|
61
|
+
get issuer() {
|
|
62
|
+
return this.issuerUrl;
|
|
63
|
+
}
|
|
64
|
+
/** The bound port. */
|
|
65
|
+
get port() {
|
|
66
|
+
const address = this.server?.address();
|
|
67
|
+
return typeof address === "object" && address !== null ? address.port : 0;
|
|
68
|
+
}
|
|
69
|
+
get discoveryUrl() {
|
|
70
|
+
return `${this.issuer}/.well-known/openid-configuration`;
|
|
71
|
+
}
|
|
72
|
+
get jwksUri() {
|
|
73
|
+
return `${this.issuer}/jwks`;
|
|
74
|
+
}
|
|
75
|
+
get authorizationEndpoint() {
|
|
76
|
+
return `${this.issuer}/authorize`;
|
|
77
|
+
}
|
|
78
|
+
get tokenEndpoint() {
|
|
79
|
+
return `${this.issuer}/token`;
|
|
80
|
+
}
|
|
81
|
+
get endSessionEndpoint() {
|
|
82
|
+
return `${this.issuer}/end_session`;
|
|
83
|
+
}
|
|
84
|
+
get deviceAuthorizationEndpoint() {
|
|
85
|
+
return `${this.issuer}/device_authorization`;
|
|
86
|
+
}
|
|
87
|
+
/** The relying party's client id the token endpoint expects. */
|
|
88
|
+
get clientId() {
|
|
89
|
+
return this.options.clientId ?? "ever-works-web";
|
|
90
|
+
}
|
|
91
|
+
/** The `client_secret_basic` secret the token endpoint expects, or `null` for "any". */
|
|
92
|
+
get clientSecret() {
|
|
93
|
+
return this.options.clientSecret === void 0 ? "fake-client-secret" : this.options.clientSecret;
|
|
94
|
+
}
|
|
95
|
+
/** The API audience a minted access token carries. */
|
|
96
|
+
get apiAudience() {
|
|
97
|
+
return this.options.apiAudience ?? "ever-works";
|
|
98
|
+
}
|
|
99
|
+
/** The `azp` a minted access token carries when the caller does not say. */
|
|
100
|
+
get defaultAuthorizedParty() {
|
|
101
|
+
return this.options.localClients?.[0]?.clientId ?? FAKE_DEFAULT_LOCAL_CLIENT_ID;
|
|
102
|
+
}
|
|
103
|
+
/** The document this provider publishes, with the fields `omit` removed. */
|
|
104
|
+
discoveryDocument() {
|
|
105
|
+
const document = {
|
|
106
|
+
issuer: this.issuer,
|
|
107
|
+
authorization_endpoint: this.authorizationEndpoint,
|
|
108
|
+
token_endpoint: this.tokenEndpoint,
|
|
109
|
+
jwks_uri: this.jwksUri,
|
|
110
|
+
end_session_endpoint: this.endSessionEndpoint,
|
|
111
|
+
device_authorization_endpoint: this.deviceAuthorizationEndpoint,
|
|
112
|
+
userinfo_endpoint: `${this.issuer}/userinfo`,
|
|
113
|
+
code_challenge_methods_supported: ["S256"],
|
|
114
|
+
id_token_signing_alg_values_supported: ["ES256"],
|
|
115
|
+
backchannel_logout_supported: true,
|
|
116
|
+
backchannel_logout_session_supported: true,
|
|
117
|
+
response_types_supported: ["code"],
|
|
118
|
+
grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
|
|
119
|
+
subject_types_supported: ["public"],
|
|
120
|
+
scopes_supported: ["openid", "email", "profile", this.delegatedScope, this.exchangeScope]
|
|
121
|
+
};
|
|
122
|
+
for (const field of this.options.omit ?? []) delete document[field];
|
|
123
|
+
return document;
|
|
124
|
+
}
|
|
125
|
+
/** FR-44's delegated scope this provider mints. */
|
|
126
|
+
get delegatedScope() {
|
|
127
|
+
return this.options.delegatedScope ?? "apps:read";
|
|
128
|
+
}
|
|
129
|
+
/** FR-39/FR-40's exchange scope this provider mints. */
|
|
130
|
+
get exchangeScope() {
|
|
131
|
+
return this.options.exchangeScope ?? "ever-works:session";
|
|
132
|
+
}
|
|
133
|
+
/** The current clock, in epoch milliseconds. */
|
|
134
|
+
now() {
|
|
135
|
+
return (this.options.now ?? Date.now)();
|
|
136
|
+
}
|
|
137
|
+
/** The current clock, in whole seconds — the unit every JWT time claim uses. */
|
|
138
|
+
nowSeconds() {
|
|
139
|
+
return Math.floor(this.now() / 1e3);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Replace the signing key, the way a provider rotates one (FR-13, ACC-12-08).
|
|
143
|
+
*
|
|
144
|
+
* `keepPrevious` publishes the old key alongside the new one, which is what a
|
|
145
|
+
* rollover looks like while tokens signed with it are still in flight; the
|
|
146
|
+
* default is the harder case, where the old key is **gone** and a token signed
|
|
147
|
+
* with it must stop validating.
|
|
148
|
+
*/
|
|
149
|
+
async rotateKeys(options = {}) {
|
|
150
|
+
this.rotateKeysSync(options);
|
|
151
|
+
}
|
|
152
|
+
/** The current key id, so a spec can name what it is signing with. */
|
|
153
|
+
/**
|
|
154
|
+
* Change the person the provider approves from now on — so one running fake
|
|
155
|
+
* can sign in a connected identity, a new one and one whose e-mail an account
|
|
156
|
+
* already uses, in turn. Additive test seam; the constructor option still sets
|
|
157
|
+
* the first person.
|
|
158
|
+
*/
|
|
159
|
+
setUser(user) {
|
|
160
|
+
this.userOverride = user;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Extra claims every ID token minted from now on carries (for example the
|
|
164
|
+
* optional `urn:ever:` hint claims). Pass `{}` to stop adding any.
|
|
165
|
+
*/
|
|
166
|
+
setIdTokenClaims(claims) {
|
|
167
|
+
this.extraIdTokenClaims = { ...claims };
|
|
168
|
+
}
|
|
169
|
+
/** The provider session id (`sid`) the next ID tokens and logout tokens carry. */
|
|
170
|
+
setSessionId(sid) {
|
|
171
|
+
this.sessionId = sid;
|
|
172
|
+
}
|
|
173
|
+
/** The provider session id the next tokens carry. */
|
|
174
|
+
get currentSessionId() {
|
|
175
|
+
return this.sessionId;
|
|
176
|
+
}
|
|
177
|
+
get currentKeyId() {
|
|
178
|
+
return this.keys[this.keys.length - 1]?.kid ?? "";
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Sign an ID token for the configured person — what the token endpoint answers
|
|
182
|
+
* with, and what a spec can use to drive `exchangeAuthorizationCode` directly.
|
|
183
|
+
*/
|
|
184
|
+
async mintIdToken(overrides = {}) {
|
|
185
|
+
const user = this.user();
|
|
186
|
+
const issuedAt = this.nowSeconds();
|
|
187
|
+
return this.sign({
|
|
188
|
+
iss: this.issuer,
|
|
189
|
+
sub: user.subject,
|
|
190
|
+
aud: this.clientId,
|
|
191
|
+
exp: issuedAt + 300,
|
|
192
|
+
iat: issuedAt,
|
|
193
|
+
email: user.email,
|
|
194
|
+
email_verified: user.emailVerified ?? true,
|
|
195
|
+
name: user.name ?? "A Person",
|
|
196
|
+
auth_time: issuedAt,
|
|
197
|
+
sid: this.sessionId,
|
|
198
|
+
jti: randomId("id-token"),
|
|
199
|
+
...this.extraIdTokenClaims,
|
|
200
|
+
...overrides
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Sign an access token — the artifact FR-40's exchange and FR-45's delegated read
|
|
205
|
+
* are handed.
|
|
206
|
+
*
|
|
207
|
+
* The defaults are a **valid** token for the configured installation: `aud` is
|
|
208
|
+
* the API audience, `azp` is the first configured local client,
|
|
209
|
+
* `scope` is the exchange scope, `iat` is now and `exp` is now plus
|
|
210
|
+
* {@link FakeOidcProviderOptions.accessTokenLifetimeSeconds}. Every case in a
|
|
211
|
+
* spec is therefore "everything is valid, except this", and the literal numbers
|
|
212
|
+
* a case wants are passed in rather than derived from the plugin.
|
|
213
|
+
*/
|
|
214
|
+
async mintAccessToken(overrides = {}) {
|
|
215
|
+
const issuedAt = overrides.issuedAt ?? this.nowSeconds();
|
|
216
|
+
const claims = {
|
|
217
|
+
iss: this.issuer,
|
|
218
|
+
sub: overrides.subject ?? this.user().subject,
|
|
219
|
+
aud: overrides.audience ?? this.apiAudience,
|
|
220
|
+
scope: (overrides.scopes ?? [this.exchangeScope]).join(" "),
|
|
221
|
+
iat: issuedAt,
|
|
222
|
+
exp: overrides.expiresAt ?? issuedAt + (this.options.accessTokenLifetimeSeconds ?? 300),
|
|
223
|
+
jti: overrides.jti === void 0 ? randomId("jti") : overrides.jti,
|
|
224
|
+
...overrides.claims
|
|
225
|
+
};
|
|
226
|
+
if (overrides.authorizedParty !== null) {
|
|
227
|
+
claims.azp = overrides.authorizedParty ?? this.defaultAuthorizedParty;
|
|
228
|
+
}
|
|
229
|
+
return this.sign(claims);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Sign a back-channel logout notice (FR-33).
|
|
233
|
+
*
|
|
234
|
+
* The default is a **valid** notice about the configured person's session:
|
|
235
|
+
* the event member, an `iat` of now, a fresh `jti`, `sid` and no `nonce`, `exp`
|
|
236
|
+
* or `sub`. `extraClaims` is how a spec adds the claims FR-33 forbids (or
|
|
237
|
+
* removes the ones it requires), so every case reads as one deliberate change.
|
|
238
|
+
*/
|
|
239
|
+
async mintLogoutToken(overrides = {}) {
|
|
240
|
+
const issuedAt = overrides.issuedAt ?? this.nowSeconds();
|
|
241
|
+
const claims = {
|
|
242
|
+
iss: this.issuer,
|
|
243
|
+
aud: this.clientId,
|
|
244
|
+
iat: issuedAt,
|
|
245
|
+
jti: overrides.jti === void 0 ? randomId("logout") : overrides.jti,
|
|
246
|
+
events: overrides.events === void 0 ? { [FAKE_BACKCHANNEL_LOGOUT_EVENT]: {} } : overrides.events,
|
|
247
|
+
sid: overrides.sid === void 0 ? this.sessionId : overrides.sid,
|
|
248
|
+
...overrides.extraClaims
|
|
249
|
+
};
|
|
250
|
+
if (overrides.subject !== void 0 && overrides.subject !== null) claims.sub = overrides.subject;
|
|
251
|
+
if (overrides.expiresAt !== void 0 && overrides.expiresAt !== null) claims.exp = overrides.expiresAt;
|
|
252
|
+
return this.sign(claims);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Post a signed notice to a relying party's back-channel endpoint, the way the
|
|
256
|
+
* provider does (FR-33's "one public endpoint", form-encoded).
|
|
257
|
+
*
|
|
258
|
+
* Answers the status and the body text rather than throwing, so a spec can assert
|
|
259
|
+
* FR-34's "an invalid notice answers 400" — and FR-34's "200 with
|
|
260
|
+
* `Cache-Control: no-store`" — on a real HTTP exchange. A token is minted first
|
|
261
|
+
* when the caller does not pass one.
|
|
262
|
+
*/
|
|
263
|
+
async postBackchannelLogout(url, token, overrides = {}) {
|
|
264
|
+
const logoutToken = token ?? await this.mintLogoutToken(overrides);
|
|
265
|
+
const response = await fetch(url, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
268
|
+
body: new URLSearchParams({ logout_token: logoutToken }).toString()
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
status: response.status,
|
|
272
|
+
body: await response.text(),
|
|
273
|
+
cacheControl: response.headers.get("cache-control")
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Approve a pending device grant — what the person does in the browser at
|
|
278
|
+
* `verification_uri` (FR-39).
|
|
279
|
+
*
|
|
280
|
+
* With no argument every pending grant is approved, which is what a spec that has
|
|
281
|
+
* just asked for one wants; the `userCode` form is there for the case that reads
|
|
282
|
+
* the code off the wire.
|
|
283
|
+
*/
|
|
284
|
+
approveDeviceAuthorization(userCode) {
|
|
285
|
+
let approved = 0;
|
|
286
|
+
for (const grant of this.devices.values()) {
|
|
287
|
+
if (userCode !== void 0 && grant.userCode !== userCode) continue;
|
|
288
|
+
grant.approved = true;
|
|
289
|
+
approved += 1;
|
|
290
|
+
}
|
|
291
|
+
return approved;
|
|
292
|
+
}
|
|
293
|
+
/** Every device grant this provider has issued, approved or not. */
|
|
294
|
+
get deviceGrants() {
|
|
295
|
+
return [...this.devices.values()].map((grant) => ({
|
|
296
|
+
userCode: grant.userCode,
|
|
297
|
+
deviceCode: grant.deviceCode,
|
|
298
|
+
approved: grant.approved
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
/** The last authorization request's query, or `null` when none has been made. */
|
|
302
|
+
lastAuthorizationRequest() {
|
|
303
|
+
for (let index = this.calls.length - 1; index >= 0; index -= 1) {
|
|
304
|
+
const call = this.calls[index];
|
|
305
|
+
if (call.path === "/authorize") return call.query;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
/** Stop listening. Safe to call twice, and safe to call after a failed start. */
|
|
310
|
+
async stop() {
|
|
311
|
+
const server = this.server;
|
|
312
|
+
if (server === null) return;
|
|
313
|
+
this.server = null;
|
|
314
|
+
await new Promise((resolve, reject) => {
|
|
315
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
316
|
+
server.closeAllConnections();
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------- internals
|
|
320
|
+
user() {
|
|
321
|
+
return this.userOverride ?? this.options.user ?? FAKE_DEFAULT_USER;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Whether `clientId` is one of the PUBLIC local clients (CLI, node) — RFC 8628
|
|
325
|
+
* public clients authenticate with their `client_id` alone, no secret.
|
|
326
|
+
*/
|
|
327
|
+
isLocalClient(clientId) {
|
|
328
|
+
if (clientId === void 0 || clientId === "") return false;
|
|
329
|
+
const configured = this.options.localClients ?? [
|
|
330
|
+
{ kind: "cli", clientId: FAKE_DEFAULT_LOCAL_CLIENT_ID }
|
|
331
|
+
];
|
|
332
|
+
return configured.some((client) => client.clientId === clientId);
|
|
333
|
+
}
|
|
334
|
+
rotateKeysSync(options) {
|
|
335
|
+
this.keySequence += 1;
|
|
336
|
+
const key = makeSigningKey(`fake-key-${this.keySequence}`);
|
|
337
|
+
if (options.keepPrevious !== true) this.keys.length = 0;
|
|
338
|
+
this.keys.push(key);
|
|
339
|
+
}
|
|
340
|
+
currentKey() {
|
|
341
|
+
const key = this.keys[this.keys.length - 1];
|
|
342
|
+
if (key === void 0) throw new Error("FakeOidcProvider has no signing key.");
|
|
343
|
+
return key;
|
|
344
|
+
}
|
|
345
|
+
sign(payload) {
|
|
346
|
+
return signCompactJws(this.currentKey(), payload);
|
|
347
|
+
}
|
|
348
|
+
async handle(request, response) {
|
|
349
|
+
const url = new URL(request.url ?? "/", this.issuer || "http://localhost");
|
|
350
|
+
const form = await readForm(request);
|
|
351
|
+
const call = {
|
|
352
|
+
method: request.method ?? "GET",
|
|
353
|
+
path: url.pathname,
|
|
354
|
+
query: Object.fromEntries(url.searchParams.entries()),
|
|
355
|
+
form,
|
|
356
|
+
headers: Object.fromEntries(
|
|
357
|
+
Object.entries(request.headers).flatMap(
|
|
358
|
+
([name, value]) => value === void 0 ? [] : [[name.toLowerCase(), Array.isArray(value) ? value.join(", ") : value]]
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
};
|
|
362
|
+
this.calls.push(call);
|
|
363
|
+
try {
|
|
364
|
+
switch (url.pathname) {
|
|
365
|
+
case "/.well-known/openid-configuration":
|
|
366
|
+
return sendJson(response, 200, this.discoveryDocument());
|
|
367
|
+
case "/jwks":
|
|
368
|
+
return sendJson(response, 200, { keys: this.keys.map((key) => key.jwk) });
|
|
369
|
+
case "/authorize":
|
|
370
|
+
return this.handleAuthorize(call, response);
|
|
371
|
+
case "/token":
|
|
372
|
+
return this.handleToken(call, response);
|
|
373
|
+
case "/device_authorization":
|
|
374
|
+
return this.handleDeviceAuthorization(call, response);
|
|
375
|
+
case "/end_session":
|
|
376
|
+
return this.handleEndSession(call, response);
|
|
377
|
+
case "/userinfo":
|
|
378
|
+
return sendJson(response, 200, { sub: this.user().subject, email: this.user().email });
|
|
379
|
+
default:
|
|
380
|
+
return sendJson(response, 404, { error: "not_found" });
|
|
381
|
+
}
|
|
382
|
+
} catch (error) {
|
|
383
|
+
return sendJson(response, 500, { error: "fake_provider_error", error_description: String(error) });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** §10.4's "authorize (auto-approve a configured user)", with S256 required. */
|
|
387
|
+
handleAuthorize(call, response) {
|
|
388
|
+
const { query } = call;
|
|
389
|
+
if (query.response_type !== "code") {
|
|
390
|
+
return sendJson(response, 400, { error: "unsupported_response_type" });
|
|
391
|
+
}
|
|
392
|
+
if (query.code_challenge_method !== "S256" || (query.code_challenge ?? "") === "") {
|
|
393
|
+
return sendJson(response, 400, { error: "invalid_request", error_description: "PKCE S256 is required" });
|
|
394
|
+
}
|
|
395
|
+
if (query.client_id !== this.clientId) {
|
|
396
|
+
return sendJson(response, 400, { error: "invalid_request", error_description: "unknown client" });
|
|
397
|
+
}
|
|
398
|
+
const redirectUri = query.redirect_uri ?? "";
|
|
399
|
+
if (redirectUri === "") {
|
|
400
|
+
return sendJson(response, 400, { error: "invalid_request", error_description: "redirect_uri is required" });
|
|
401
|
+
}
|
|
402
|
+
const code = randomId("code");
|
|
403
|
+
this.codes.set(code, {
|
|
404
|
+
code,
|
|
405
|
+
clientId: query.client_id,
|
|
406
|
+
redirectUri,
|
|
407
|
+
codeChallenge: query.code_challenge ?? "",
|
|
408
|
+
nonce: query.nonce ?? null,
|
|
409
|
+
scope: query.scope ?? "openid email profile",
|
|
410
|
+
expiresAtSeconds: this.nowSeconds() + (this.options.authorizationCodeLifetimeSeconds ?? 60)
|
|
411
|
+
});
|
|
412
|
+
const target = new URL(redirectUri);
|
|
413
|
+
target.searchParams.set("code", code);
|
|
414
|
+
if (query.state !== void 0) target.searchParams.set("state", query.state);
|
|
415
|
+
target.searchParams.set("iss", this.issuer);
|
|
416
|
+
response.writeHead(302, { location: target.toString(), "cache-control": "no-store" });
|
|
417
|
+
response.end();
|
|
418
|
+
}
|
|
419
|
+
/** RFC 6749 §4.1.3 and RFC 8628 §3.4–3.5: two grants, both checked. */
|
|
420
|
+
handleToken(call, response) {
|
|
421
|
+
const { form } = call;
|
|
422
|
+
const publicDeviceClient = form.grant_type === "urn:ietf:params:oauth:grant-type:device_code" && this.isLocalClient(form.client_id);
|
|
423
|
+
if (this.clientSecret !== null && !publicDeviceClient && !this.credentialsAccepted(call)) {
|
|
424
|
+
return sendJson(response, 401, { error: "invalid_client" });
|
|
425
|
+
}
|
|
426
|
+
if (form.grant_type === "authorization_code") return this.handleAuthorizationCodeGrant(form, response);
|
|
427
|
+
if (form.grant_type === "urn:ietf:params:oauth:grant-type:device_code") {
|
|
428
|
+
return this.handleDeviceCodeGrant(form, response);
|
|
429
|
+
}
|
|
430
|
+
return sendJson(response, 400, { error: "unsupported_grant_type" });
|
|
431
|
+
}
|
|
432
|
+
handleAuthorizationCodeGrant(form, response) {
|
|
433
|
+
const stored = this.codes.get(form.code ?? "");
|
|
434
|
+
if (stored === void 0) {
|
|
435
|
+
return sendJson(response, 400, { error: "invalid_grant", error_description: "unknown code" });
|
|
436
|
+
}
|
|
437
|
+
this.codes.delete(stored.code);
|
|
438
|
+
if (stored.expiresAtSeconds < this.nowSeconds()) {
|
|
439
|
+
return sendJson(response, 400, { error: "invalid_grant", error_description: "expired code" });
|
|
440
|
+
}
|
|
441
|
+
if (form.redirect_uri !== stored.redirectUri) {
|
|
442
|
+
return sendJson(response, 400, { error: "invalid_grant", error_description: "redirect_uri mismatch" });
|
|
443
|
+
}
|
|
444
|
+
if (s256(form.code_verifier ?? "") !== stored.codeChallenge) {
|
|
445
|
+
return sendJson(response, 400, { error: "invalid_grant", error_description: "PKCE verification failed" });
|
|
446
|
+
}
|
|
447
|
+
void this.tokensFor(stored.nonce, stored.scope).then(
|
|
448
|
+
(tokens) => sendJson(response, 200, tokens),
|
|
449
|
+
(error) => sendJson(response, 500, { error: "server_error", error_description: String(error) })
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
handleDeviceCodeGrant(form, response) {
|
|
453
|
+
const grant = this.devices.get(form.device_code ?? "");
|
|
454
|
+
if (grant === void 0) {
|
|
455
|
+
return sendJson(response, 400, { error: "invalid_grant", error_description: "unknown device code" });
|
|
456
|
+
}
|
|
457
|
+
const nowSeconds = this.nowSeconds();
|
|
458
|
+
if (grant.expiresAtSeconds < nowSeconds) {
|
|
459
|
+
this.devices.delete(grant.deviceCode);
|
|
460
|
+
return sendJson(response, 400, { error: "expired_token" });
|
|
461
|
+
}
|
|
462
|
+
if (grant.lastPolledAtSeconds !== null && nowSeconds - grant.lastPolledAtSeconds < grant.intervalSeconds) {
|
|
463
|
+
grant.lastPolledAtSeconds = nowSeconds;
|
|
464
|
+
return sendJson(response, 400, { error: "slow_down" });
|
|
465
|
+
}
|
|
466
|
+
grant.lastPolledAtSeconds = nowSeconds;
|
|
467
|
+
if (!grant.approved) return sendJson(response, 400, { error: "authorization_pending" });
|
|
468
|
+
this.devices.delete(grant.deviceCode);
|
|
469
|
+
const authorizedParty = this.isLocalClient(grant.clientId) ? grant.clientId : this.clientId;
|
|
470
|
+
void this.tokensFor(null, grant.scope, authorizedParty).then(
|
|
471
|
+
(tokens) => sendJson(response, 200, tokens),
|
|
472
|
+
(error) => sendJson(response, 500, { error: "server_error", error_description: String(error) })
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
/** RFC 8628 §3.2's device authorization response. */
|
|
476
|
+
handleDeviceAuthorization(call, response) {
|
|
477
|
+
if (this.clientSecret !== null && !this.isLocalClient(call.form.client_id) && !this.credentialsAccepted(call)) {
|
|
478
|
+
return sendJson(response, 401, { error: "invalid_client" });
|
|
479
|
+
}
|
|
480
|
+
const intervalSeconds = this.options.devicePollIntervalSeconds ?? 5;
|
|
481
|
+
const grant = {
|
|
482
|
+
deviceCode: randomId("device"),
|
|
483
|
+
userCode: randomUserCode(),
|
|
484
|
+
clientId: call.form.client_id ?? this.clientId,
|
|
485
|
+
scope: call.form.scope ?? "openid email profile",
|
|
486
|
+
expiresAtSeconds: this.nowSeconds() + (this.options.deviceCodeLifetimeSeconds ?? 900),
|
|
487
|
+
intervalSeconds,
|
|
488
|
+
approved: false,
|
|
489
|
+
lastPolledAtSeconds: null
|
|
490
|
+
};
|
|
491
|
+
this.devices.set(grant.deviceCode, grant);
|
|
492
|
+
return sendJson(response, 200, {
|
|
493
|
+
device_code: grant.deviceCode,
|
|
494
|
+
user_code: grant.userCode,
|
|
495
|
+
verification_uri: `${this.issuer}/device`,
|
|
496
|
+
verification_uri_complete: `${this.issuer}/device?user_code=${grant.userCode}`,
|
|
497
|
+
expires_in: this.options.deviceCodeLifetimeSeconds ?? 900,
|
|
498
|
+
interval: intervalSeconds
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
/** RP-Initiated Logout 1.0 §2: back to the relying party, with `state` preserved. */
|
|
502
|
+
handleEndSession(call, response) {
|
|
503
|
+
const redirectUri = call.query.post_logout_redirect_uri;
|
|
504
|
+
if (redirectUri === void 0 || redirectUri === "") {
|
|
505
|
+
return sendJson(response, 200, { signedOut: true });
|
|
506
|
+
}
|
|
507
|
+
const target = new URL(redirectUri);
|
|
508
|
+
if (call.query.state !== void 0) target.searchParams.set("state", call.query.state);
|
|
509
|
+
response.writeHead(302, { location: target.toString(), "cache-control": "no-store" });
|
|
510
|
+
response.end();
|
|
511
|
+
}
|
|
512
|
+
async tokensFor(nonce, scope, authorizedParty = this.clientId) {
|
|
513
|
+
const user = this.user();
|
|
514
|
+
const idToken = await this.mintIdToken({ nonce, sid: this.sessionId, sub: user.subject });
|
|
515
|
+
const accessToken = await this.mintAccessToken({
|
|
516
|
+
scopes: scope.split(/\s+/u).filter((entry) => entry.length > 0),
|
|
517
|
+
authorizedParty
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
access_token: accessToken,
|
|
521
|
+
id_token: idToken,
|
|
522
|
+
token_type: "Bearer",
|
|
523
|
+
expires_in: this.options.accessTokenLifetimeSeconds ?? 300,
|
|
524
|
+
scope
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/** `client_secret_basic` (RFC 6749 §2.3.1), which is what plan §4.2 registers. */
|
|
528
|
+
credentialsAccepted(call) {
|
|
529
|
+
const header = call.headers.authorization;
|
|
530
|
+
if (header === void 0 || !header.toLowerCase().startsWith("basic ")) return false;
|
|
531
|
+
const raw = header.slice("basic ".length);
|
|
532
|
+
const decoded = Buffer.from(raw, "base64").toString("utf8");
|
|
533
|
+
const separator = decoded.indexOf(":");
|
|
534
|
+
if (separator < 0) return false;
|
|
535
|
+
const username = decoded.slice(0, separator);
|
|
536
|
+
const decodedUsername = decodeURIComponent(username.replace(/\+/gu, " "));
|
|
537
|
+
const password = decodeURIComponent(decoded.slice(separator + 1).replace(/\+/gu, " "));
|
|
538
|
+
const usernameMatches = username === this.clientId || decodedUsername === this.clientId;
|
|
539
|
+
return usernameMatches && password === (this.clientSecret ?? "");
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
function s256(codeVerifier) {
|
|
543
|
+
return createHash("sha256").update(codeVerifier, "ascii").digest("base64url");
|
|
544
|
+
}
|
|
545
|
+
function makeSigningKey(kid) {
|
|
546
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
547
|
+
const jwk = publicKey.export({ format: "jwk" });
|
|
548
|
+
return { kid, jwk: { ...jwk, kid, alg: "ES256", use: "sig" }, privateKey };
|
|
549
|
+
}
|
|
550
|
+
function signCompactJws(key, payload) {
|
|
551
|
+
const header = { alg: "ES256", kid: key.kid, typ: "JWT" };
|
|
552
|
+
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`;
|
|
553
|
+
const signature = createSign("SHA256").update(signingInput).sign(key.privateKey);
|
|
554
|
+
return `${signingInput}.${base64Url(derToJoseSignature(signature))}`;
|
|
555
|
+
}
|
|
556
|
+
function derToJoseSignature(der, partLength = 32) {
|
|
557
|
+
if (der.length < 8 || der[0] !== 48) throw new Error("unexpected ECDSA signature encoding");
|
|
558
|
+
let offset = 1;
|
|
559
|
+
if (der[offset] !== void 0 && (der[offset] & 128) !== 0) {
|
|
560
|
+
offset += 1 + (der[offset] & 127);
|
|
561
|
+
} else {
|
|
562
|
+
offset += 1;
|
|
563
|
+
}
|
|
564
|
+
const readInteger = () => {
|
|
565
|
+
if (der[offset] !== 2) throw new Error("unexpected ECDSA signature encoding");
|
|
566
|
+
const length = der[offset + 1];
|
|
567
|
+
if (length === void 0) throw new Error("unexpected ECDSA signature encoding");
|
|
568
|
+
const value = der.subarray(offset + 2, offset + 2 + length);
|
|
569
|
+
offset += 2 + length;
|
|
570
|
+
return value;
|
|
571
|
+
};
|
|
572
|
+
const r = readInteger();
|
|
573
|
+
const s = readInteger();
|
|
574
|
+
return Buffer.concat([leftPadUnsigned(r, partLength), leftPadUnsigned(s, partLength)]);
|
|
575
|
+
}
|
|
576
|
+
function leftPadUnsigned(value, length) {
|
|
577
|
+
let start = 0;
|
|
578
|
+
while (start < value.length - 1 && value[start] === 0) start += 1;
|
|
579
|
+
const trimmed = value.subarray(start);
|
|
580
|
+
if (trimmed.length > length) throw new Error("ECDSA coordinate wider than the curve");
|
|
581
|
+
return Buffer.concat([Buffer.alloc(length - trimmed.length, 0), trimmed]);
|
|
582
|
+
}
|
|
583
|
+
function base64Url(value) {
|
|
584
|
+
return Buffer.from(value).toString("base64url");
|
|
585
|
+
}
|
|
586
|
+
function randomId(prefix) {
|
|
587
|
+
return `${prefix}-${randomBytes(12).toString("hex")}`;
|
|
588
|
+
}
|
|
589
|
+
function randomUserCode() {
|
|
590
|
+
const alphabet = "BCDFGHJKLMNPQRSTVWXZ";
|
|
591
|
+
const bytes = randomBytes(8);
|
|
592
|
+
let code = "";
|
|
593
|
+
for (let index = 0; index < 8; index += 1) {
|
|
594
|
+
if (index === 4) code += "-";
|
|
595
|
+
code += alphabet[bytes[index] % alphabet.length];
|
|
596
|
+
}
|
|
597
|
+
return code;
|
|
598
|
+
}
|
|
599
|
+
function sendJson(response, status, body) {
|
|
600
|
+
const payload = JSON.stringify(body);
|
|
601
|
+
response.writeHead(status, {
|
|
602
|
+
"content-type": "application/json",
|
|
603
|
+
"content-length": Buffer.byteLength(payload),
|
|
604
|
+
"cache-control": "no-store"
|
|
605
|
+
});
|
|
606
|
+
response.end(payload);
|
|
607
|
+
}
|
|
608
|
+
async function readForm(request) {
|
|
609
|
+
if (request.method !== "POST") return {};
|
|
610
|
+
const chunks = [];
|
|
611
|
+
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
612
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
613
|
+
if (body === "") return {};
|
|
614
|
+
return Object.fromEntries(new URLSearchParams(body).entries());
|
|
615
|
+
}
|
|
616
|
+
export {
|
|
617
|
+
FAKE_BACKCHANNEL_LOGOUT_EVENT,
|
|
618
|
+
FAKE_DEFAULT_LOCAL_CLIENT_ID,
|
|
619
|
+
FAKE_DEFAULT_USER,
|
|
620
|
+
FakeOidcProvider,
|
|
621
|
+
s256
|
|
622
|
+
};
|