@hyper.authio/testing 0.1.1 → 0.8.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 +36 -6
- package/dist/index.cjs +449 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +213 -9
- package/dist/index.d.ts +213 -9
- package/dist/index.js +451 -47
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'http';
|
|
2
|
-
import { randomUUID } from 'crypto';
|
|
3
|
-
import { SignJWT, decodeJwt, generateKeyPair, exportJWK, importJWK,
|
|
2
|
+
import { randomUUID, createHash } from 'crypto';
|
|
3
|
+
import { SignJWT, decodeJwt, generateKeyPair, exportJWK, jwtVerify, importJWK, decodeProtectedHeader, CompactEncrypt } from 'jose';
|
|
4
4
|
import { AuthioWebhookHeaders, computeWebhookSignatureHeader } from '@hyper.authio/client';
|
|
5
5
|
|
|
6
6
|
// src/fake-authio.ts
|
|
@@ -9,23 +9,35 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
9
9
|
requests = [];
|
|
10
10
|
/** Answers the next requests to an endpoint with these errors, in order, before answering normally. */
|
|
11
11
|
failures = /* @__PURE__ */ new Map();
|
|
12
|
+
/** Every back-channel logout token posted, in order. */
|
|
13
|
+
backchannelDeliveries = [];
|
|
14
|
+
/** Every front-channel logout page loaded, in order. */
|
|
15
|
+
frontchannelDeliveries = [];
|
|
12
16
|
/** What successive device-code polls answer; the last value repeats. Defaults to `["approved"]`. */
|
|
13
17
|
deviceOutcomes = ["approved"];
|
|
14
18
|
/** Overrides the discovery document's issuer, to test the mismatch check. */
|
|
15
19
|
discoveryIssuer;
|
|
16
|
-
/** Answers UserInfo as `application/jwt`, signed by the realm key. */
|
|
20
|
+
/** Answers UserInfo as `application/jwt`, signed by the realm key (and encrypted for a client with a key). */
|
|
17
21
|
userInfoAsJwt = false;
|
|
22
|
+
/** Leaves `iss` out of authorization responses, to test the RFC 9207 check. */
|
|
23
|
+
omitAuthorizationIssuer = false;
|
|
18
24
|
/** Answers connected-account exchanges. Defaults to granting a token for the provider. */
|
|
19
25
|
connectionResponder;
|
|
26
|
+
/** The person the authorization endpoint signs in when the request names none with `login_hint`. Defaults to the first user. */
|
|
27
|
+
signInAs;
|
|
20
28
|
clients = /* @__PURE__ */ new Map();
|
|
21
29
|
users = /* @__PURE__ */ new Map();
|
|
30
|
+
personalAccessTokens = /* @__PURE__ */ new Map();
|
|
22
31
|
codes = /* @__PURE__ */ new Map();
|
|
23
32
|
refreshTokens = /* @__PURE__ */ new Map();
|
|
24
33
|
issued = /* @__PURE__ */ new Map();
|
|
25
34
|
devicePolls = /* @__PURE__ */ new Map();
|
|
26
35
|
lifetime;
|
|
36
|
+
clock;
|
|
37
|
+
session;
|
|
27
38
|
server;
|
|
28
39
|
signingKey;
|
|
40
|
+
verificationKey;
|
|
29
41
|
untrustedKey;
|
|
30
42
|
publicJwk;
|
|
31
43
|
baseUrl;
|
|
@@ -34,8 +46,10 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
34
46
|
constructor(options) {
|
|
35
47
|
this.realm = options.realm ?? "fake";
|
|
36
48
|
this.lifetime = options.accessTokenLifetime ?? 300;
|
|
37
|
-
|
|
49
|
+
this.clock = options.clock ?? { now: () => Date.now() };
|
|
50
|
+
for (const client of options.clients ?? []) this.clients.set(client.clientId, checkClient(client));
|
|
38
51
|
for (const user of options.users ?? []) this.users.set(user.username, user);
|
|
52
|
+
for (const pat of options.personalAccessTokens ?? []) this.personalAccessTokens.set(pat.token, pat);
|
|
39
53
|
}
|
|
40
54
|
/**
|
|
41
55
|
* Starts the fake on a random loopback port.
|
|
@@ -57,14 +71,26 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
57
71
|
get jwks() {
|
|
58
72
|
return { keys: [this.publicJwk] };
|
|
59
73
|
}
|
|
74
|
+
/** The session the authorization endpoint keeps (one browser), or `undefined` when nobody is signed in. */
|
|
75
|
+
get currentSession() {
|
|
76
|
+
return this.session ? { sub: this.session.sub, sid: this.session.sid, authTime: this.session.authTime } : void 0;
|
|
77
|
+
}
|
|
60
78
|
/** Registers a client. */
|
|
61
79
|
addClient(client) {
|
|
62
|
-
this.clients.set(client.clientId, client);
|
|
80
|
+
this.clients.set(client.clientId, checkClient(client));
|
|
63
81
|
}
|
|
64
82
|
/** Registers a person. */
|
|
65
83
|
addUser(user) {
|
|
66
84
|
this.users.set(user.username, user);
|
|
67
85
|
}
|
|
86
|
+
/** Registers a personal access token. */
|
|
87
|
+
addPersonalAccessToken(token) {
|
|
88
|
+
this.personalAccessTokens.set(token.token, token);
|
|
89
|
+
}
|
|
90
|
+
/** Ends the signed-in session without notifying anyone, as if it expired. */
|
|
91
|
+
endSession() {
|
|
92
|
+
this.session = void 0;
|
|
93
|
+
}
|
|
68
94
|
/**
|
|
69
95
|
* Registers an authorization code for a person, as the authorization endpoint would after sign-in.
|
|
70
96
|
*/
|
|
@@ -77,15 +103,32 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
77
103
|
redirectUri: options.redirectUri,
|
|
78
104
|
scope: options.scope,
|
|
79
105
|
codeVerifier: options.codeChallengeVerifier,
|
|
80
|
-
resource: options.resource
|
|
106
|
+
resource: options.resource,
|
|
107
|
+
nonce: options.nonce
|
|
81
108
|
});
|
|
82
109
|
return code;
|
|
83
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Follows an authorization URL as a browser would, signing in `signInAs` (or the `login_hint`), and returns what
|
|
113
|
+
* Authio sent back: the callback URL for `response_mode=query`, the posted message's parameters for `web_message`.
|
|
114
|
+
*/
|
|
115
|
+
async authorize(authorizationUrl) {
|
|
116
|
+
const response = await fetch(authorizationUrl, { redirect: "manual" });
|
|
117
|
+
const location = response.headers.get("location");
|
|
118
|
+
if (location) {
|
|
119
|
+
const callback = new URL(location);
|
|
120
|
+
return { parameters: Object.fromEntries(callback.searchParams), callbackUrl: location };
|
|
121
|
+
}
|
|
122
|
+
const html = await response.text();
|
|
123
|
+
const message = /data-authorization-response='([^']*)'/.exec(html)?.[1];
|
|
124
|
+
if (!message) throw new Error(`The fake answered the authorization with HTTP ${response.status} and no response.`);
|
|
125
|
+
return { parameters: JSON.parse(decodeURIComponent(message)), callbackUrl: void 0 };
|
|
126
|
+
}
|
|
84
127
|
/**
|
|
85
128
|
* Mints an access token signed by the realm key, recorded so introspection and revocation know it.
|
|
86
129
|
*/
|
|
87
130
|
async issueAccessToken(options = {}) {
|
|
88
|
-
const now =
|
|
131
|
+
const now = this.nowSeconds();
|
|
89
132
|
const clientId = options.clientId ?? "fake-client";
|
|
90
133
|
const jti = randomUUID();
|
|
91
134
|
const payload = {
|
|
@@ -96,14 +139,101 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
96
139
|
...options.sub ? { sub: options.sub } : {},
|
|
97
140
|
...options.claims
|
|
98
141
|
};
|
|
99
|
-
const
|
|
100
|
-
|
|
142
|
+
const builder = new SignJWT(payload).setIssuer(options.issuer ?? this.issuer).setAudience(options.audience ?? clientId).setIssuedAt(now).setNotBefore(now).setExpirationTime(now + (options.expiresIn ?? this.lifetime)).setJti(jti);
|
|
143
|
+
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");
|
|
144
|
+
const token = options.encryptTo ? await encryptTo(signed, options.encryptTo) : signed;
|
|
145
|
+
this.issued.set(token, { claims: decodeJwt(signed), clientId, revoked: false });
|
|
101
146
|
return token;
|
|
102
147
|
}
|
|
103
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Mints a back-channel logout token for a client, signed by the realm key. `overrides` replaces claims (`undefined`
|
|
150
|
+
* removes one) and `type` the header `typ`, to build invalid ones in tests.
|
|
151
|
+
*/
|
|
152
|
+
async createLogoutToken(options) {
|
|
153
|
+
const now = this.nowSeconds();
|
|
154
|
+
const payload = {
|
|
155
|
+
iss: this.issuer,
|
|
156
|
+
aud: options.clientId,
|
|
157
|
+
iat: now,
|
|
158
|
+
exp: now + 120,
|
|
159
|
+
jti: randomUUID(),
|
|
160
|
+
...options.sub ? { sub: options.sub } : {},
|
|
161
|
+
...options.sid ? { sid: options.sid } : {},
|
|
162
|
+
events: { "http://schemas.openid.net/event/backchannel-logout": {} },
|
|
163
|
+
...options.overrides
|
|
164
|
+
};
|
|
165
|
+
for (const key of Object.keys(payload)) if (payload[key] === void 0) delete payload[key];
|
|
166
|
+
const signed = await this.sign(new SignJWT(payload), this.clients.get(options.clientId), options.type ?? "logout+jwt");
|
|
167
|
+
const encryption = this.clients.get(options.clientId)?.encryption;
|
|
168
|
+
return encryption?.logoutTokens ? encryptTo(signed, encryption) : signed;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Posts a back-channel logout token to the client's `backchannelLogoutUri`, as Authio does when a session ends.
|
|
172
|
+
*
|
|
173
|
+
* @returns The HTTP status the client answered, or 0 when it could not be reached.
|
|
174
|
+
*/
|
|
175
|
+
async deliverBackchannelLogout(clientId, options = {}) {
|
|
176
|
+
const client = this.clients.get(clientId);
|
|
177
|
+
if (!client?.backchannelLogoutUri) throw new Error(`The fake client ${clientId} has no backchannelLogoutUri.`);
|
|
178
|
+
const logoutToken = await this.createLogoutToken({ clientId, sub: options.sub, sid: options.sid });
|
|
179
|
+
let status = 0;
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetch(client.backchannelLogoutUri, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
184
|
+
body: new URLSearchParams({ logout_token: logoutToken }).toString()
|
|
185
|
+
});
|
|
186
|
+
status = response.status;
|
|
187
|
+
await response.arrayBuffer();
|
|
188
|
+
} catch {
|
|
189
|
+
status = 0;
|
|
190
|
+
}
|
|
191
|
+
this.backchannelDeliveries.push({ clientId, uri: client.backchannelLogoutUri, logoutToken, status });
|
|
192
|
+
return status;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The front-channel logout URL of a client for a session: its `frontchannelLogoutUri`, with `iss` and `sid` when the
|
|
196
|
+
* client requires the session.
|
|
197
|
+
*/
|
|
198
|
+
frontchannelLogoutUrl(clientId, sid) {
|
|
199
|
+
const client = this.clients.get(clientId);
|
|
200
|
+
if (!client?.frontchannelLogoutUri) throw new Error(`The fake client ${clientId} has no frontchannelLogoutUri.`);
|
|
201
|
+
const url = new URL(client.frontchannelLogoutUri);
|
|
202
|
+
if (client.frontchannelLogoutSessionRequired) {
|
|
203
|
+
url.searchParams.set("iss", this.issuer);
|
|
204
|
+
url.searchParams.set("sid", sid);
|
|
205
|
+
}
|
|
206
|
+
return url.toString();
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Loads the client's front-channel logout page, as the browser does in an iframe when Authio ends a session.
|
|
210
|
+
*
|
|
211
|
+
* @returns The HTTP status the client answered, or 0 when it could not be reached.
|
|
212
|
+
*/
|
|
213
|
+
async deliverFrontchannelLogout(clientId, options) {
|
|
214
|
+
const url = this.frontchannelLogoutUrl(clientId, options.sid);
|
|
215
|
+
let status = 0;
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetch(url, { headers: options.headers, redirect: "manual" });
|
|
218
|
+
status = response.status;
|
|
219
|
+
await response.arrayBuffer();
|
|
220
|
+
} catch {
|
|
221
|
+
status = 0;
|
|
222
|
+
}
|
|
223
|
+
this.frontchannelDeliveries.push({ clientId, url, status });
|
|
224
|
+
return status;
|
|
225
|
+
}
|
|
226
|
+
/** Marks a token revoked, as Authio would after a sign-out: an access token, or a refresh token, which is then forgotten. */
|
|
104
227
|
revokeToken(token) {
|
|
105
228
|
const issued = this.issued.get(token);
|
|
106
229
|
if (issued) issued.revoked = true;
|
|
230
|
+
this.refreshTokens.delete(token);
|
|
231
|
+
}
|
|
232
|
+
/** Revokes every refresh token of a person, or of one Authio session (`sid`), as Authio does when a session ends. */
|
|
233
|
+
revokeRefreshTokens(options) {
|
|
234
|
+
for (const [token, refresh] of this.refreshTokens)
|
|
235
|
+
if (options.sid !== void 0 && refresh.sid === options.sid || options.sid === void 0 && options.sub !== void 0 && refresh.sub === options.sub)
|
|
236
|
+
this.refreshTokens.delete(token);
|
|
107
237
|
}
|
|
108
238
|
/** Forgets the recorded requests. */
|
|
109
239
|
clearRequests() {
|
|
@@ -124,6 +254,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
124
254
|
const pair = await generateKeyPair("RS256", { extractable: true });
|
|
125
255
|
const untrusted = await generateKeyPair("RS256");
|
|
126
256
|
this.signingKey = pair.privateKey;
|
|
257
|
+
this.verificationKey = pair.publicKey;
|
|
127
258
|
this.untrustedKey = untrusted.privateKey;
|
|
128
259
|
this.publicJwk = { ...await exportJWK(pair.publicKey), kid: this.keyId, alg: "RS256", use: "sig" };
|
|
129
260
|
this.server = createServer((request, response) => {
|
|
@@ -139,7 +270,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
139
270
|
const body = await readBody(request);
|
|
140
271
|
const form = Object.fromEntries(new URLSearchParams(request.headers["content-type"]?.includes("form") ? body : ""));
|
|
141
272
|
const headers = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name, Array.isArray(value) ? value.join(", ") : value ?? ""]));
|
|
142
|
-
const
|
|
273
|
+
const query = Object.fromEntries(url.searchParams);
|
|
274
|
+
const recorded = { method: request.method ?? "GET", path: url.pathname, headers, form, query };
|
|
143
275
|
this.requests.push(recorded);
|
|
144
276
|
const prefix = `/realms/${this.realm}`;
|
|
145
277
|
const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname;
|
|
@@ -151,6 +283,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
151
283
|
return send(response, { status: 200, body: this.discovery() });
|
|
152
284
|
case "/protocol/openid-connect/certs":
|
|
153
285
|
return send(response, { status: 200, body: this.jwks });
|
|
286
|
+
case "/protocol/openid-connect/auth":
|
|
287
|
+
return this.authorizeEndpoint(query, response);
|
|
154
288
|
case "/protocol/openid-connect/token":
|
|
155
289
|
return send(response, await this.token(recorded, form));
|
|
156
290
|
case "/protocol/openid-connect/token/introspect":
|
|
@@ -161,6 +295,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
161
295
|
return this.userInfo(headers, response);
|
|
162
296
|
case "/protocol/openid-connect/auth/device":
|
|
163
297
|
return send(response, await this.device(recorded, form));
|
|
298
|
+
case "/protocol/openid-connect/logout":
|
|
299
|
+
return this.endSessionEndpoint(query, response);
|
|
164
300
|
default:
|
|
165
301
|
return send(response, { status: 404, body: { error: "not_found" } });
|
|
166
302
|
}
|
|
@@ -177,46 +313,211 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
177
313
|
end_session_endpoint: `${base}/logout`,
|
|
178
314
|
device_authorization_endpoint: `${base}/auth/device`,
|
|
179
315
|
jwks_uri: `${base}/certs`,
|
|
180
|
-
|
|
316
|
+
scopes_supported: ["openid", "profile", "email", "phone", "roles", "offline_access"],
|
|
317
|
+
response_types_supported: ["code"],
|
|
318
|
+
response_modes_supported: ["query", "web_message"],
|
|
319
|
+
grant_types_supported: [
|
|
320
|
+
"authorization_code",
|
|
321
|
+
"refresh_token",
|
|
322
|
+
"client_credentials",
|
|
323
|
+
"password",
|
|
324
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
325
|
+
"urn:ietf:params:oauth:grant-type:token-exchange",
|
|
326
|
+
"personal_access_token"
|
|
327
|
+
],
|
|
328
|
+
subject_types_supported: ["public"],
|
|
329
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
330
|
+
id_token_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
|
|
331
|
+
id_token_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
|
|
332
|
+
userinfo_signing_alg_values_supported: ["RS256"],
|
|
333
|
+
userinfo_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
|
|
334
|
+
userinfo_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
|
|
181
335
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "private_key_jwt", "none"],
|
|
182
|
-
code_challenge_methods_supported: ["S256"],
|
|
183
|
-
|
|
336
|
+
code_challenge_methods_supported: ["S256", "plain"],
|
|
337
|
+
prompt_values_supported: ["none", "login", "select_account", "consent"],
|
|
338
|
+
display_values_supported: ["page", "popup"],
|
|
339
|
+
acr_values_supported: ["1", "2", "3"],
|
|
340
|
+
backchannel_logout_supported: true,
|
|
341
|
+
backchannel_logout_session_supported: true,
|
|
342
|
+
frontchannel_logout_supported: true,
|
|
343
|
+
frontchannel_logout_session_supported: true,
|
|
344
|
+
authorization_response_iss_parameter_supported: !this.omitAuthorizationIssuer
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
authorizeEndpoint(query, response) {
|
|
348
|
+
const client = this.clients.get(query.client_id ?? "");
|
|
349
|
+
if (!client) return send(response, { status: 400, body: { error: "invalid_request", error_description: "Unknown client_id." } });
|
|
350
|
+
if (!query.redirect_uri || client.redirectUris && !client.redirectUris.includes(query.redirect_uri))
|
|
351
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The redirect_uri is not registered." } });
|
|
352
|
+
const answer = (parameters) => {
|
|
353
|
+
const values = {};
|
|
354
|
+
for (const [name, value] of Object.entries({ ...parameters, state: query.state, iss: this.omitAuthorizationIssuer ? void 0 : this.issuer }))
|
|
355
|
+
if (value !== void 0) values[name] = value;
|
|
356
|
+
if (query.response_mode === "web_message") {
|
|
357
|
+
const message = encodeURIComponent(JSON.stringify(values));
|
|
358
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
359
|
+
response.end(
|
|
360
|
+
`<!doctype html><html><body data-authorization-response='${message}'><script>window.opener&&window.opener.postMessage({type:"authorization_response",response:JSON.parse(decodeURIComponent(document.body.dataset.authorizationResponse))},${JSON.stringify(new URL(query.redirect_uri).origin)});</script></body></html>`
|
|
361
|
+
);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const location = new URL(query.redirect_uri);
|
|
365
|
+
for (const [name, value] of Object.entries(values)) location.searchParams.set(name, value);
|
|
366
|
+
response.writeHead(302, { location: location.toString() });
|
|
367
|
+
response.end();
|
|
184
368
|
};
|
|
369
|
+
if (query.response_mode !== void 0 && query.response_mode !== "query" && query.response_mode !== "web_message")
|
|
370
|
+
return answer({ error: "invalid_request", error_description: `The response_mode '${query.response_mode}' is not supported: Authio answers with query or web_message.` });
|
|
371
|
+
if (query.response_type !== "code") return answer({ error: "unsupported_response_type", error_description: "Only code is supported." });
|
|
372
|
+
const method = query.code_challenge ? query.code_challenge_method : void 0;
|
|
373
|
+
const allowedMethods = client.pkceMethods ?? ["S256"];
|
|
374
|
+
if (query.code_challenge) {
|
|
375
|
+
if (!method)
|
|
376
|
+
return answer({ error: "invalid_request", error_description: "code_challenge_method is required with code_challenge: Authio does not assume plain." });
|
|
377
|
+
if (method !== "S256" && method !== "plain") return answer({ error: "invalid_request", error_description: `The PKCE method '${method}' is not supported.` });
|
|
378
|
+
if (!allowedMethods.includes(method))
|
|
379
|
+
return answer({
|
|
380
|
+
error: "invalid_request",
|
|
381
|
+
error_description: `The PKCE method '${method}' is not allowed for the client ${client.clientId}, which allows ${allowedMethods.join(", ")} (codeChallengeMethod).`
|
|
382
|
+
});
|
|
383
|
+
} else if (client.requirePkce ?? true) {
|
|
384
|
+
return answer({
|
|
385
|
+
error: "invalid_request",
|
|
386
|
+
error_description: `The client ${client.clientId} requires PKCE: send code_challenge and code_challenge_method (checks: ["pkce"]).`
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const prompts = (query.prompt ?? "").split(" ").filter(Boolean);
|
|
390
|
+
const now = this.nowSeconds();
|
|
391
|
+
const maxAge = query.max_age !== void 0 ? Number(query.max_age) : void 0;
|
|
392
|
+
const tooOld = this.session !== void 0 && maxAge !== void 0 && now - this.session.authTime > maxAge;
|
|
393
|
+
const requestedAcr = (query.acr_values ?? "").split(" ").filter(Boolean).sort().at(-1);
|
|
394
|
+
const weaker = this.session !== void 0 && requestedAcr !== void 0 && Number(this.session.acr) < Number(requestedAcr);
|
|
395
|
+
if (prompts.includes("none") && (!this.session || tooOld || weaker)) return answer({ error: "login_required", error_description: "Nobody is signed in." });
|
|
396
|
+
if (!this.session || prompts.includes("login") || tooOld || weaker || query.login_hint && this.users.get(query.login_hint)?.sub !== this.session.sub) {
|
|
397
|
+
const username = (query.login_hint && this.users.has(query.login_hint) ? query.login_hint : void 0) ?? this.signInAs ?? this.users.keys().next().value;
|
|
398
|
+
const user = username ? this.users.get(username) : void 0;
|
|
399
|
+
if (!user) return answer({ error: "access_denied", error_description: "The fake has no user to sign in." });
|
|
400
|
+
const acr = requestedAcr ?? user.acr ?? "1";
|
|
401
|
+
this.session = {
|
|
402
|
+
sub: user.sub,
|
|
403
|
+
sid: randomUUID().replace(/-/g, ""),
|
|
404
|
+
authTime: now,
|
|
405
|
+
acr,
|
|
406
|
+
amr: user.amr ?? (Number(acr) >= 2 ? ["pwd", "otp", "mfa"] : ["pwd"]),
|
|
407
|
+
clients: /* @__PURE__ */ new Set()
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
this.session.clients.add(client.clientId);
|
|
411
|
+
const code = randomUUID();
|
|
412
|
+
this.codes.set(code, {
|
|
413
|
+
clientId: client.clientId,
|
|
414
|
+
sub: this.session.sub,
|
|
415
|
+
redirectUri: query.redirect_uri,
|
|
416
|
+
scope: query.scope,
|
|
417
|
+
codeChallenge: query.code_challenge,
|
|
418
|
+
codeChallengeMethod: method,
|
|
419
|
+
resource: query.resource,
|
|
420
|
+
nonce: query.nonce,
|
|
421
|
+
sid: this.session.sid,
|
|
422
|
+
authTime: this.session.authTime,
|
|
423
|
+
acr: this.session.acr,
|
|
424
|
+
amr: this.session.amr
|
|
425
|
+
});
|
|
426
|
+
answer({ code });
|
|
427
|
+
}
|
|
428
|
+
async endSessionEndpoint(query, response) {
|
|
429
|
+
let clientId = query.client_id;
|
|
430
|
+
if (query.id_token_hint) {
|
|
431
|
+
try {
|
|
432
|
+
const audience = [decodeJwt(query.id_token_hint).aud ?? []].flat()[0];
|
|
433
|
+
const { payload } = await jwtVerify(query.id_token_hint, this.verificationKeyFor(audience ? this.clients.get(audience) : void 0, query.id_token_hint), {
|
|
434
|
+
issuer: this.issuer,
|
|
435
|
+
currentDate: new Date(this.clock.now())
|
|
436
|
+
});
|
|
437
|
+
clientId ??= [payload.aud ?? []].flat()[0];
|
|
438
|
+
} catch {
|
|
439
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The id_token_hint is not valid." } });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const client = clientId ? this.clients.get(clientId) : void 0;
|
|
443
|
+
const redirect = query.post_logout_redirect_uri;
|
|
444
|
+
if (redirect && (!client || client.postLogoutRedirectUris && !client.postLogoutRedirectUris.includes(redirect)))
|
|
445
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The post_logout_redirect_uri is not registered." } });
|
|
446
|
+
const ended = this.session;
|
|
447
|
+
this.session = void 0;
|
|
448
|
+
if (ended) this.revokeRefreshTokens({ sid: ended.sid });
|
|
449
|
+
if (ended)
|
|
450
|
+
for (const id of ended.clients) {
|
|
451
|
+
if (this.clients.get(id)?.backchannelLogoutUri) await this.deliverBackchannelLogout(id, { sub: ended.sub, sid: ended.sid });
|
|
452
|
+
if (this.clients.get(id)?.frontchannelLogoutUri) await this.deliverFrontchannelLogout(id, { sid: ended.sid });
|
|
453
|
+
}
|
|
454
|
+
if (!redirect) {
|
|
455
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
456
|
+
response.end("<!doctype html><p>You are signed out.</p>");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const location = new URL(redirect);
|
|
460
|
+
if (query.state) location.searchParams.set("state", query.state);
|
|
461
|
+
response.writeHead(302, { location: location.toString() });
|
|
462
|
+
response.end();
|
|
185
463
|
}
|
|
186
464
|
async authenticate(recorded, form) {
|
|
187
465
|
const invalid = (description) => ({ status: 401, body: { error: "invalid_client", error_description: description } });
|
|
188
466
|
const authorization = recorded.headers.authorization;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
467
|
+
const basic = authorization?.startsWith("Basic ") ? Buffer.from(authorization.slice(6), "base64").toString("utf8").split(":").map(decodeURIComponent) : void 0;
|
|
468
|
+
const presented = basic ? "client_secret_basic" : form.client_assertion ? "private_key_jwt" : form.client_secret !== void 0 ? "client_secret_post" : "none";
|
|
469
|
+
const client = this.clients.get((basic ? basic[0] : form.client_id) ?? "");
|
|
470
|
+
if (!client) return invalid("Unknown client.");
|
|
471
|
+
if (form.client_assertion && form.client_secret !== void 0) return invalid("A client assertion and a client secret cannot be sent together.");
|
|
472
|
+
const registered = registeredMethodOf(client);
|
|
473
|
+
if (presented !== registered && !(registered === "none" && (presented === "client_secret_basic" || presented === "client_secret_post")))
|
|
474
|
+
return invalid(`The client ${client.clientId} authenticates with ${registered} (tokenEndpointAuthMethod), not ${presented}.`);
|
|
475
|
+
switch (registered) {
|
|
476
|
+
case "client_secret_basic":
|
|
477
|
+
if (!client.clientSecret || client.clientSecret !== basic?.[1]) return invalid("The client credentials are wrong.");
|
|
478
|
+
break;
|
|
479
|
+
case "client_secret_post":
|
|
480
|
+
if (!client.clientSecret || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
|
|
481
|
+
break;
|
|
482
|
+
case "private_key_jwt": {
|
|
483
|
+
if (!client.publicKey) return invalid("The client has no registered key.");
|
|
484
|
+
if (form.client_assertion_type !== "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") return invalid("Wrong assertion type.");
|
|
485
|
+
try {
|
|
486
|
+
const key = await importJWK(client.publicKey, client.publicKey.alg);
|
|
487
|
+
const { payload } = await jwtVerify(form.client_assertion, key, {
|
|
488
|
+
issuer: client.clientId,
|
|
489
|
+
subject: client.clientId,
|
|
490
|
+
audience: this.issuer,
|
|
491
|
+
currentDate: new Date(this.clock.now())
|
|
492
|
+
});
|
|
493
|
+
if (!payload.jti) return invalid("The assertion has no jti.");
|
|
494
|
+
} catch (error) {
|
|
495
|
+
return invalid(`The assertion does not verify: ${error.message}`);
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
204
498
|
}
|
|
205
|
-
} else if (form.client_secret !== void 0) {
|
|
206
|
-
client = this.clients.get(form.client_id ?? "");
|
|
207
|
-
if (!client || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
|
|
208
|
-
} else {
|
|
209
|
-
client = this.clients.get(form.client_id ?? "");
|
|
210
|
-
if (!client || client.clientSecret || client.publicKey) return invalid("The client must authenticate.");
|
|
211
499
|
}
|
|
212
500
|
recorded.clientId = client.clientId;
|
|
213
501
|
return client;
|
|
214
502
|
}
|
|
215
503
|
async token(recorded, form) {
|
|
504
|
+
const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
|
|
505
|
+
if (form.grant_type === "personal_access_token") {
|
|
506
|
+
const pat = this.personalAccessTokens.get(form.client_secret ?? "");
|
|
507
|
+
if (!pat || form.client_id && pat.clientId && form.client_id !== pat.clientId)
|
|
508
|
+
return { status: 401, body: { error: "invalid_client", error_description: "The personal access token is not valid." } };
|
|
509
|
+
const clientId = pat.clientId ?? "authio";
|
|
510
|
+
recorded.clientId = clientId;
|
|
511
|
+
return this.tokenAnswer(this.clients.get(clientId) ?? { clientId }, {
|
|
512
|
+
sub: pat.sub,
|
|
513
|
+
scope: form.scope,
|
|
514
|
+
audience: form.resource,
|
|
515
|
+
claims: { amr: "personal_access_token", actor_type: "personal_access_token", credential_id: pat.sub, ...pat.claims }
|
|
516
|
+
});
|
|
517
|
+
}
|
|
216
518
|
const client = await this.authenticate(recorded, form);
|
|
217
519
|
if ("status" in client) return client;
|
|
218
520
|
const scope = form.scope ?? client.defaultScopes?.join(" ");
|
|
219
|
-
const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
|
|
220
521
|
switch (form.grant_type) {
|
|
221
522
|
case "client_credentials":
|
|
222
523
|
return this.tokenAnswer(client, { sub: client.clientId, scope, audience: form.resource, claims: client.claims });
|
|
@@ -229,12 +530,23 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
229
530
|
const code = this.codes.get(form.code ?? "");
|
|
230
531
|
if (!code || code.clientId !== client.clientId || code.redirectUri !== form.redirect_uri) return grant("invalid_grant", "The code is not valid.");
|
|
231
532
|
if (code.codeVerifier && code.codeVerifier !== form.code_verifier) return grant("invalid_grant", "PKCE verification failed.");
|
|
533
|
+
if (code.codeChallenge && !form.code_verifier) return grant("invalid_grant", "code_verifier is required: the code was issued with a code_challenge.");
|
|
534
|
+
if (code.codeChallenge && !verifiesChallenge(form.code_verifier, code.codeChallenge, code.codeChallengeMethod ?? "S256"))
|
|
535
|
+
return grant("invalid_grant", "PKCE verification failed.");
|
|
232
536
|
this.codes.delete(form.code);
|
|
233
537
|
const user = [...this.users.values()].find((candidate) => candidate.sub === code.sub);
|
|
538
|
+
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"] };
|
|
234
539
|
return this.tokenAnswer(
|
|
235
540
|
client,
|
|
236
|
-
{
|
|
237
|
-
|
|
541
|
+
{
|
|
542
|
+
sub: user.sub,
|
|
543
|
+
scope: code.scope,
|
|
544
|
+
audience: form.resource ?? code.resource,
|
|
545
|
+
claims: { ...client.claims, ...user.claims, ...signIn, preferred_username: user.username }
|
|
546
|
+
},
|
|
547
|
+
user,
|
|
548
|
+
void 0,
|
|
549
|
+
{ nonce: code.nonce, signIn }
|
|
238
550
|
);
|
|
239
551
|
}
|
|
240
552
|
case "refresh_token": {
|
|
@@ -242,10 +554,13 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
242
554
|
if (!refresh || refresh.clientId !== client.clientId) return grant("invalid_grant", "The refresh token is not valid.");
|
|
243
555
|
this.refreshTokens.delete(form.refresh_token);
|
|
244
556
|
const user = [...this.users.values()].find((candidate) => candidate.sub === refresh.sub);
|
|
557
|
+
const signIn = { ...refresh.sid ? { sid: refresh.sid } : {}, ...refresh.authTime ? { auth_time: refresh.authTime } : {} };
|
|
245
558
|
return this.tokenAnswer(
|
|
246
559
|
client,
|
|
247
|
-
{ sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims } },
|
|
248
|
-
user
|
|
560
|
+
{ sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...signIn } },
|
|
561
|
+
user,
|
|
562
|
+
void 0,
|
|
563
|
+
client.issueIdTokenOnRefresh ? { signIn } : void 0
|
|
249
564
|
);
|
|
250
565
|
}
|
|
251
566
|
case "urn:ietf:params:oauth:grant-type:token-exchange": {
|
|
@@ -261,12 +576,12 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
261
576
|
};
|
|
262
577
|
return "status" in answer && typeof answer.status === "number" ? answer : { status: 200, body: answer };
|
|
263
578
|
}
|
|
264
|
-
const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, ...claims } = subject.claims;
|
|
579
|
+
const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, scope: _scope, ...claims } = subject.claims;
|
|
265
580
|
return this.tokenAnswer(client, {
|
|
266
581
|
sub: subject.claims.sub,
|
|
267
582
|
scope: form.scope ?? subject.claims.scope,
|
|
268
583
|
audience: form.resource,
|
|
269
|
-
claims: { ...claims, act: { client_id: client.clientId
|
|
584
|
+
claims: { ...claims, act: { client_id: client.clientId } }
|
|
270
585
|
}, void 0, form.requested_token_type);
|
|
271
586
|
}
|
|
272
587
|
case "urn:ietf:params:oauth:grant-type:device_code": {
|
|
@@ -291,13 +606,15 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
291
606
|
return grant("unsupported_grant_type", `The grant ${form.grant_type} is not supported.`);
|
|
292
607
|
}
|
|
293
608
|
}
|
|
294
|
-
async tokenAnswer(client, options, user, issuedTokenType) {
|
|
609
|
+
async tokenAnswer(client, options, user, issuedTokenType, idToken) {
|
|
610
|
+
const encryption = client.encryption;
|
|
295
611
|
const accessToken = await this.issueAccessToken({
|
|
296
612
|
clientId: client.clientId,
|
|
297
613
|
sub: options.sub,
|
|
298
614
|
scope: options.scope,
|
|
299
615
|
audience: options.audience ?? client.clientId,
|
|
300
|
-
claims: options.claims
|
|
616
|
+
claims: options.claims,
|
|
617
|
+
...encryption?.accessTokens ? { encryptTo: encryption } : {}
|
|
301
618
|
});
|
|
302
619
|
const body = {
|
|
303
620
|
access_token: accessToken,
|
|
@@ -308,16 +625,57 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
308
625
|
};
|
|
309
626
|
if (user) {
|
|
310
627
|
const refreshToken = randomUUID();
|
|
311
|
-
|
|
628
|
+
const sid = typeof options.claims?.sid === "string" ? options.claims.sid : void 0;
|
|
629
|
+
const authTime = typeof options.claims?.auth_time === "number" ? options.claims.auth_time : void 0;
|
|
630
|
+
this.refreshTokens.set(refreshToken, {
|
|
631
|
+
clientId: client.clientId,
|
|
632
|
+
sub: user.sub,
|
|
633
|
+
scope: options.scope,
|
|
634
|
+
sid,
|
|
635
|
+
authTime,
|
|
636
|
+
expiresAt: this.nowSeconds() + 30 * 24 * 3600
|
|
637
|
+
});
|
|
312
638
|
body.refresh_token = refreshToken;
|
|
313
639
|
}
|
|
640
|
+
if (user && idToken && (options.scope ?? "").split(" ").includes("openid")) {
|
|
641
|
+
const now = this.nowSeconds();
|
|
642
|
+
const signIn = Object.fromEntries(Object.entries(idToken.signIn).filter(([, value]) => value !== void 0));
|
|
643
|
+
const signed = await this.sign(
|
|
644
|
+
new SignJWT({
|
|
645
|
+
typ: "ID",
|
|
646
|
+
azp: client.clientId,
|
|
647
|
+
...signIn,
|
|
648
|
+
...idToken.nonce ? { nonce: idToken.nonce } : {},
|
|
649
|
+
at_hash: halfHash(accessToken, client.signingAlgorithm ?? "RS256"),
|
|
650
|
+
preferred_username: user.username,
|
|
651
|
+
...user.userInfo
|
|
652
|
+
}).setIssuer(this.issuer).setAudience(client.clientId).setSubject(user.sub).setIssuedAt(now).setExpirationTime(now + this.lifetime),
|
|
653
|
+
client,
|
|
654
|
+
"JWT"
|
|
655
|
+
);
|
|
656
|
+
body.id_token = encryption && encryption.idTokens !== false ? await encryptTo(signed, encryption) : signed;
|
|
657
|
+
}
|
|
314
658
|
return { status: 200, body };
|
|
315
659
|
}
|
|
316
660
|
async introspect(recorded, form) {
|
|
317
661
|
const client = await this.authenticate(recorded, form);
|
|
318
662
|
if ("status" in client) return client;
|
|
319
663
|
const issued = this.issued.get(form.token ?? "");
|
|
320
|
-
const now =
|
|
664
|
+
const now = this.nowSeconds();
|
|
665
|
+
const refresh = this.refreshTokens.get(form.token ?? "");
|
|
666
|
+
if (refresh)
|
|
667
|
+
return refresh.clientId === client.clientId ? {
|
|
668
|
+
status: 200,
|
|
669
|
+
body: {
|
|
670
|
+
active: true,
|
|
671
|
+
token_type: "Refresh",
|
|
672
|
+
client_id: refresh.clientId,
|
|
673
|
+
sub: refresh.sub,
|
|
674
|
+
exp: refresh.expiresAt,
|
|
675
|
+
...refresh.scope ? { scope: refresh.scope } : {},
|
|
676
|
+
...refresh.sid ? { sid: refresh.sid } : {}
|
|
677
|
+
}
|
|
678
|
+
} : { status: 200, body: { active: false } };
|
|
321
679
|
if (!issued || issued.revoked || (issued.claims.exp ?? 0) <= now) return { status: 200, body: { active: false } };
|
|
322
680
|
const audiences = [issued.claims.aud ?? []].flat();
|
|
323
681
|
const allowed = issued.clientId === client.clientId && audiences.includes(client.clientId) ? true : audiences.some((audience) => this.clients.get(audience)?.introspectionClients?.includes(client.clientId) || audience === client.clientId);
|
|
@@ -360,10 +718,29 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
360
718
|
const user = [...this.users.values()].find((candidate) => candidate.sub === issued.claims.sub);
|
|
361
719
|
const claims = { sub: issued.claims.sub, ...user?.userInfo };
|
|
362
720
|
if (!this.userInfoAsJwt) return send(response, { status: 200, body: claims });
|
|
363
|
-
const
|
|
721
|
+
const now = this.nowSeconds();
|
|
722
|
+
const signed = await this.sign(
|
|
723
|
+
new SignJWT(claims).setIssuer(this.issuer).setAudience(issued.clientId).setIssuedAt(now).setExpirationTime(now + this.lifetime),
|
|
724
|
+
this.clients.get(issued.clientId),
|
|
725
|
+
void 0
|
|
726
|
+
);
|
|
727
|
+
const encryption = this.clients.get(issued.clientId)?.encryption;
|
|
728
|
+
const jwt = encryption && encryption.userInfo !== false ? await encryptTo(signed, encryption) : signed;
|
|
364
729
|
response.writeHead(200, { "content-type": "application/jwt" });
|
|
365
730
|
response.end(jwt);
|
|
366
731
|
}
|
|
732
|
+
nowSeconds() {
|
|
733
|
+
return Math.floor(this.clock.now() / 1e3);
|
|
734
|
+
}
|
|
735
|
+
sign(builder, client, type, algorithm = client?.signingAlgorithm ?? "RS256") {
|
|
736
|
+
if (algorithm === "RS256") return builder.setProtectedHeader({ alg: "RS256", kid: this.keyId, ...type ? { typ: type } : {} }).sign(this.signingKey);
|
|
737
|
+
if (!client?.clientSecret) throw new Error(`The fake client ${client?.clientId} signs with ${algorithm} and has no clientSecret.`);
|
|
738
|
+
return builder.setProtectedHeader({ alg: algorithm, ...type ? { typ: type } : {} }).sign(new TextEncoder().encode(client.clientSecret));
|
|
739
|
+
}
|
|
740
|
+
verificationKeyFor(client, token) {
|
|
741
|
+
const algorithm = String(decodeProtectedHeader(token).alg ?? "");
|
|
742
|
+
return algorithm.startsWith("HS") && client?.clientSecret ? new TextEncoder().encode(client.clientSecret) : this.verificationKey;
|
|
743
|
+
}
|
|
367
744
|
requireUser(username) {
|
|
368
745
|
const user = this.users.get(username);
|
|
369
746
|
if (!user) throw new Error(`The fake has no user ${username}.`);
|
|
@@ -377,8 +754,35 @@ function endpointOf(path) {
|
|
|
377
754
|
if (path.endsWith("/userinfo")) return "userinfo";
|
|
378
755
|
if (path.endsWith("/auth/device")) return "device";
|
|
379
756
|
if (path.endsWith("/openid-configuration")) return "discovery";
|
|
757
|
+
if (path.endsWith("/auth")) return "authorize";
|
|
758
|
+
if (path.endsWith("/logout")) return "logout";
|
|
380
759
|
return void 0;
|
|
381
760
|
}
|
|
761
|
+
async function encryptTo(signed, encryption) {
|
|
762
|
+
const algorithm = encryption.algorithm ?? "RSA-OAEP-256";
|
|
763
|
+
const key = await importJWK({ ...encryption.key, alg: algorithm, use: "enc", key_ops: void 0 }, algorithm);
|
|
764
|
+
return new CompactEncrypt(new TextEncoder().encode(signed)).setProtectedHeader({ alg: algorithm, enc: encryption.encryptionMethod ?? "A256CBC-HS512", typ: "JWE", cty: "JWT", ...encryption.key.kid ? { kid: encryption.key.kid } : {} }).encrypt(key);
|
|
765
|
+
}
|
|
766
|
+
function verifiesChallenge(verifier, challenge, method) {
|
|
767
|
+
if (!verifier) return false;
|
|
768
|
+
return method === "S256" ? createHash("sha256").update(verifier, "ascii").digest("base64url") === challenge : verifier === challenge;
|
|
769
|
+
}
|
|
770
|
+
function halfHash(value, algorithm) {
|
|
771
|
+
const size = algorithm.endsWith("384") ? 384 : algorithm.endsWith("512") ? 512 : 256;
|
|
772
|
+
const digest = createHash(`sha${size}`).update(value, "ascii").digest();
|
|
773
|
+
return digest.subarray(0, digest.length / 2).toString("base64url");
|
|
774
|
+
}
|
|
775
|
+
function checkClient(client) {
|
|
776
|
+
for (const algorithm of [client.signingAlgorithm ?? "RS256", client.accessTokenSigningAlgorithm ?? "RS256"]) {
|
|
777
|
+
const minimum = { HS256: 32, HS384: 48, HS512: 64 }[algorithm];
|
|
778
|
+
if (minimum !== void 0 && Buffer.byteLength(client.clientSecret ?? "", "utf8") < minimum)
|
|
779
|
+
throw new Error(`The fake client ${client.clientId} signs with ${algorithm}, which needs a clientSecret of at least ${minimum} bytes (RFC 7518 \xA73.2).`);
|
|
780
|
+
}
|
|
781
|
+
return client;
|
|
782
|
+
}
|
|
783
|
+
function registeredMethodOf(client) {
|
|
784
|
+
return client.tokenEndpointAuthMethod ?? (client.clientSecret ? "client_secret_basic" : client.publicKey ? "private_key_jwt" : "none");
|
|
785
|
+
}
|
|
382
786
|
function send(response, answer) {
|
|
383
787
|
const headers = { ...answer.headers };
|
|
384
788
|
if (answer.body !== void 0) headers["content-type"] ??= "application/json";
|