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