@hyper.authio/testing 0.1.1 → 0.7.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 +18 -4
- package/dist/index.cjs +306 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +134 -7
- package/dist/index.d.ts +134 -7
- package/dist/index.js +308 -21
- 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, CompactEncrypt } from 'jose';
|
|
4
4
|
import { AuthioWebhookHeaders, computeWebhookSignatureHeader } from '@hyper.authio/client';
|
|
5
5
|
|
|
6
6
|
// src/fake-authio.ts
|
|
@@ -9,23 +9,32 @@ 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 = [];
|
|
12
14
|
/** What successive device-code polls answer; the last value repeats. Defaults to `["approved"]`. */
|
|
13
15
|
deviceOutcomes = ["approved"];
|
|
14
16
|
/** Overrides the discovery document's issuer, to test the mismatch check. */
|
|
15
17
|
discoveryIssuer;
|
|
16
|
-
/** Answers UserInfo as `application/jwt`, signed by the realm key. */
|
|
18
|
+
/** Answers UserInfo as `application/jwt`, signed by the realm key (and encrypted for a client with a key). */
|
|
17
19
|
userInfoAsJwt = false;
|
|
20
|
+
/** Leaves `iss` out of authorization responses, to test the RFC 9207 check. */
|
|
21
|
+
omitAuthorizationIssuer = false;
|
|
18
22
|
/** Answers connected-account exchanges. Defaults to granting a token for the provider. */
|
|
19
23
|
connectionResponder;
|
|
24
|
+
/** The person the authorization endpoint signs in when the request names none with `login_hint`. Defaults to the first user. */
|
|
25
|
+
signInAs;
|
|
20
26
|
clients = /* @__PURE__ */ new Map();
|
|
21
27
|
users = /* @__PURE__ */ new Map();
|
|
28
|
+
personalAccessTokens = /* @__PURE__ */ new Map();
|
|
22
29
|
codes = /* @__PURE__ */ new Map();
|
|
23
30
|
refreshTokens = /* @__PURE__ */ new Map();
|
|
24
31
|
issued = /* @__PURE__ */ new Map();
|
|
25
32
|
devicePolls = /* @__PURE__ */ new Map();
|
|
26
33
|
lifetime;
|
|
34
|
+
session;
|
|
27
35
|
server;
|
|
28
36
|
signingKey;
|
|
37
|
+
verificationKey;
|
|
29
38
|
untrustedKey;
|
|
30
39
|
publicJwk;
|
|
31
40
|
baseUrl;
|
|
@@ -36,6 +45,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
36
45
|
this.lifetime = options.accessTokenLifetime ?? 300;
|
|
37
46
|
for (const client of options.clients ?? []) this.clients.set(client.clientId, client);
|
|
38
47
|
for (const user of options.users ?? []) this.users.set(user.username, user);
|
|
48
|
+
for (const pat of options.personalAccessTokens ?? []) this.personalAccessTokens.set(pat.token, pat);
|
|
39
49
|
}
|
|
40
50
|
/**
|
|
41
51
|
* Starts the fake on a random loopback port.
|
|
@@ -57,6 +67,10 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
57
67
|
get jwks() {
|
|
58
68
|
return { keys: [this.publicJwk] };
|
|
59
69
|
}
|
|
70
|
+
/** The session the authorization endpoint keeps (one browser), or `undefined` when nobody is signed in. */
|
|
71
|
+
get currentSession() {
|
|
72
|
+
return this.session ? { sub: this.session.sub, sid: this.session.sid, authTime: this.session.authTime } : void 0;
|
|
73
|
+
}
|
|
60
74
|
/** Registers a client. */
|
|
61
75
|
addClient(client) {
|
|
62
76
|
this.clients.set(client.clientId, client);
|
|
@@ -65,6 +79,14 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
65
79
|
addUser(user) {
|
|
66
80
|
this.users.set(user.username, user);
|
|
67
81
|
}
|
|
82
|
+
/** Registers a personal access token. */
|
|
83
|
+
addPersonalAccessToken(token) {
|
|
84
|
+
this.personalAccessTokens.set(token.token, token);
|
|
85
|
+
}
|
|
86
|
+
/** Ends the signed-in session without notifying anyone, as if it expired. */
|
|
87
|
+
endSession() {
|
|
88
|
+
this.session = void 0;
|
|
89
|
+
}
|
|
68
90
|
/**
|
|
69
91
|
* Registers an authorization code for a person, as the authorization endpoint would after sign-in.
|
|
70
92
|
*/
|
|
@@ -77,10 +99,27 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
77
99
|
redirectUri: options.redirectUri,
|
|
78
100
|
scope: options.scope,
|
|
79
101
|
codeVerifier: options.codeChallengeVerifier,
|
|
80
|
-
resource: options.resource
|
|
102
|
+
resource: options.resource,
|
|
103
|
+
nonce: options.nonce
|
|
81
104
|
});
|
|
82
105
|
return code;
|
|
83
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Follows an authorization URL as a browser would, signing in `signInAs` (or the `login_hint`), and returns what
|
|
109
|
+
* Authio sent back: the callback URL for `response_mode=query`, the posted message's parameters for `web_message`.
|
|
110
|
+
*/
|
|
111
|
+
async authorize(authorizationUrl) {
|
|
112
|
+
const response = await fetch(authorizationUrl, { redirect: "manual" });
|
|
113
|
+
const location = response.headers.get("location");
|
|
114
|
+
if (location) {
|
|
115
|
+
const callback = new URL(location);
|
|
116
|
+
return { parameters: Object.fromEntries(callback.searchParams), callbackUrl: location };
|
|
117
|
+
}
|
|
118
|
+
const html = await response.text();
|
|
119
|
+
const message = /data-authorization-response='([^']*)'/.exec(html)?.[1];
|
|
120
|
+
if (!message) throw new Error(`The fake answered the authorization with HTTP ${response.status} and no response.`);
|
|
121
|
+
return { parameters: JSON.parse(decodeURIComponent(message)), callbackUrl: void 0 };
|
|
122
|
+
}
|
|
84
123
|
/**
|
|
85
124
|
* Mints an access token signed by the realm key, recorded so introspection and revocation know it.
|
|
86
125
|
*/
|
|
@@ -96,14 +135,68 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
96
135
|
...options.sub ? { sub: options.sub } : {},
|
|
97
136
|
...options.claims
|
|
98
137
|
};
|
|
99
|
-
const
|
|
100
|
-
|
|
138
|
+
const signed = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId, typ: "JWT" }).setIssuer(options.issuer ?? this.issuer).setAudience(options.audience ?? clientId).setIssuedAt(now).setNotBefore(now).setExpirationTime(now + (options.expiresIn ?? this.lifetime)).setJti(jti).sign(options.untrusted ? this.untrustedKey : this.signingKey);
|
|
139
|
+
const token = options.encryptTo ? await encryptTo(signed, options.encryptTo) : signed;
|
|
140
|
+
this.issued.set(token, { claims: decodeJwt(signed), clientId, revoked: false });
|
|
101
141
|
return token;
|
|
102
142
|
}
|
|
103
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Mints a back-channel logout token for a client, signed by the realm key. `overrides` replaces claims (`undefined`
|
|
145
|
+
* removes one) and `type` the header `typ`, to build invalid ones in tests.
|
|
146
|
+
*/
|
|
147
|
+
async createLogoutToken(options) {
|
|
148
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
149
|
+
const payload = {
|
|
150
|
+
iss: this.issuer,
|
|
151
|
+
aud: options.clientId,
|
|
152
|
+
iat: now,
|
|
153
|
+
exp: now + 120,
|
|
154
|
+
jti: randomUUID(),
|
|
155
|
+
...options.sub ? { sub: options.sub } : {},
|
|
156
|
+
...options.sid ? { sid: options.sid } : {},
|
|
157
|
+
events: { "http://schemas.openid.net/event/backchannel-logout": {} },
|
|
158
|
+
...options.overrides
|
|
159
|
+
};
|
|
160
|
+
for (const key of Object.keys(payload)) if (payload[key] === void 0) delete payload[key];
|
|
161
|
+
const signed = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId, typ: options.type ?? "logout+jwt" }).sign(this.signingKey);
|
|
162
|
+
const encryption = this.clients.get(options.clientId)?.encryption;
|
|
163
|
+
return encryption?.logoutTokens ? encryptTo(signed, encryption) : signed;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Posts a back-channel logout token to the client's `backchannelLogoutUri`, as Authio does when a session ends.
|
|
167
|
+
*
|
|
168
|
+
* @returns The HTTP status the client answered, or 0 when it could not be reached.
|
|
169
|
+
*/
|
|
170
|
+
async deliverBackchannelLogout(clientId, options = {}) {
|
|
171
|
+
const client = this.clients.get(clientId);
|
|
172
|
+
if (!client?.backchannelLogoutUri) throw new Error(`The fake client ${clientId} has no backchannelLogoutUri.`);
|
|
173
|
+
const logoutToken = await this.createLogoutToken({ clientId, sub: options.sub, sid: options.sid });
|
|
174
|
+
let status = 0;
|
|
175
|
+
try {
|
|
176
|
+
const response = await fetch(client.backchannelLogoutUri, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
179
|
+
body: new URLSearchParams({ logout_token: logoutToken }).toString()
|
|
180
|
+
});
|
|
181
|
+
status = response.status;
|
|
182
|
+
await response.arrayBuffer();
|
|
183
|
+
} catch {
|
|
184
|
+
status = 0;
|
|
185
|
+
}
|
|
186
|
+
this.backchannelDeliveries.push({ clientId, uri: client.backchannelLogoutUri, logoutToken, status });
|
|
187
|
+
return status;
|
|
188
|
+
}
|
|
189
|
+
/** Marks a token revoked, as Authio would after a sign-out: an access token, or a refresh token, which is then forgotten. */
|
|
104
190
|
revokeToken(token) {
|
|
105
191
|
const issued = this.issued.get(token);
|
|
106
192
|
if (issued) issued.revoked = true;
|
|
193
|
+
this.refreshTokens.delete(token);
|
|
194
|
+
}
|
|
195
|
+
/** Revokes every refresh token of a person, or of one Authio session (`sid`), as Authio does when a session ends. */
|
|
196
|
+
revokeRefreshTokens(options) {
|
|
197
|
+
for (const [token, refresh] of this.refreshTokens)
|
|
198
|
+
if (options.sid !== void 0 && refresh.sid === options.sid || options.sid === void 0 && options.sub !== void 0 && refresh.sub === options.sub)
|
|
199
|
+
this.refreshTokens.delete(token);
|
|
107
200
|
}
|
|
108
201
|
/** Forgets the recorded requests. */
|
|
109
202
|
clearRequests() {
|
|
@@ -124,6 +217,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
124
217
|
const pair = await generateKeyPair("RS256", { extractable: true });
|
|
125
218
|
const untrusted = await generateKeyPair("RS256");
|
|
126
219
|
this.signingKey = pair.privateKey;
|
|
220
|
+
this.verificationKey = pair.publicKey;
|
|
127
221
|
this.untrustedKey = untrusted.privateKey;
|
|
128
222
|
this.publicJwk = { ...await exportJWK(pair.publicKey), kid: this.keyId, alg: "RS256", use: "sig" };
|
|
129
223
|
this.server = createServer((request, response) => {
|
|
@@ -139,7 +233,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
139
233
|
const body = await readBody(request);
|
|
140
234
|
const form = Object.fromEntries(new URLSearchParams(request.headers["content-type"]?.includes("form") ? body : ""));
|
|
141
235
|
const headers = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name, Array.isArray(value) ? value.join(", ") : value ?? ""]));
|
|
142
|
-
const
|
|
236
|
+
const query = Object.fromEntries(url.searchParams);
|
|
237
|
+
const recorded = { method: request.method ?? "GET", path: url.pathname, headers, form, query };
|
|
143
238
|
this.requests.push(recorded);
|
|
144
239
|
const prefix = `/realms/${this.realm}`;
|
|
145
240
|
const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname;
|
|
@@ -151,6 +246,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
151
246
|
return send(response, { status: 200, body: this.discovery() });
|
|
152
247
|
case "/protocol/openid-connect/certs":
|
|
153
248
|
return send(response, { status: 200, body: this.jwks });
|
|
249
|
+
case "/protocol/openid-connect/auth":
|
|
250
|
+
return this.authorizeEndpoint(query, response);
|
|
154
251
|
case "/protocol/openid-connect/token":
|
|
155
252
|
return send(response, await this.token(recorded, form));
|
|
156
253
|
case "/protocol/openid-connect/token/introspect":
|
|
@@ -161,6 +258,8 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
161
258
|
return this.userInfo(headers, response);
|
|
162
259
|
case "/protocol/openid-connect/auth/device":
|
|
163
260
|
return send(response, await this.device(recorded, form));
|
|
261
|
+
case "/protocol/openid-connect/logout":
|
|
262
|
+
return this.endSessionEndpoint(query, response);
|
|
164
263
|
default:
|
|
165
264
|
return send(response, { status: 404, body: { error: "not_found" } });
|
|
166
265
|
}
|
|
@@ -177,12 +276,129 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
177
276
|
end_session_endpoint: `${base}/logout`,
|
|
178
277
|
device_authorization_endpoint: `${base}/auth/device`,
|
|
179
278
|
jwks_uri: `${base}/certs`,
|
|
180
|
-
|
|
279
|
+
scopes_supported: ["openid", "profile", "email", "phone", "roles", "offline_access"],
|
|
280
|
+
response_types_supported: ["code"],
|
|
281
|
+
response_modes_supported: ["query", "web_message"],
|
|
282
|
+
grant_types_supported: [
|
|
283
|
+
"authorization_code",
|
|
284
|
+
"refresh_token",
|
|
285
|
+
"client_credentials",
|
|
286
|
+
"password",
|
|
287
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
288
|
+
"urn:ietf:params:oauth:grant-type:token-exchange",
|
|
289
|
+
"personal_access_token"
|
|
290
|
+
],
|
|
291
|
+
subject_types_supported: ["public"],
|
|
292
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
293
|
+
id_token_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
|
|
294
|
+
id_token_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
|
|
295
|
+
userinfo_signing_alg_values_supported: ["RS256"],
|
|
296
|
+
userinfo_encryption_alg_values_supported: ["RSA-OAEP", "RSA-OAEP-256"],
|
|
297
|
+
userinfo_encryption_enc_values_supported: ["A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512"],
|
|
181
298
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "private_key_jwt", "none"],
|
|
182
|
-
code_challenge_methods_supported: ["S256"],
|
|
183
|
-
|
|
299
|
+
code_challenge_methods_supported: ["S256", "plain"],
|
|
300
|
+
prompt_values_supported: ["none", "login", "select_account", "consent"],
|
|
301
|
+
display_values_supported: ["page", "popup"],
|
|
302
|
+
acr_values_supported: ["1", "2", "3"],
|
|
303
|
+
backchannel_logout_supported: true,
|
|
304
|
+
backchannel_logout_session_supported: true,
|
|
305
|
+
authorization_response_iss_parameter_supported: !this.omitAuthorizationIssuer
|
|
184
306
|
};
|
|
185
307
|
}
|
|
308
|
+
authorizeEndpoint(query, response) {
|
|
309
|
+
const client = this.clients.get(query.client_id ?? "");
|
|
310
|
+
if (!client) return send(response, { status: 400, body: { error: "invalid_request", error_description: "Unknown client_id." } });
|
|
311
|
+
if (!query.redirect_uri || client.redirectUris && !client.redirectUris.includes(query.redirect_uri))
|
|
312
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The redirect_uri is not registered." } });
|
|
313
|
+
const answer = (parameters) => {
|
|
314
|
+
const values = {};
|
|
315
|
+
for (const [name, value] of Object.entries({ ...parameters, state: query.state, iss: this.omitAuthorizationIssuer ? void 0 : this.issuer }))
|
|
316
|
+
if (value !== void 0) values[name] = value;
|
|
317
|
+
if (query.response_mode === "web_message") {
|
|
318
|
+
const message = encodeURIComponent(JSON.stringify(values));
|
|
319
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
320
|
+
response.end(
|
|
321
|
+
`<!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>`
|
|
322
|
+
);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const location = new URL(query.redirect_uri);
|
|
326
|
+
for (const [name, value] of Object.entries(values)) location.searchParams.set(name, value);
|
|
327
|
+
response.writeHead(302, { location: location.toString() });
|
|
328
|
+
response.end();
|
|
329
|
+
};
|
|
330
|
+
if (query.response_type !== "code") return answer({ error: "unsupported_response_type", error_description: "Only code is supported." });
|
|
331
|
+
const method = query.code_challenge_method ?? (query.code_challenge ? "plain" : void 0);
|
|
332
|
+
if (method && method !== "S256" && method !== "plain") return answer({ error: "invalid_request", error_description: "Unsupported PKCE method." });
|
|
333
|
+
const prompts = (query.prompt ?? "").split(" ").filter(Boolean);
|
|
334
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
335
|
+
const maxAge = query.max_age !== void 0 ? Number(query.max_age) : void 0;
|
|
336
|
+
const tooOld = this.session !== void 0 && maxAge !== void 0 && now - this.session.authTime > maxAge;
|
|
337
|
+
const requestedAcr = (query.acr_values ?? "").split(" ").filter(Boolean).sort().at(-1);
|
|
338
|
+
const weaker = this.session !== void 0 && requestedAcr !== void 0 && Number(this.session.acr) < Number(requestedAcr);
|
|
339
|
+
if (prompts.includes("none") && (!this.session || tooOld || weaker)) return answer({ error: "login_required", error_description: "Nobody is signed in." });
|
|
340
|
+
if (!this.session || prompts.includes("login") || tooOld || weaker || query.login_hint && this.users.get(query.login_hint)?.sub !== this.session.sub) {
|
|
341
|
+
const username = (query.login_hint && this.users.has(query.login_hint) ? query.login_hint : void 0) ?? this.signInAs ?? this.users.keys().next().value;
|
|
342
|
+
const user = username ? this.users.get(username) : void 0;
|
|
343
|
+
if (!user) return answer({ error: "access_denied", error_description: "The fake has no user to sign in." });
|
|
344
|
+
const acr = requestedAcr ?? user.acr ?? "1";
|
|
345
|
+
this.session = {
|
|
346
|
+
sub: user.sub,
|
|
347
|
+
sid: randomUUID().replace(/-/g, ""),
|
|
348
|
+
authTime: now,
|
|
349
|
+
acr,
|
|
350
|
+
amr: user.amr ?? (Number(acr) >= 2 ? ["pwd", "otp", "mfa"] : ["pwd"]),
|
|
351
|
+
clients: /* @__PURE__ */ new Set()
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
this.session.clients.add(client.clientId);
|
|
355
|
+
const code = randomUUID();
|
|
356
|
+
this.codes.set(code, {
|
|
357
|
+
clientId: client.clientId,
|
|
358
|
+
sub: this.session.sub,
|
|
359
|
+
redirectUri: query.redirect_uri,
|
|
360
|
+
scope: query.scope,
|
|
361
|
+
codeChallenge: query.code_challenge,
|
|
362
|
+
codeChallengeMethod: method,
|
|
363
|
+
resource: query.resource,
|
|
364
|
+
nonce: query.nonce,
|
|
365
|
+
sid: this.session.sid,
|
|
366
|
+
authTime: this.session.authTime,
|
|
367
|
+
acr: this.session.acr,
|
|
368
|
+
amr: this.session.amr
|
|
369
|
+
});
|
|
370
|
+
answer({ code });
|
|
371
|
+
}
|
|
372
|
+
async endSessionEndpoint(query, response) {
|
|
373
|
+
let clientId = query.client_id;
|
|
374
|
+
if (query.id_token_hint) {
|
|
375
|
+
try {
|
|
376
|
+
const { payload } = await jwtVerify(query.id_token_hint, this.verificationKey, { issuer: this.issuer });
|
|
377
|
+
clientId ??= [payload.aud ?? []].flat()[0];
|
|
378
|
+
} catch {
|
|
379
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The id_token_hint is not valid." } });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const client = clientId ? this.clients.get(clientId) : void 0;
|
|
383
|
+
const redirect = query.post_logout_redirect_uri;
|
|
384
|
+
if (redirect && (!client || client.postLogoutRedirectUris && !client.postLogoutRedirectUris.includes(redirect)))
|
|
385
|
+
return send(response, { status: 400, body: { error: "invalid_request", error_description: "The post_logout_redirect_uri is not registered." } });
|
|
386
|
+
const ended = this.session;
|
|
387
|
+
this.session = void 0;
|
|
388
|
+
if (ended) this.revokeRefreshTokens({ sid: ended.sid });
|
|
389
|
+
if (ended) {
|
|
390
|
+
for (const id of ended.clients) if (this.clients.get(id)?.backchannelLogoutUri) await this.deliverBackchannelLogout(id, { sub: ended.sub, sid: ended.sid });
|
|
391
|
+
}
|
|
392
|
+
if (!redirect) {
|
|
393
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
394
|
+
response.end("<!doctype html><p>You are signed out.</p>");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const location = new URL(redirect);
|
|
398
|
+
if (query.state) location.searchParams.set("state", query.state);
|
|
399
|
+
response.writeHead(302, { location: location.toString() });
|
|
400
|
+
response.end();
|
|
401
|
+
}
|
|
186
402
|
async authenticate(recorded, form) {
|
|
187
403
|
const invalid = (description) => ({ status: 401, body: { error: "invalid_client", error_description: description } });
|
|
188
404
|
const authorization = recorded.headers.authorization;
|
|
@@ -213,10 +429,23 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
213
429
|
return client;
|
|
214
430
|
}
|
|
215
431
|
async token(recorded, form) {
|
|
432
|
+
const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
|
|
433
|
+
if (form.grant_type === "personal_access_token") {
|
|
434
|
+
const pat = this.personalAccessTokens.get(form.client_secret ?? "");
|
|
435
|
+
if (!pat || form.client_id && pat.clientId && form.client_id !== pat.clientId)
|
|
436
|
+
return { status: 401, body: { error: "invalid_client", error_description: "The personal access token is not valid." } };
|
|
437
|
+
const clientId = pat.clientId ?? "authio";
|
|
438
|
+
recorded.clientId = clientId;
|
|
439
|
+
return this.tokenAnswer(this.clients.get(clientId) ?? { clientId }, {
|
|
440
|
+
sub: pat.sub,
|
|
441
|
+
scope: form.scope,
|
|
442
|
+
audience: form.resource,
|
|
443
|
+
claims: { amr: "personal_access_token", actor_type: "personal_access_token", credential_id: pat.sub, ...pat.claims }
|
|
444
|
+
});
|
|
445
|
+
}
|
|
216
446
|
const client = await this.authenticate(recorded, form);
|
|
217
447
|
if ("status" in client) return client;
|
|
218
448
|
const scope = form.scope ?? client.defaultScopes?.join(" ");
|
|
219
|
-
const grant = (error, description) => ({ status: 400, body: { error, error_description: description } });
|
|
220
449
|
switch (form.grant_type) {
|
|
221
450
|
case "client_credentials":
|
|
222
451
|
return this.tokenAnswer(client, { sub: client.clientId, scope, audience: form.resource, claims: client.claims });
|
|
@@ -229,12 +458,22 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
229
458
|
const code = this.codes.get(form.code ?? "");
|
|
230
459
|
if (!code || code.clientId !== client.clientId || code.redirectUri !== form.redirect_uri) return grant("invalid_grant", "The code is not valid.");
|
|
231
460
|
if (code.codeVerifier && code.codeVerifier !== form.code_verifier) return grant("invalid_grant", "PKCE verification failed.");
|
|
461
|
+
if (code.codeChallenge && !verifiesChallenge(form.code_verifier, code.codeChallenge, code.codeChallengeMethod ?? "plain"))
|
|
462
|
+
return grant("invalid_grant", "PKCE verification failed.");
|
|
232
463
|
this.codes.delete(form.code);
|
|
233
464
|
const user = [...this.users.values()].find((candidate) => candidate.sub === code.sub);
|
|
465
|
+
const signIn = code.sid ? { sid: code.sid, auth_time: code.authTime, acr: code.acr, amr: code.amr } : { auth_time: Math.floor(Date.now() / 1e3), acr: user.acr ?? "1", amr: user.amr ?? ["pwd"] };
|
|
234
466
|
return this.tokenAnswer(
|
|
235
467
|
client,
|
|
236
|
-
{
|
|
237
|
-
|
|
468
|
+
{
|
|
469
|
+
sub: user.sub,
|
|
470
|
+
scope: code.scope,
|
|
471
|
+
audience: form.resource ?? code.resource,
|
|
472
|
+
claims: { ...client.claims, ...user.claims, ...signIn, preferred_username: user.username }
|
|
473
|
+
},
|
|
474
|
+
user,
|
|
475
|
+
void 0,
|
|
476
|
+
{ nonce: code.nonce, signIn }
|
|
238
477
|
);
|
|
239
478
|
}
|
|
240
479
|
case "refresh_token": {
|
|
@@ -244,7 +483,7 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
244
483
|
const user = [...this.users.values()].find((candidate) => candidate.sub === refresh.sub);
|
|
245
484
|
return this.tokenAnswer(
|
|
246
485
|
client,
|
|
247
|
-
{ sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims } },
|
|
486
|
+
{ sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...refresh.sid ? { sid: refresh.sid } : {} } },
|
|
248
487
|
user
|
|
249
488
|
);
|
|
250
489
|
}
|
|
@@ -261,12 +500,12 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
261
500
|
};
|
|
262
501
|
return "status" in answer && typeof answer.status === "number" ? answer : { status: 200, body: answer };
|
|
263
502
|
}
|
|
264
|
-
const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, ...claims } = subject.claims;
|
|
503
|
+
const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, scope: _scope, ...claims } = subject.claims;
|
|
265
504
|
return this.tokenAnswer(client, {
|
|
266
505
|
sub: subject.claims.sub,
|
|
267
506
|
scope: form.scope ?? subject.claims.scope,
|
|
268
507
|
audience: form.resource,
|
|
269
|
-
claims: { ...claims, act: { client_id: client.clientId
|
|
508
|
+
claims: { ...claims, act: { client_id: client.clientId } }
|
|
270
509
|
}, void 0, form.requested_token_type);
|
|
271
510
|
}
|
|
272
511
|
case "urn:ietf:params:oauth:grant-type:device_code": {
|
|
@@ -291,13 +530,15 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
291
530
|
return grant("unsupported_grant_type", `The grant ${form.grant_type} is not supported.`);
|
|
292
531
|
}
|
|
293
532
|
}
|
|
294
|
-
async tokenAnswer(client, options, user, issuedTokenType) {
|
|
533
|
+
async tokenAnswer(client, options, user, issuedTokenType, idToken) {
|
|
534
|
+
const encryption = client.encryption;
|
|
295
535
|
const accessToken = await this.issueAccessToken({
|
|
296
536
|
clientId: client.clientId,
|
|
297
537
|
sub: options.sub,
|
|
298
538
|
scope: options.scope,
|
|
299
539
|
audience: options.audience ?? client.clientId,
|
|
300
|
-
claims: options.claims
|
|
540
|
+
claims: options.claims,
|
|
541
|
+
...encryption?.accessTokens ? { encryptTo: encryption } : {}
|
|
301
542
|
});
|
|
302
543
|
const body = {
|
|
303
544
|
access_token: accessToken,
|
|
@@ -308,9 +549,24 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
308
549
|
};
|
|
309
550
|
if (user) {
|
|
310
551
|
const refreshToken = randomUUID();
|
|
311
|
-
|
|
552
|
+
const sid = typeof options.claims?.sid === "string" ? options.claims.sid : void 0;
|
|
553
|
+
this.refreshTokens.set(refreshToken, { clientId: client.clientId, sub: user.sub, scope: options.scope, sid, expiresAt: Math.floor(Date.now() / 1e3) + 30 * 24 * 3600 });
|
|
312
554
|
body.refresh_token = refreshToken;
|
|
313
555
|
}
|
|
556
|
+
if (user && idToken && (options.scope ?? "").split(" ").includes("openid")) {
|
|
557
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
558
|
+
const signIn = Object.fromEntries(Object.entries(idToken.signIn).filter(([, value]) => value !== void 0));
|
|
559
|
+
const signed = await new SignJWT({
|
|
560
|
+
typ: "ID",
|
|
561
|
+
azp: client.clientId,
|
|
562
|
+
...signIn,
|
|
563
|
+
...idToken.nonce ? { nonce: idToken.nonce } : {},
|
|
564
|
+
at_hash: halfHash(accessToken),
|
|
565
|
+
preferred_username: user.username,
|
|
566
|
+
...user.userInfo
|
|
567
|
+
}).setProtectedHeader({ alg: "RS256", kid: this.keyId, typ: "JWT" }).setIssuer(this.issuer).setAudience(client.clientId).setSubject(user.sub).setIssuedAt(now).setExpirationTime(now + this.lifetime).sign(this.signingKey);
|
|
568
|
+
body.id_token = encryption && encryption.idTokens !== false ? await encryptTo(signed, encryption) : signed;
|
|
569
|
+
}
|
|
314
570
|
return { status: 200, body };
|
|
315
571
|
}
|
|
316
572
|
async introspect(recorded, form) {
|
|
@@ -318,6 +574,20 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
318
574
|
if ("status" in client) return client;
|
|
319
575
|
const issued = this.issued.get(form.token ?? "");
|
|
320
576
|
const now = Math.floor(Date.now() / 1e3);
|
|
577
|
+
const refresh = this.refreshTokens.get(form.token ?? "");
|
|
578
|
+
if (refresh)
|
|
579
|
+
return refresh.clientId === client.clientId ? {
|
|
580
|
+
status: 200,
|
|
581
|
+
body: {
|
|
582
|
+
active: true,
|
|
583
|
+
token_type: "Refresh",
|
|
584
|
+
client_id: refresh.clientId,
|
|
585
|
+
sub: refresh.sub,
|
|
586
|
+
exp: refresh.expiresAt,
|
|
587
|
+
...refresh.scope ? { scope: refresh.scope } : {},
|
|
588
|
+
...refresh.sid ? { sid: refresh.sid } : {}
|
|
589
|
+
}
|
|
590
|
+
} : { status: 200, body: { active: false } };
|
|
321
591
|
if (!issued || issued.revoked || (issued.claims.exp ?? 0) <= now) return { status: 200, body: { active: false } };
|
|
322
592
|
const audiences = [issued.claims.aud ?? []].flat();
|
|
323
593
|
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,7 +630,10 @@ var FakeAuthio = class _FakeAuthio {
|
|
|
360
630
|
const user = [...this.users.values()].find((candidate) => candidate.sub === issued.claims.sub);
|
|
361
631
|
const claims = { sub: issued.claims.sub, ...user?.userInfo };
|
|
362
632
|
if (!this.userInfoAsJwt) return send(response, { status: 200, body: claims });
|
|
363
|
-
const
|
|
633
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
634
|
+
const signed = await new SignJWT(claims).setProtectedHeader({ alg: "RS256", kid: this.keyId }).setIssuer(this.issuer).setAudience(issued.clientId).setIssuedAt(now).setExpirationTime(now + this.lifetime).sign(this.signingKey);
|
|
635
|
+
const encryption = this.clients.get(issued.clientId)?.encryption;
|
|
636
|
+
const jwt = encryption && encryption.userInfo !== false ? await encryptTo(signed, encryption) : signed;
|
|
364
637
|
response.writeHead(200, { "content-type": "application/jwt" });
|
|
365
638
|
response.end(jwt);
|
|
366
639
|
}
|
|
@@ -377,8 +650,22 @@ function endpointOf(path) {
|
|
|
377
650
|
if (path.endsWith("/userinfo")) return "userinfo";
|
|
378
651
|
if (path.endsWith("/auth/device")) return "device";
|
|
379
652
|
if (path.endsWith("/openid-configuration")) return "discovery";
|
|
653
|
+
if (path.endsWith("/auth")) return "authorize";
|
|
654
|
+
if (path.endsWith("/logout")) return "logout";
|
|
380
655
|
return void 0;
|
|
381
656
|
}
|
|
657
|
+
async function encryptTo(signed, encryption) {
|
|
658
|
+
const algorithm = encryption.algorithm ?? "RSA-OAEP-256";
|
|
659
|
+
const key = await importJWK({ ...encryption.key, alg: algorithm, use: "enc", key_ops: void 0 }, algorithm);
|
|
660
|
+
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);
|
|
661
|
+
}
|
|
662
|
+
function verifiesChallenge(verifier, challenge, method) {
|
|
663
|
+
if (!verifier) return false;
|
|
664
|
+
return method === "S256" ? createHash("sha256").update(verifier, "ascii").digest("base64url") === challenge : verifier === challenge;
|
|
665
|
+
}
|
|
666
|
+
function halfHash(value) {
|
|
667
|
+
return createHash("sha256").update(value, "ascii").digest().subarray(0, 16).toString("base64url");
|
|
668
|
+
}
|
|
382
669
|
function send(response, answer) {
|
|
383
670
|
const headers = { ...answer.headers };
|
|
384
671
|
if (answer.body !== void 0) headers["content-type"] ??= "application/json";
|