@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.cjs
CHANGED
|
@@ -11,23 +11,35 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
11
11
|
requests = [];
|
|
12
12
|
/** Answers the next requests to an endpoint with these errors, in order, before answering normally. */
|
|
13
13
|
failures = /* @__PURE__ */ new Map();
|
|
14
|
+
/** Every back-channel logout token posted, in order. */
|
|
15
|
+
backchannelDeliveries = [];
|
|
16
|
+
/** Every front-channel logout page loaded, in order. */
|
|
17
|
+
frontchannelDeliveries = [];
|
|
14
18
|
/** What successive device-code polls answer; the last value repeats. Defaults to `["approved"]`. */
|
|
15
19
|
deviceOutcomes = ["approved"];
|
|
16
20
|
/** Overrides the discovery document's issuer, to test the mismatch check. */
|
|
17
21
|
discoveryIssuer;
|
|
18
|
-
/** Answers UserInfo as `application/jwt`, signed by the realm key. */
|
|
22
|
+
/** Answers UserInfo as `application/jwt`, signed by the realm key (and encrypted for a client with a key). */
|
|
19
23
|
userInfoAsJwt = false;
|
|
24
|
+
/** Leaves `iss` out of authorization responses, to test the RFC 9207 check. */
|
|
25
|
+
omitAuthorizationIssuer = false;
|
|
20
26
|
/** Answers connected-account exchanges. Defaults to granting a token for the provider. */
|
|
21
27
|
connectionResponder;
|
|
28
|
+
/** The person the authorization endpoint signs in when the request names none with `login_hint`. Defaults to the first user. */
|
|
29
|
+
signInAs;
|
|
22
30
|
clients = /* @__PURE__ */ new Map();
|
|
23
31
|
users = /* @__PURE__ */ new Map();
|
|
32
|
+
personalAccessTokens = /* @__PURE__ */ new Map();
|
|
24
33
|
codes = /* @__PURE__ */ new Map();
|
|
25
34
|
refreshTokens = /* @__PURE__ */ new Map();
|
|
26
35
|
issued = /* @__PURE__ */ new Map();
|
|
27
36
|
devicePolls = /* @__PURE__ */ new Map();
|
|
28
37
|
lifetime;
|
|
38
|
+
clock;
|
|
39
|
+
session;
|
|
29
40
|
server;
|
|
30
41
|
signingKey;
|
|
42
|
+
verificationKey;
|
|
31
43
|
untrustedKey;
|
|
32
44
|
publicJwk;
|
|
33
45
|
baseUrl;
|
|
@@ -36,8 +48,10 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
36
48
|
constructor(options) {
|
|
37
49
|
this.realm = options.realm ?? "fake";
|
|
38
50
|
this.lifetime = options.accessTokenLifetime ?? 300;
|
|
39
|
-
|
|
51
|
+
this.clock = options.clock ?? { now: () => Date.now() };
|
|
52
|
+
for (const client of options.clients ?? []) this.clients.set(client.clientId, checkClient(client));
|
|
40
53
|
for (const user of options.users ?? []) this.users.set(user.username, user);
|
|
54
|
+
for (const pat of options.personalAccessTokens ?? []) this.personalAccessTokens.set(pat.token, pat);
|
|
41
55
|
}
|
|
42
56
|
/**
|
|
43
57
|
* Starts the fake on a random loopback port.
|
|
@@ -59,14 +73,26 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
59
73
|
get jwks() {
|
|
60
74
|
return { keys: [this.publicJwk] };
|
|
61
75
|
}
|
|
76
|
+
/** The session the authorization endpoint keeps (one browser), or `undefined` when nobody is signed in. */
|
|
77
|
+
get currentSession() {
|
|
78
|
+
return this.session ? { sub: this.session.sub, sid: this.session.sid, authTime: this.session.authTime } : void 0;
|
|
79
|
+
}
|
|
62
80
|
/** Registers a client. */
|
|
63
81
|
addClient(client) {
|
|
64
|
-
this.clients.set(client.clientId, client);
|
|
82
|
+
this.clients.set(client.clientId, checkClient(client));
|
|
65
83
|
}
|
|
66
84
|
/** Registers a person. */
|
|
67
85
|
addUser(user) {
|
|
68
86
|
this.users.set(user.username, user);
|
|
69
87
|
}
|
|
88
|
+
/** Registers a personal access token. */
|
|
89
|
+
addPersonalAccessToken(token) {
|
|
90
|
+
this.personalAccessTokens.set(token.token, token);
|
|
91
|
+
}
|
|
92
|
+
/** Ends the signed-in session without notifying anyone, as if it expired. */
|
|
93
|
+
endSession() {
|
|
94
|
+
this.session = void 0;
|
|
95
|
+
}
|
|
70
96
|
/**
|
|
71
97
|
* Registers an authorization code for a person, as the authorization endpoint would after sign-in.
|
|
72
98
|
*/
|
|
@@ -79,15 +105,32 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
79
105
|
redirectUri: options.redirectUri,
|
|
80
106
|
scope: options.scope,
|
|
81
107
|
codeVerifier: options.codeChallengeVerifier,
|
|
82
|
-
resource: options.resource
|
|
108
|
+
resource: options.resource,
|
|
109
|
+
nonce: options.nonce
|
|
83
110
|
});
|
|
84
111
|
return code;
|
|
85
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Follows an authorization URL as a browser would, signing in `signInAs` (or the `login_hint`), and returns what
|
|
115
|
+
* Authio sent back: the callback URL for `response_mode=query`, the posted message's parameters for `web_message`.
|
|
116
|
+
*/
|
|
117
|
+
async authorize(authorizationUrl) {
|
|
118
|
+
const response = await fetch(authorizationUrl, { redirect: "manual" });
|
|
119
|
+
const location = response.headers.get("location");
|
|
120
|
+
if (location) {
|
|
121
|
+
const callback = new URL(location);
|
|
122
|
+
return { parameters: Object.fromEntries(callback.searchParams), callbackUrl: location };
|
|
123
|
+
}
|
|
124
|
+
const html = await response.text();
|
|
125
|
+
const message = /data-authorization-response='([^']*)'/.exec(html)?.[1];
|
|
126
|
+
if (!message) throw new Error(`The fake answered the authorization with HTTP ${response.status} and no response.`);
|
|
127
|
+
return { parameters: JSON.parse(decodeURIComponent(message)), callbackUrl: void 0 };
|
|
128
|
+
}
|
|
86
129
|
/**
|
|
87
130
|
* Mints an access token signed by the realm key, recorded so introspection and revocation know it.
|
|
88
131
|
*/
|
|
89
132
|
async issueAccessToken(options = {}) {
|
|
90
|
-
const now =
|
|
133
|
+
const now = this.nowSeconds();
|
|
91
134
|
const clientId = options.clientId ?? "fake-client";
|
|
92
135
|
const jti = crypto.randomUUID();
|
|
93
136
|
const payload = {
|
|
@@ -98,14 +141,101 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
98
141
|
...options.sub ? { sub: options.sub } : {},
|
|
99
142
|
...options.claims
|
|
100
143
|
};
|
|
101
|
-
const
|
|
102
|
-
|
|
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");
|
|
146
|
+
const token = options.encryptTo ? await encryptTo(signed, options.encryptTo) : signed;
|
|
147
|
+
this.issued.set(token, { claims: jose.decodeJwt(signed), clientId, revoked: false });
|
|
103
148
|
return token;
|
|
104
149
|
}
|
|
105
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Mints a back-channel logout token for a client, signed by the realm key. `overrides` replaces claims (`undefined`
|
|
152
|
+
* removes one) and `type` the header `typ`, to build invalid ones in tests.
|
|
153
|
+
*/
|
|
154
|
+
async createLogoutToken(options) {
|
|
155
|
+
const now = this.nowSeconds();
|
|
156
|
+
const payload = {
|
|
157
|
+
iss: this.issuer,
|
|
158
|
+
aud: options.clientId,
|
|
159
|
+
iat: now,
|
|
160
|
+
exp: now + 120,
|
|
161
|
+
jti: crypto.randomUUID(),
|
|
162
|
+
...options.sub ? { sub: options.sub } : {},
|
|
163
|
+
...options.sid ? { sid: options.sid } : {},
|
|
164
|
+
events: { "http://schemas.openid.net/event/backchannel-logout": {} },
|
|
165
|
+
...options.overrides
|
|
166
|
+
};
|
|
167
|
+
for (const key of Object.keys(payload)) if (payload[key] === void 0) delete payload[key];
|
|
168
|
+
const signed = await this.sign(new jose.SignJWT(payload), this.clients.get(options.clientId), options.type ?? "logout+jwt");
|
|
169
|
+
const encryption = this.clients.get(options.clientId)?.encryption;
|
|
170
|
+
return encryption?.logoutTokens ? encryptTo(signed, encryption) : signed;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Posts a back-channel logout token to the client's `backchannelLogoutUri`, as Authio does when a session ends.
|
|
174
|
+
*
|
|
175
|
+
* @returns The HTTP status the client answered, or 0 when it could not be reached.
|
|
176
|
+
*/
|
|
177
|
+
async deliverBackchannelLogout(clientId, options = {}) {
|
|
178
|
+
const client = this.clients.get(clientId);
|
|
179
|
+
if (!client?.backchannelLogoutUri) throw new Error(`The fake client ${clientId} has no backchannelLogoutUri.`);
|
|
180
|
+
const logoutToken = await this.createLogoutToken({ clientId, sub: options.sub, sid: options.sid });
|
|
181
|
+
let status = 0;
|
|
182
|
+
try {
|
|
183
|
+
const response = await fetch(client.backchannelLogoutUri, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
186
|
+
body: new URLSearchParams({ logout_token: logoutToken }).toString()
|
|
187
|
+
});
|
|
188
|
+
status = response.status;
|
|
189
|
+
await response.arrayBuffer();
|
|
190
|
+
} catch {
|
|
191
|
+
status = 0;
|
|
192
|
+
}
|
|
193
|
+
this.backchannelDeliveries.push({ clientId, uri: client.backchannelLogoutUri, logoutToken, status });
|
|
194
|
+
return status;
|
|
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
|
+
}
|
|
228
|
+
/** Marks a token revoked, as Authio would after a sign-out: an access token, or a refresh token, which is then forgotten. */
|
|
106
229
|
revokeToken(token) {
|
|
107
230
|
const issued = this.issued.get(token);
|
|
108
231
|
if (issued) issued.revoked = true;
|
|
232
|
+
this.refreshTokens.delete(token);
|
|
233
|
+
}
|
|
234
|
+
/** Revokes every refresh token of a person, or of one Authio session (`sid`), as Authio does when a session ends. */
|
|
235
|
+
revokeRefreshTokens(options) {
|
|
236
|
+
for (const [token, refresh] of this.refreshTokens)
|
|
237
|
+
if (options.sid !== void 0 && refresh.sid === options.sid || options.sid === void 0 && options.sub !== void 0 && refresh.sub === options.sub)
|
|
238
|
+
this.refreshTokens.delete(token);
|
|
109
239
|
}
|
|
110
240
|
/** Forgets the recorded requests. */
|
|
111
241
|
clearRequests() {
|
|
@@ -126,6 +256,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
126
256
|
const pair = await jose.generateKeyPair("RS256", { extractable: true });
|
|
127
257
|
const untrusted = await jose.generateKeyPair("RS256");
|
|
128
258
|
this.signingKey = pair.privateKey;
|
|
259
|
+
this.verificationKey = pair.publicKey;
|
|
129
260
|
this.untrustedKey = untrusted.privateKey;
|
|
130
261
|
this.publicJwk = { ...await jose.exportJWK(pair.publicKey), kid: this.keyId, alg: "RS256", use: "sig" };
|
|
131
262
|
this.server = http.createServer((request, response) => {
|
|
@@ -141,7 +272,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
141
272
|
const body = await readBody(request);
|
|
142
273
|
const form = Object.fromEntries(new URLSearchParams(request.headers["content-type"]?.includes("form") ? body : ""));
|
|
143
274
|
const headers = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name, Array.isArray(value) ? value.join(", ") : value ?? ""]));
|
|
144
|
-
const
|
|
275
|
+
const query = Object.fromEntries(url.searchParams);
|
|
276
|
+
const recorded = { method: request.method ?? "GET", path: url.pathname, headers, form, query };
|
|
145
277
|
this.requests.push(recorded);
|
|
146
278
|
const prefix = `/realms/${this.realm}`;
|
|
147
279
|
const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname;
|
|
@@ -153,6 +285,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
153
285
|
return send(response, { status: 200, body: this.discovery() });
|
|
154
286
|
case "/protocol/openid-connect/certs":
|
|
155
287
|
return send(response, { status: 200, body: this.jwks });
|
|
288
|
+
case "/protocol/openid-connect/auth":
|
|
289
|
+
return this.authorizeEndpoint(query, response);
|
|
156
290
|
case "/protocol/openid-connect/token":
|
|
157
291
|
return send(response, await this.token(recorded, form));
|
|
158
292
|
case "/protocol/openid-connect/token/introspect":
|
|
@@ -163,6 +297,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
163
297
|
return this.userInfo(headers, response);
|
|
164
298
|
case "/protocol/openid-connect/auth/device":
|
|
165
299
|
return send(response, await this.device(recorded, form));
|
|
300
|
+
case "/protocol/openid-connect/logout":
|
|
301
|
+
return this.endSessionEndpoint(query, response);
|
|
166
302
|
default:
|
|
167
303
|
return send(response, { status: 404, body: { error: "not_found" } });
|
|
168
304
|
}
|
|
@@ -179,46 +315,211 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
179
315
|
end_session_endpoint: `${base}/logout`,
|
|
180
316
|
device_authorization_endpoint: `${base}/auth/device`,
|
|
181
317
|
jwks_uri: `${base}/certs`,
|
|
182
|
-
|
|
318
|
+
scopes_supported: ["openid", "profile", "email", "phone", "roles", "offline_access"],
|
|
319
|
+
response_types_supported: ["code"],
|
|
320
|
+
response_modes_supported: ["query", "web_message"],
|
|
321
|
+
grant_types_supported: [
|
|
322
|
+
"authorization_code",
|
|
323
|
+
"refresh_token",
|
|
324
|
+
"client_credentials",
|
|
325
|
+
"password",
|
|
326
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
327
|
+
"urn:ietf:params:oauth:grant-type:token-exchange",
|
|
328
|
+
"personal_access_token"
|
|
329
|
+
],
|
|
330
|
+
subject_types_supported: ["public"],
|
|
331
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
332
|
+
id_token_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
|
|
333
|
+
id_token_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
|
|
334
|
+
userinfo_signing_alg_values_supported: ["RS256"],
|
|
335
|
+
userinfo_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
|
|
336
|
+
userinfo_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
|
|
183
337
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "private_key_jwt", "none"],
|
|
184
|
-
code_challenge_methods_supported: ["S256"],
|
|
185
|
-
|
|
338
|
+
code_challenge_methods_supported: ["S256", "plain"],
|
|
339
|
+
prompt_values_supported: ["none", "login", "select_account", "consent"],
|
|
340
|
+
display_values_supported: ["page", "popup"],
|
|
341
|
+
acr_values_supported: ["1", "2", "3"],
|
|
342
|
+
backchannel_logout_supported: true,
|
|
343
|
+
backchannel_logout_session_supported: true,
|
|
344
|
+
frontchannel_logout_supported: true,
|
|
345
|
+
frontchannel_logout_session_supported: true,
|
|
346
|
+
authorization_response_iss_parameter_supported: !this.omitAuthorizationIssuer
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
authorizeEndpoint(query, response) {
|
|
350
|
+
const client = this.clients.get(query.client_id ?? "");
|
|
351
|
+
if (!client) return send(response, { status: 400, body: { error: "invalid_request", error_description: "Unknown client_id." } });
|
|
352
|
+
if (!query.redirect_uri || client.redirectUris && !client.redirectUris.includes(query.redirect_uri))
|
|
353
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The redirect_uri is not registered." } });
|
|
354
|
+
const answer = (parameters) => {
|
|
355
|
+
const values = {};
|
|
356
|
+
for (const [name, value] of Object.entries({ ...parameters, state: query.state, iss: this.omitAuthorizationIssuer ? void 0 : this.issuer }))
|
|
357
|
+
if (value !== void 0) values[name] = value;
|
|
358
|
+
if (query.response_mode === "web_message") {
|
|
359
|
+
const message = encodeURIComponent(JSON.stringify(values));
|
|
360
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
361
|
+
response.end(
|
|
362
|
+
`<!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>`
|
|
363
|
+
);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const location = new URL(query.redirect_uri);
|
|
367
|
+
for (const [name, value] of Object.entries(values)) location.searchParams.set(name, value);
|
|
368
|
+
response.writeHead(302, { location: location.toString() });
|
|
369
|
+
response.end();
|
|
186
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.` });
|
|
373
|
+
if (query.response_type !== "code") return answer({ error: "unsupported_response_type", error_description: "Only code is supported." });
|
|
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
|
+
}
|
|
391
|
+
const prompts = (query.prompt ?? "").split(" ").filter(Boolean);
|
|
392
|
+
const now = this.nowSeconds();
|
|
393
|
+
const maxAge = query.max_age !== void 0 ? Number(query.max_age) : void 0;
|
|
394
|
+
const tooOld = this.session !== void 0 && maxAge !== void 0 && now - this.session.authTime > maxAge;
|
|
395
|
+
const requestedAcr = (query.acr_values ?? "").split(" ").filter(Boolean).sort().at(-1);
|
|
396
|
+
const weaker = this.session !== void 0 && requestedAcr !== void 0 && Number(this.session.acr) < Number(requestedAcr);
|
|
397
|
+
if (prompts.includes("none") && (!this.session || tooOld || weaker)) return answer({ error: "login_required", error_description: "Nobody is signed in." });
|
|
398
|
+
if (!this.session || prompts.includes("login") || tooOld || weaker || query.login_hint && this.users.get(query.login_hint)?.sub !== this.session.sub) {
|
|
399
|
+
const username = (query.login_hint && this.users.has(query.login_hint) ? query.login_hint : void 0) ?? this.signInAs ?? this.users.keys().next().value;
|
|
400
|
+
const user = username ? this.users.get(username) : void 0;
|
|
401
|
+
if (!user) return answer({ error: "access_denied", error_description: "The fake has no user to sign in." });
|
|
402
|
+
const acr = requestedAcr ?? user.acr ?? "1";
|
|
403
|
+
this.session = {
|
|
404
|
+
sub: user.sub,
|
|
405
|
+
sid: crypto.randomUUID().replace(/-/g, ""),
|
|
406
|
+
authTime: now,
|
|
407
|
+
acr,
|
|
408
|
+
amr: user.amr ?? (Number(acr) >= 2 ? ["pwd", "otp", "mfa"] : ["pwd"]),
|
|
409
|
+
clients: /* @__PURE__ */ new Set()
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
this.session.clients.add(client.clientId);
|
|
413
|
+
const code = crypto.randomUUID();
|
|
414
|
+
this.codes.set(code, {
|
|
415
|
+
clientId: client.clientId,
|
|
416
|
+
sub: this.session.sub,
|
|
417
|
+
redirectUri: query.redirect_uri,
|
|
418
|
+
scope: query.scope,
|
|
419
|
+
codeChallenge: query.code_challenge,
|
|
420
|
+
codeChallengeMethod: method,
|
|
421
|
+
resource: query.resource,
|
|
422
|
+
nonce: query.nonce,
|
|
423
|
+
sid: this.session.sid,
|
|
424
|
+
authTime: this.session.authTime,
|
|
425
|
+
acr: this.session.acr,
|
|
426
|
+
amr: this.session.amr
|
|
427
|
+
});
|
|
428
|
+
answer({ code });
|
|
429
|
+
}
|
|
430
|
+
async endSessionEndpoint(query, response) {
|
|
431
|
+
let clientId = query.client_id;
|
|
432
|
+
if (query.id_token_hint) {
|
|
433
|
+
try {
|
|
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
|
+
});
|
|
439
|
+
clientId ??= [payload.aud ?? []].flat()[0];
|
|
440
|
+
} catch {
|
|
441
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The id_token_hint is not valid." } });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const client = clientId ? this.clients.get(clientId) : void 0;
|
|
445
|
+
const redirect = query.post_logout_redirect_uri;
|
|
446
|
+
if (redirect && (!client || client.postLogoutRedirectUris && !client.postLogoutRedirectUris.includes(redirect)))
|
|
447
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The post_logout_redirect_uri is not registered." } });
|
|
448
|
+
const ended = this.session;
|
|
449
|
+
this.session = void 0;
|
|
450
|
+
if (ended) this.revokeRefreshTokens({ sid: ended.sid });
|
|
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
|
+
}
|
|
456
|
+
if (!redirect) {
|
|
457
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
458
|
+
response.end("<!doctype html><p>You are signed out.</p>");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const location = new URL(redirect);
|
|
462
|
+
if (query.state) location.searchParams.set("state", query.state);
|
|
463
|
+
response.writeHead(302, { location: location.toString() });
|
|
464
|
+
response.end();
|
|
187
465
|
}
|
|
188
466
|
async authenticate(recorded, form) {
|
|
189
467
|
const invalid = (description) => ({ status: 401, body: { error: "invalid_client", error_description: description } });
|
|
190
468
|
const authorization = recorded.headers.authorization;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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;
|
|
206
500
|
}
|
|
207
|
-
} else if (form.client_secret !== void 0) {
|
|
208
|
-
client = this.clients.get(form.client_id ?? "");
|
|
209
|
-
if (!client || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
|
|
210
|
-
} else {
|
|
211
|
-
client = this.clients.get(form.client_id ?? "");
|
|
212
|
-
if (!client || client.clientSecret || client.publicKey) return invalid("The client must authenticate.");
|
|
213
501
|
}
|
|
214
502
|
recorded.clientId = client.clientId;
|
|
215
503
|
return client;
|
|
216
504
|
}
|
|
217
505
|
async token(recorded, form) {
|
|
506
|
+
const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
|
|
507
|
+
if (form.grant_type === "personal_access_token") {
|
|
508
|
+
const pat = this.personalAccessTokens.get(form.client_secret ?? "");
|
|
509
|
+
if (!pat || form.client_id && pat.clientId && form.client_id !== pat.clientId)
|
|
510
|
+
return { status: 401, body: { error: "invalid_client", error_description: "The personal access token is not valid." } };
|
|
511
|
+
const clientId = pat.clientId ?? "authio";
|
|
512
|
+
recorded.clientId = clientId;
|
|
513
|
+
return this.tokenAnswer(this.clients.get(clientId) ?? { clientId }, {
|
|
514
|
+
sub: pat.sub,
|
|
515
|
+
scope: form.scope,
|
|
516
|
+
audience: form.resource,
|
|
517
|
+
claims: { amr: "personal_access_token", actor_type: "personal_access_token", credential_id: pat.sub, ...pat.claims }
|
|
518
|
+
});
|
|
519
|
+
}
|
|
218
520
|
const client = await this.authenticate(recorded, form);
|
|
219
521
|
if ("status" in client) return client;
|
|
220
522
|
const scope = form.scope ?? client.defaultScopes?.join(" ");
|
|
221
|
-
const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
|
|
222
523
|
switch (form.grant_type) {
|
|
223
524
|
case "client_credentials":
|
|
224
525
|
return this.tokenAnswer(client, { sub: client.clientId, scope, audience: form.resource, claims: client.claims });
|
|
@@ -231,12 +532,23 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
231
532
|
const code = this.codes.get(form.code ?? "");
|
|
232
533
|
if (!code || code.clientId !== client.clientId || code.redirectUri !== form.redirect_uri) return grant("invalid_grant", "The code is not valid.");
|
|
233
534
|
if (code.codeVerifier && code.codeVerifier !== form.code_verifier) return grant("invalid_grant", "PKCE verification failed.");
|
|
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"))
|
|
537
|
+
return grant("invalid_grant", "PKCE verification failed.");
|
|
234
538
|
this.codes.delete(form.code);
|
|
235
539
|
const user = [...this.users.values()].find((candidate) => candidate.sub === code.sub);
|
|
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"] };
|
|
236
541
|
return this.tokenAnswer(
|
|
237
542
|
client,
|
|
238
|
-
{
|
|
239
|
-
|
|
543
|
+
{
|
|
544
|
+
sub: user.sub,
|
|
545
|
+
scope: code.scope,
|
|
546
|
+
audience: form.resource ?? code.resource,
|
|
547
|
+
claims: { ...client.claims, ...user.claims, ...signIn, preferred_username: user.username }
|
|
548
|
+
},
|
|
549
|
+
user,
|
|
550
|
+
void 0,
|
|
551
|
+
{ nonce: code.nonce, signIn }
|
|
240
552
|
);
|
|
241
553
|
}
|
|
242
554
|
case "refresh_token": {
|
|
@@ -244,10 +556,13 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
244
556
|
if (!refresh || refresh.clientId !== client.clientId) return grant("invalid_grant", "The refresh token is not valid.");
|
|
245
557
|
this.refreshTokens.delete(form.refresh_token);
|
|
246
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 } : {} };
|
|
247
560
|
return this.tokenAnswer(
|
|
248
561
|
client,
|
|
249
|
-
{ sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims } },
|
|
250
|
-
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
|
|
251
566
|
);
|
|
252
567
|
}
|
|
253
568
|
case "urn:ietf:params:oauth:grant-type:token-exchange": {
|
|
@@ -263,12 +578,12 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
263
578
|
};
|
|
264
579
|
return "status" in answer && typeof answer.status === "number" ? answer : { status: 200, body: answer };
|
|
265
580
|
}
|
|
266
|
-
const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, ...claims } = subject.claims;
|
|
581
|
+
const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, scope: _scope, ...claims } = subject.claims;
|
|
267
582
|
return this.tokenAnswer(client, {
|
|
268
583
|
sub: subject.claims.sub,
|
|
269
584
|
scope: form.scope ?? subject.claims.scope,
|
|
270
585
|
audience: form.resource,
|
|
271
|
-
claims: { ...claims, act: { client_id: client.clientId
|
|
586
|
+
claims: { ...claims, act: { client_id: client.clientId } }
|
|
272
587
|
}, void 0, form.requested_token_type);
|
|
273
588
|
}
|
|
274
589
|
case "urn:ietf:params:oauth:grant-type:device_code": {
|
|
@@ -293,13 +608,15 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
293
608
|
return grant("unsupported_grant_type", `The grant ${form.grant_type} is not supported.`);
|
|
294
609
|
}
|
|
295
610
|
}
|
|
296
|
-
async tokenAnswer(client, options, user, issuedTokenType) {
|
|
611
|
+
async tokenAnswer(client, options, user, issuedTokenType, idToken) {
|
|
612
|
+
const encryption = client.encryption;
|
|
297
613
|
const accessToken = await this.issueAccessToken({
|
|
298
614
|
clientId: client.clientId,
|
|
299
615
|
sub: options.sub,
|
|
300
616
|
scope: options.scope,
|
|
301
617
|
audience: options.audience ?? client.clientId,
|
|
302
|
-
claims: options.claims
|
|
618
|
+
claims: options.claims,
|
|
619
|
+
...encryption?.accessTokens ? { encryptTo: encryption } : {}
|
|
303
620
|
});
|
|
304
621
|
const body = {
|
|
305
622
|
access_token: accessToken,
|
|
@@ -310,16 +627,57 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
310
627
|
};
|
|
311
628
|
if (user) {
|
|
312
629
|
const refreshToken = crypto.randomUUID();
|
|
313
|
-
|
|
630
|
+
const sid = typeof options.claims?.sid === "string" ? options.claims.sid : void 0;
|
|
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
|
+
});
|
|
314
640
|
body.refresh_token = refreshToken;
|
|
315
641
|
}
|
|
642
|
+
if (user && idToken && (options.scope ?? "").split(" ").includes("openid")) {
|
|
643
|
+
const now = this.nowSeconds();
|
|
644
|
+
const signIn = Object.fromEntries(Object.entries(idToken.signIn).filter(([, value]) => value !== void 0));
|
|
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
|
+
);
|
|
658
|
+
body.id_token = encryption && encryption.idTokens !== false ? await encryptTo(signed, encryption) : signed;
|
|
659
|
+
}
|
|
316
660
|
return { status: 200, body };
|
|
317
661
|
}
|
|
318
662
|
async introspect(recorded, form) {
|
|
319
663
|
const client = await this.authenticate(recorded, form);
|
|
320
664
|
if ("status" in client) return client;
|
|
321
665
|
const issued = this.issued.get(form.token ?? "");
|
|
322
|
-
const now =
|
|
666
|
+
const now = this.nowSeconds();
|
|
667
|
+
const refresh = this.refreshTokens.get(form.token ?? "");
|
|
668
|
+
if (refresh)
|
|
669
|
+
return refresh.clientId === client.clientId ? {
|
|
670
|
+
status: 200,
|
|
671
|
+
body: {
|
|
672
|
+
active: true,
|
|
673
|
+
token_type: "Refresh",
|
|
674
|
+
client_id: refresh.clientId,
|
|
675
|
+
sub: refresh.sub,
|
|
676
|
+
exp: refresh.expiresAt,
|
|
677
|
+
...refresh.scope ? { scope: refresh.scope } : {},
|
|
678
|
+
...refresh.sid ? { sid: refresh.sid } : {}
|
|
679
|
+
}
|
|
680
|
+
} : { status: 200, body: { active: false } };
|
|
323
681
|
if (!issued || issued.revoked || (issued.claims.exp ?? 0) <= now) return { status: 200, body: { active: false } };
|
|
324
682
|
const audiences = [issued.claims.aud ?? []].flat();
|
|
325
683
|
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);
|
|
@@ -362,10 +720,29 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
362
720
|
const user = [...this.users.values()].find((candidate) => candidate.sub === issued.claims.sub);
|
|
363
721
|
const claims = { sub: issued.claims.sub, ...user?.userInfo };
|
|
364
722
|
if (!this.userInfoAsJwt) return send(response, { status: 200, body: claims });
|
|
365
|
-
const
|
|
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
|
+
);
|
|
729
|
+
const encryption = this.clients.get(issued.clientId)?.encryption;
|
|
730
|
+
const jwt = encryption && encryption.userInfo !== false ? await encryptTo(signed, encryption) : signed;
|
|
366
731
|
response.writeHead(200, { "content-type": "application/jwt" });
|
|
367
732
|
response.end(jwt);
|
|
368
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
|
+
}
|
|
369
746
|
requireUser(username) {
|
|
370
747
|
const user = this.users.get(username);
|
|
371
748
|
if (!user) throw new Error(`The fake has no user ${username}.`);
|
|
@@ -379,8 +756,35 @@ function endpointOf(path) {
|
|
|
379
756
|
if (path.endsWith("/userinfo")) return "userinfo";
|
|
380
757
|
if (path.endsWith("/auth/device")) return "device";
|
|
381
758
|
if (path.endsWith("/openid-configuration")) return "discovery";
|
|
759
|
+
if (path.endsWith("/auth")) return "authorize";
|
|
760
|
+
if (path.endsWith("/logout")) return "logout";
|
|
382
761
|
return void 0;
|
|
383
762
|
}
|
|
763
|
+
async function encryptTo(signed, encryption) {
|
|
764
|
+
const algorithm = encryption.algorithm ?? "RSA-OAEP-256";
|
|
765
|
+
const key = await jose.importJWK({ ...encryption.key, alg: algorithm, use: "enc", key_ops: void 0 }, algorithm);
|
|
766
|
+
return new jose.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);
|
|
767
|
+
}
|
|
768
|
+
function verifiesChallenge(verifier, challenge, method) {
|
|
769
|
+
if (!verifier) return false;
|
|
770
|
+
return method === "S256" ? crypto.createHash("sha256").update(verifier, "ascii").digest("base64url") === challenge : verifier === challenge;
|
|
771
|
+
}
|
|
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");
|
|
787
|
+
}
|
|
384
788
|
function send(response, answer) {
|
|
385
789
|
const headers = { ...answer.headers };
|
|
386
790
|
if (answer.body !== void 0) headers["content-type"] ??= "application/json";
|