@hyper.authio/testing 0.7.0 → 0.8.1
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 +19 -3
- package/dist/index.cjs +171 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +87 -10
- package/dist/index.d.ts +87 -10
- package/dist/index.js +172 -55
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -33,7 +33,8 @@ await authio.close();
|
|
|
33
33
|
UserInfo (JSON, or a signed JWT with `userInfoAsJwt`), device authorization and end session.
|
|
34
34
|
- Sign-in: `authorize(url)` follows an authorization URL as a browser would and returns the callback URL (or, for
|
|
35
35
|
`response_mode=web_message`, the posted parameters). It signs in `signInAs`, the `login_hint` or the first user;
|
|
36
|
-
honours PKCE (S256, plain), `prompt` (`none` answers `login_required` without a
|
|
36
|
+
honours PKCE (S256, plain; `code_challenge_method` is required), `prompt` (`none` answers `login_required` without a
|
|
37
|
+
session, `login` signs in again),
|
|
37
38
|
`max_age`, `acr_values`, and sends RFC 9207 `iss` (`omitAuthorizationIssuer` leaves it out). ID tokens carry `nonce`,
|
|
38
39
|
`sid`, `auth_time`, `acr`, `amr` and `at_hash`. `createAuthorizationCode` still mints a code directly.
|
|
39
40
|
- Sign-out: the end-session endpoint checks `post_logout_redirect_uri`, echoes `state`, and posts a logout token to the
|
|
@@ -45,8 +46,23 @@ await authio.close();
|
|
|
45
46
|
- JWE: a client with `encryption: { key, algorithm }` (RSA-OAEP or RSA-OAEP-256) gets its ID tokens, UserInfo, and
|
|
46
47
|
optionally access and logout tokens encrypted; `issueAccessToken({ encryptTo })` mints one directly.
|
|
47
48
|
- Personal access tokens: `personalAccessTokens: [{ token: "pat_…", sub, clientId? }]`.
|
|
48
|
-
-
|
|
49
|
-
|
|
49
|
+
- Clients are configured as in Authio's console, and the fake enforces it as Authio does, refusing with an error that
|
|
50
|
+
names the setting and accepting what the client does not require:
|
|
51
|
+
- `tokenEndpointAuthMethod`: `client_secret_basic` (default with `clientSecret`), `client_secret_post`,
|
|
52
|
+
`private_key_jwt` (default with only `publicKey`) or `none` (default with neither), matched exactly
|
|
53
|
+
(`invalid_client`); a secret sent by a `none` client is ignored.
|
|
54
|
+
- `requirePkce` (default `true`, as Authio creates clients) refuses an authorization request without
|
|
55
|
+
`code_challenge`: declare `requirePkce: false` for a client with PKCE off. `pkceMethods` (default `["S256"]`)
|
|
56
|
+
refuses another method even when PKCE is not required; PKCE sent to a client that does not require it is checked
|
|
57
|
+
and accepted.
|
|
58
|
+
- `signingAlgorithm`: `RS256` (default) or `HS256`/`HS384`/`HS512` with the client secret, for the client's ID
|
|
59
|
+
tokens, UserInfo and logout tokens; `accessTokenSigningAlgorithm` the same for its access tokens, as Authio keeps
|
|
60
|
+
two settings. The secret needs at least 32, 48 or 64 bytes (RFC 7518 §3.2), or the client is refused.
|
|
61
|
+
- `issueIdTokenOnRefresh` adds a new ID token to every refresh.
|
|
62
|
+
- `frontchannelLogoutUri` (with `frontchannelLogoutSessionRequired` for `iss` and `sid`) is loaded when a session of
|
|
63
|
+
the client ends (`frontchannelDeliveries`); `frontchannelLogoutUrl` and `deliverFrontchannelLogout` do it by hand.
|
|
64
|
+
- `response_mode` other than `query` and `web_message` is refused.
|
|
65
|
+
- `clock` (such as the `ManualClock` the library uses) is the time tokens are issued and checked with.
|
|
50
66
|
- `issueAccessToken` options: `expiresIn` (negative for an expired token), `type` (`ID` for a non-access token),
|
|
51
67
|
`issuer`, `untrusted` (a signature that does not verify).
|
|
52
68
|
- `failures.set("token", [{ status: 503 }])` answers the next calls to an endpoint with errors; `deviceOutcomes`
|
package/dist/index.cjs
CHANGED
|
@@ -13,6 +13,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
13
13
|
failures = /* @__PURE__ */ new Map();
|
|
14
14
|
/** Every back-channel logout token posted, in order. */
|
|
15
15
|
backchannelDeliveries = [];
|
|
16
|
+
/** Every front-channel logout page loaded, in order. */
|
|
17
|
+
frontchannelDeliveries = [];
|
|
16
18
|
/** What successive device-code polls answer; the last value repeats. Defaults to `["approved"]`. */
|
|
17
19
|
deviceOutcomes = ["approved"];
|
|
18
20
|
/** Overrides the discovery document's issuer, to test the mismatch check. */
|
|
@@ -33,6 +35,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
33
35
|
issued = /* @__PURE__ */ new Map();
|
|
34
36
|
devicePolls = /* @__PURE__ */ new Map();
|
|
35
37
|
lifetime;
|
|
38
|
+
clock;
|
|
36
39
|
session;
|
|
37
40
|
server;
|
|
38
41
|
signingKey;
|
|
@@ -45,7 +48,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
45
48
|
constructor(options) {
|
|
46
49
|
this.realm = options.realm ?? "fake";
|
|
47
50
|
this.lifetime = options.accessTokenLifetime ?? 300;
|
|
48
|
-
|
|
51
|
+
this.clock = options.clock ?? { now: () => Date.now() };
|
|
52
|
+
for (const client of options.clients ?? []) this.clients.set(client.clientId, checkClient(client));
|
|
49
53
|
for (const user of options.users ?? []) this.users.set(user.username, user);
|
|
50
54
|
for (const pat of options.personalAccessTokens ?? []) this.personalAccessTokens.set(pat.token, pat);
|
|
51
55
|
}
|
|
@@ -75,7 +79,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
75
79
|
}
|
|
76
80
|
/** Registers a client. */
|
|
77
81
|
addClient(client) {
|
|
78
|
-
this.clients.set(client.clientId, client);
|
|
82
|
+
this.clients.set(client.clientId, checkClient(client));
|
|
79
83
|
}
|
|
80
84
|
/** Registers a person. */
|
|
81
85
|
addUser(user) {
|
|
@@ -126,7 +130,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
126
130
|
* Mints an access token signed by the realm key, recorded so introspection and revocation know it.
|
|
127
131
|
*/
|
|
128
132
|
async issueAccessToken(options = {}) {
|
|
129
|
-
const now =
|
|
133
|
+
const now = this.nowSeconds();
|
|
130
134
|
const clientId = options.clientId ?? "fake-client";
|
|
131
135
|
const jti = crypto.randomUUID();
|
|
132
136
|
const payload = {
|
|
@@ -137,7 +141,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
137
141
|
...options.sub ? { sub: options.sub } : {},
|
|
138
142
|
...options.claims
|
|
139
143
|
};
|
|
140
|
-
const
|
|
144
|
+
const builder = new jose.SignJWT(payload).setIssuer(options.issuer ?? this.issuer).setAudience(options.audience ?? clientId).setIssuedAt(now).setNotBefore(now).setExpirationTime(now + (options.expiresIn ?? this.lifetime)).setJti(jti);
|
|
145
|
+
const signed = options.untrusted ? await builder.setProtectedHeader({ alg: "RS256", kid: this.keyId, typ: "JWT" }).sign(this.untrustedKey) : await this.sign(builder, this.clients.get(clientId), "JWT", this.clients.get(clientId)?.accessTokenSigningAlgorithm ?? "RS256");
|
|
141
146
|
const token = options.encryptTo ? await encryptTo(signed, options.encryptTo) : signed;
|
|
142
147
|
this.issued.set(token, { claims: jose.decodeJwt(signed), clientId, revoked: false });
|
|
143
148
|
return token;
|
|
@@ -147,7 +152,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
147
152
|
* removes one) and `type` the header `typ`, to build invalid ones in tests.
|
|
148
153
|
*/
|
|
149
154
|
async createLogoutToken(options) {
|
|
150
|
-
const now =
|
|
155
|
+
const now = this.nowSeconds();
|
|
151
156
|
const payload = {
|
|
152
157
|
iss: this.issuer,
|
|
153
158
|
aud: options.clientId,
|
|
@@ -160,7 +165,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
160
165
|
...options.overrides
|
|
161
166
|
};
|
|
162
167
|
for (const key of Object.keys(payload)) if (payload[key] === void 0) delete payload[key];
|
|
163
|
-
const signed = await new jose.SignJWT(payload)
|
|
168
|
+
const signed = await this.sign(new jose.SignJWT(payload), this.clients.get(options.clientId), options.type ?? "logout+jwt");
|
|
164
169
|
const encryption = this.clients.get(options.clientId)?.encryption;
|
|
165
170
|
return encryption?.logoutTokens ? encryptTo(signed, encryption) : signed;
|
|
166
171
|
}
|
|
@@ -188,6 +193,38 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
188
193
|
this.backchannelDeliveries.push({ clientId, uri: client.backchannelLogoutUri, logoutToken, status });
|
|
189
194
|
return status;
|
|
190
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* The front-channel logout URL of a client for a session: its `frontchannelLogoutUri`, with `iss` and `sid` when the
|
|
198
|
+
* client requires the session.
|
|
199
|
+
*/
|
|
200
|
+
frontchannelLogoutUrl(clientId, sid) {
|
|
201
|
+
const client = this.clients.get(clientId);
|
|
202
|
+
if (!client?.frontchannelLogoutUri) throw new Error(`The fake client ${clientId} has no frontchannelLogoutUri.`);
|
|
203
|
+
const url = new URL(client.frontchannelLogoutUri);
|
|
204
|
+
if (client.frontchannelLogoutSessionRequired) {
|
|
205
|
+
url.searchParams.set("iss", this.issuer);
|
|
206
|
+
url.searchParams.set("sid", sid);
|
|
207
|
+
}
|
|
208
|
+
return url.toString();
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Loads the client's front-channel logout page, as the browser does in an iframe when Authio ends a session.
|
|
212
|
+
*
|
|
213
|
+
* @returns The HTTP status the client answered, or 0 when it could not be reached.
|
|
214
|
+
*/
|
|
215
|
+
async deliverFrontchannelLogout(clientId, options) {
|
|
216
|
+
const url = this.frontchannelLogoutUrl(clientId, options.sid);
|
|
217
|
+
let status = 0;
|
|
218
|
+
try {
|
|
219
|
+
const response = await fetch(url, { headers: options.headers, redirect: "manual" });
|
|
220
|
+
status = response.status;
|
|
221
|
+
await response.arrayBuffer();
|
|
222
|
+
} catch {
|
|
223
|
+
status = 0;
|
|
224
|
+
}
|
|
225
|
+
this.frontchannelDeliveries.push({ clientId, url, status });
|
|
226
|
+
return status;
|
|
227
|
+
}
|
|
191
228
|
/** Marks a token revoked, as Authio would after a sign-out: an access token, or a refresh token, which is then forgotten. */
|
|
192
229
|
revokeToken(token) {
|
|
193
230
|
const issued = this.issued.get(token);
|
|
@@ -304,6 +341,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
304
341
|
acr_values_supported: ["1", "2", "3"],
|
|
305
342
|
backchannel_logout_supported: true,
|
|
306
343
|
backchannel_logout_session_supported: true,
|
|
344
|
+
frontchannel_logout_supported: true,
|
|
345
|
+
frontchannel_logout_session_supported: true,
|
|
307
346
|
authorization_response_iss_parameter_supported: !this.omitAuthorizationIssuer
|
|
308
347
|
};
|
|
309
348
|
}
|
|
@@ -329,11 +368,28 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
329
368
|
response.writeHead(302, { location: location.toString() });
|
|
330
369
|
response.end();
|
|
331
370
|
};
|
|
371
|
+
if (query.response_mode !== void 0 && query.response_mode !== "query" && query.response_mode !== "web_message")
|
|
372
|
+
return answer({ error: "invalid_request", error_description: `The response_mode '${query.response_mode}' is not supported: Authio answers with query or web_message.` });
|
|
332
373
|
if (query.response_type !== "code") return answer({ error: "unsupported_response_type", error_description: "Only code is supported." });
|
|
333
|
-
const method = query.
|
|
334
|
-
|
|
374
|
+
const method = query.code_challenge ? query.code_challenge_method : void 0;
|
|
375
|
+
const allowedMethods = client.pkceMethods ?? ["S256"];
|
|
376
|
+
if (query.code_challenge) {
|
|
377
|
+
if (!method)
|
|
378
|
+
return answer({ error: "invalid_request", error_description: "code_challenge_method is required with code_challenge: Authio does not assume plain." });
|
|
379
|
+
if (method !== "S256" && method !== "plain") return answer({ error: "invalid_request", error_description: `The PKCE method '${method}' is not supported.` });
|
|
380
|
+
if (!allowedMethods.includes(method))
|
|
381
|
+
return answer({
|
|
382
|
+
error: "invalid_request",
|
|
383
|
+
error_description: `The PKCE method '${method}' is not allowed for the client ${client.clientId}, which allows ${allowedMethods.join(", ")} (codeChallengeMethod).`
|
|
384
|
+
});
|
|
385
|
+
} else if (client.requirePkce ?? true) {
|
|
386
|
+
return answer({
|
|
387
|
+
error: "invalid_request",
|
|
388
|
+
error_description: `The client ${client.clientId} requires PKCE: send code_challenge and code_challenge_method (checks: ["pkce"]).`
|
|
389
|
+
});
|
|
390
|
+
}
|
|
335
391
|
const prompts = (query.prompt ?? "").split(" ").filter(Boolean);
|
|
336
|
-
const now =
|
|
392
|
+
const now = this.nowSeconds();
|
|
337
393
|
const maxAge = query.max_age !== void 0 ? Number(query.max_age) : void 0;
|
|
338
394
|
const tooOld = this.session !== void 0 && maxAge !== void 0 && now - this.session.authTime > maxAge;
|
|
339
395
|
const requestedAcr = (query.acr_values ?? "").split(" ").filter(Boolean).sort().at(-1);
|
|
@@ -375,7 +431,11 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
375
431
|
let clientId = query.client_id;
|
|
376
432
|
if (query.id_token_hint) {
|
|
377
433
|
try {
|
|
378
|
-
const
|
|
434
|
+
const audience = [jose.decodeJwt(query.id_token_hint).aud ?? []].flat()[0];
|
|
435
|
+
const { payload } = await jose.jwtVerify(query.id_token_hint, this.verificationKeyFor(audience ? this.clients.get(audience) : void 0, query.id_token_hint), {
|
|
436
|
+
issuer: this.issuer,
|
|
437
|
+
currentDate: new Date(this.clock.now())
|
|
438
|
+
});
|
|
379
439
|
clientId ??= [payload.aud ?? []].flat()[0];
|
|
380
440
|
} catch {
|
|
381
441
|
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The id_token_hint is not valid." } });
|
|
@@ -388,9 +448,11 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
388
448
|
const ended = this.session;
|
|
389
449
|
this.session = void 0;
|
|
390
450
|
if (ended) this.revokeRefreshTokens({ sid: ended.sid });
|
|
391
|
-
if (ended)
|
|
392
|
-
for (const id of ended.clients)
|
|
393
|
-
|
|
451
|
+
if (ended)
|
|
452
|
+
for (const id of ended.clients) {
|
|
453
|
+
if (this.clients.get(id)?.backchannelLogoutUri) await this.deliverBackchannelLogout(id, { sub: ended.sub, sid: ended.sid });
|
|
454
|
+
if (this.clients.get(id)?.frontchannelLogoutUri) await this.deliverFrontchannelLogout(id, { sid: ended.sid });
|
|
455
|
+
}
|
|
394
456
|
if (!redirect) {
|
|
395
457
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
396
458
|
response.end("<!doctype html><p>You are signed out.</p>");
|
|
@@ -404,28 +466,38 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
404
466
|
async authenticate(recorded, form) {
|
|
405
467
|
const invalid = (description) => ({ status: 401, body: { error: "invalid_client", error_description: description } });
|
|
406
468
|
const authorization = recorded.headers.authorization;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
469
|
+
const basic = authorization?.startsWith("Basic ") ? Buffer.from(authorization.slice(6), "base64").toString("utf8").split(":").map(decodeURIComponent) : void 0;
|
|
470
|
+
const presented = basic ? "client_secret_basic" : form.client_assertion ? "private_key_jwt" : form.client_secret !== void 0 ? "client_secret_post" : "none";
|
|
471
|
+
const client = this.clients.get((basic ? basic[0] : form.client_id) ?? "");
|
|
472
|
+
if (!client) return invalid("Unknown client.");
|
|
473
|
+
if (form.client_assertion && form.client_secret !== void 0) return invalid("A client assertion and a client secret cannot be sent together.");
|
|
474
|
+
const registered = registeredMethodOf(client);
|
|
475
|
+
if (presented !== registered && !(registered === "none" && (presented === "client_secret_basic" || presented === "client_secret_post")))
|
|
476
|
+
return invalid(`The client ${client.clientId} authenticates with ${registered} (tokenEndpointAuthMethod), not ${presented}.`);
|
|
477
|
+
switch (registered) {
|
|
478
|
+
case "client_secret_basic":
|
|
479
|
+
if (!client.clientSecret || client.clientSecret !== basic?.[1]) return invalid("The client credentials are wrong.");
|
|
480
|
+
break;
|
|
481
|
+
case "client_secret_post":
|
|
482
|
+
if (!client.clientSecret || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
|
|
483
|
+
break;
|
|
484
|
+
case "private_key_jwt": {
|
|
485
|
+
if (!client.publicKey) return invalid("The client has no registered key.");
|
|
486
|
+
if (form.client_assertion_type !== "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") return invalid("Wrong assertion type.");
|
|
487
|
+
try {
|
|
488
|
+
const key = await jose.importJWK(client.publicKey, client.publicKey.alg);
|
|
489
|
+
const { payload } = await jose.jwtVerify(form.client_assertion, key, {
|
|
490
|
+
issuer: client.clientId,
|
|
491
|
+
subject: client.clientId,
|
|
492
|
+
audience: this.issuer,
|
|
493
|
+
currentDate: new Date(this.clock.now())
|
|
494
|
+
});
|
|
495
|
+
if (!payload.jti) return invalid("The assertion has no jti.");
|
|
496
|
+
} catch (error) {
|
|
497
|
+
return invalid(`The assertion does not verify: ${error.message}`);
|
|
498
|
+
}
|
|
499
|
+
break;
|
|
422
500
|
}
|
|
423
|
-
} else if (form.client_secret !== void 0) {
|
|
424
|
-
client = this.clients.get(form.client_id ?? "");
|
|
425
|
-
if (!client || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
|
|
426
|
-
} else {
|
|
427
|
-
client = this.clients.get(form.client_id ?? "");
|
|
428
|
-
if (!client || client.clientSecret || client.publicKey) return invalid("The client must authenticate.");
|
|
429
501
|
}
|
|
430
502
|
recorded.clientId = client.clientId;
|
|
431
503
|
return client;
|
|
@@ -460,11 +532,12 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
460
532
|
const code = this.codes.get(form.code ?? "");
|
|
461
533
|
if (!code || code.clientId !== client.clientId || code.redirectUri !== form.redirect_uri) return grant("invalid_grant", "The code is not valid.");
|
|
462
534
|
if (code.codeVerifier && code.codeVerifier !== form.code_verifier) return grant("invalid_grant", "PKCE verification failed.");
|
|
463
|
-
if (code.codeChallenge && !
|
|
535
|
+
if (code.codeChallenge && !form.code_verifier) return grant("invalid_grant", "code_verifier is required: the code was issued with a code_challenge.");
|
|
536
|
+
if (code.codeChallenge && !verifiesChallenge(form.code_verifier, code.codeChallenge, code.codeChallengeMethod ?? "S256"))
|
|
464
537
|
return grant("invalid_grant", "PKCE verification failed.");
|
|
465
538
|
this.codes.delete(form.code);
|
|
466
539
|
const user = [...this.users.values()].find((candidate) => candidate.sub === code.sub);
|
|
467
|
-
const signIn = code.sid ? { sid: code.sid, auth_time: code.authTime, acr: code.acr, amr: code.amr } : { auth_time:
|
|
540
|
+
const signIn = code.sid ? { sid: code.sid, auth_time: code.authTime, acr: code.acr, amr: code.amr } : { auth_time: this.nowSeconds(), acr: user.acr ?? "1", amr: user.amr ?? ["pwd"] };
|
|
468
541
|
return this.tokenAnswer(
|
|
469
542
|
client,
|
|
470
543
|
{
|
|
@@ -483,10 +556,13 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
483
556
|
if (!refresh || refresh.clientId !== client.clientId) return grant("invalid_grant", "The refresh token is not valid.");
|
|
484
557
|
this.refreshTokens.delete(form.refresh_token);
|
|
485
558
|
const user = [...this.users.values()].find((candidate) => candidate.sub === refresh.sub);
|
|
559
|
+
const signIn = { ...refresh.sid ? { sid: refresh.sid } : {}, ...refresh.authTime ? { auth_time: refresh.authTime } : {} };
|
|
486
560
|
return this.tokenAnswer(
|
|
487
561
|
client,
|
|
488
|
-
{ sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...
|
|
489
|
-
user
|
|
562
|
+
{ sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...signIn } },
|
|
563
|
+
user,
|
|
564
|
+
void 0,
|
|
565
|
+
client.issueIdTokenOnRefresh ? { signIn } : void 0
|
|
490
566
|
);
|
|
491
567
|
}
|
|
492
568
|
case "urn:ietf:params:oauth:grant-type:token-exchange": {
|
|
@@ -552,21 +628,33 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
552
628
|
if (user) {
|
|
553
629
|
const refreshToken = crypto.randomUUID();
|
|
554
630
|
const sid = typeof options.claims?.sid === "string" ? options.claims.sid : void 0;
|
|
555
|
-
|
|
631
|
+
const authTime = typeof options.claims?.auth_time === "number" ? options.claims.auth_time : void 0;
|
|
632
|
+
this.refreshTokens.set(refreshToken, {
|
|
633
|
+
clientId: client.clientId,
|
|
634
|
+
sub: user.sub,
|
|
635
|
+
scope: options.scope,
|
|
636
|
+
sid,
|
|
637
|
+
authTime,
|
|
638
|
+
expiresAt: this.nowSeconds() + 30 * 24 * 3600
|
|
639
|
+
});
|
|
556
640
|
body.refresh_token = refreshToken;
|
|
557
641
|
}
|
|
558
642
|
if (user && idToken && (options.scope ?? "").split(" ").includes("openid")) {
|
|
559
|
-
const now =
|
|
643
|
+
const now = this.nowSeconds();
|
|
560
644
|
const signIn = Object.fromEntries(Object.entries(idToken.signIn).filter(([, value]) => value !== void 0));
|
|
561
|
-
const signed = await
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
645
|
+
const signed = await this.sign(
|
|
646
|
+
new jose.SignJWT({
|
|
647
|
+
typ: "ID",
|
|
648
|
+
azp: client.clientId,
|
|
649
|
+
...signIn,
|
|
650
|
+
...idToken.nonce ? { nonce: idToken.nonce } : {},
|
|
651
|
+
at_hash: halfHash(accessToken, client.signingAlgorithm ?? "RS256"),
|
|
652
|
+
preferred_username: user.username,
|
|
653
|
+
...user.userInfo
|
|
654
|
+
}).setIssuer(this.issuer).setAudience(client.clientId).setSubject(user.sub).setIssuedAt(now).setExpirationTime(now + this.lifetime),
|
|
655
|
+
client,
|
|
656
|
+
"JWT"
|
|
657
|
+
);
|
|
570
658
|
body.id_token = encryption && encryption.idTokens !== false ? await encryptTo(signed, encryption) : signed;
|
|
571
659
|
}
|
|
572
660
|
return { status: 200, body };
|
|
@@ -575,7 +663,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
575
663
|
const client = await this.authenticate(recorded, form);
|
|
576
664
|
if ("status" in client) return client;
|
|
577
665
|
const issued = this.issued.get(form.token ?? "");
|
|
578
|
-
const now =
|
|
666
|
+
const now = this.nowSeconds();
|
|
579
667
|
const refresh = this.refreshTokens.get(form.token ?? "");
|
|
580
668
|
if (refresh)
|
|
581
669
|
return refresh.clientId === client.clientId ? {
|
|
@@ -632,13 +720,29 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
632
720
|
const user = [...this.users.values()].find((candidate) => candidate.sub === issued.claims.sub);
|
|
633
721
|
const claims = { sub: issued.claims.sub, ...user?.userInfo };
|
|
634
722
|
if (!this.userInfoAsJwt) return send(response, { status: 200, body: claims });
|
|
635
|
-
const now =
|
|
636
|
-
const signed = await
|
|
723
|
+
const now = this.nowSeconds();
|
|
724
|
+
const signed = await this.sign(
|
|
725
|
+
new jose.SignJWT(claims).setIssuer(this.issuer).setAudience(issued.clientId).setIssuedAt(now).setExpirationTime(now + this.lifetime),
|
|
726
|
+
this.clients.get(issued.clientId),
|
|
727
|
+
void 0
|
|
728
|
+
);
|
|
637
729
|
const encryption = this.clients.get(issued.clientId)?.encryption;
|
|
638
730
|
const jwt = encryption && encryption.userInfo !== false ? await encryptTo(signed, encryption) : signed;
|
|
639
731
|
response.writeHead(200, { "content-type": "application/jwt" });
|
|
640
732
|
response.end(jwt);
|
|
641
733
|
}
|
|
734
|
+
nowSeconds() {
|
|
735
|
+
return Math.floor(this.clock.now() / 1e3);
|
|
736
|
+
}
|
|
737
|
+
sign(builder, client, type, algorithm = client?.signingAlgorithm ?? "RS256") {
|
|
738
|
+
if (algorithm === "RS256") return builder.setProtectedHeader({ alg: "RS256", kid: this.keyId, ...type ? { typ: type } : {} }).sign(this.signingKey);
|
|
739
|
+
if (!client?.clientSecret) throw new Error(`The fake client ${client?.clientId} signs with ${algorithm} and has no clientSecret.`);
|
|
740
|
+
return builder.setProtectedHeader({ alg: algorithm, ...type ? { typ: type } : {} }).sign(new TextEncoder().encode(client.clientSecret));
|
|
741
|
+
}
|
|
742
|
+
verificationKeyFor(client, token) {
|
|
743
|
+
const algorithm = String(jose.decodeProtectedHeader(token).alg ?? "");
|
|
744
|
+
return algorithm.startsWith("HS") && client?.clientSecret ? new TextEncoder().encode(client.clientSecret) : this.verificationKey;
|
|
745
|
+
}
|
|
642
746
|
requireUser(username) {
|
|
643
747
|
const user = this.users.get(username);
|
|
644
748
|
if (!user) throw new Error(`The fake has no user ${username}.`);
|
|
@@ -665,8 +769,21 @@ function verifiesChallenge(verifier, challenge, method) {
|
|
|
665
769
|
if (!verifier) return false;
|
|
666
770
|
return method === "S256" ? crypto.createHash("sha256").update(verifier, "ascii").digest("base64url") === challenge : verifier === challenge;
|
|
667
771
|
}
|
|
668
|
-
function halfHash(value) {
|
|
669
|
-
|
|
772
|
+
function halfHash(value, algorithm) {
|
|
773
|
+
const size = algorithm.endsWith("384") ? 384 : algorithm.endsWith("512") ? 512 : 256;
|
|
774
|
+
const digest = crypto.createHash(`sha${size}`).update(value, "ascii").digest();
|
|
775
|
+
return digest.subarray(0, digest.length / 2).toString("base64url");
|
|
776
|
+
}
|
|
777
|
+
function checkClient(client) {
|
|
778
|
+
for (const algorithm of [client.signingAlgorithm ?? "RS256", client.accessTokenSigningAlgorithm ?? "RS256"]) {
|
|
779
|
+
const minimum = { HS256: 32, HS384: 48, HS512: 64 }[algorithm];
|
|
780
|
+
if (minimum !== void 0 && Buffer.byteLength(client.clientSecret ?? "", "utf8") < minimum)
|
|
781
|
+
throw new Error(`The fake client ${client.clientId} signs with ${algorithm}, which needs a clientSecret of at least ${minimum} bytes (RFC 7518 \xA73.2).`);
|
|
782
|
+
}
|
|
783
|
+
return client;
|
|
784
|
+
}
|
|
785
|
+
function registeredMethodOf(client) {
|
|
786
|
+
return client.tokenEndpointAuthMethod ?? (client.clientSecret ? "client_secret_basic" : client.publicKey ? "private_key_jwt" : "none");
|
|
670
787
|
}
|
|
671
788
|
function send(response, answer) {
|
|
672
789
|
const headers = { ...answer.headers };
|