@hyper.authio/testing 0.0.0-stage → 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 +58 -2
- package/dist/index.cjs +740 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +342 -0
- package/dist/index.d.ts +342 -0
- package/dist/index.js +736 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { randomUUID, createHash } from 'crypto';
|
|
3
|
+
import { SignJWT, decodeJwt, generateKeyPair, exportJWK, jwtVerify, importJWK, CompactEncrypt } from 'jose';
|
|
4
|
+
import { AuthioWebhookHeaders, computeWebhookSignatureHeader } from '@hyper.authio/client';
|
|
5
|
+
|
|
6
|
+
// src/fake-authio.ts
|
|
7
|
+
var FakeAuthio = class _FakeAuthio {
|
|
8
|
+
/** Every request received, in order. */
|
|
9
|
+
requests = [];
|
|
10
|
+
/** Answers the next requests to an endpoint with these errors, in order, before answering normally. */
|
|
11
|
+
failures = /* @__PURE__ */ new Map();
|
|
12
|
+
/** Every back-channel logout token posted, in order. */
|
|
13
|
+
backchannelDeliveries = [];
|
|
14
|
+
/** What successive device-code polls answer; the last value repeats. Defaults to `["approved"]`. */
|
|
15
|
+
deviceOutcomes = ["approved"];
|
|
16
|
+
/** Overrides the discovery document's issuer, to test the mismatch check. */
|
|
17
|
+
discoveryIssuer;
|
|
18
|
+
/** Answers UserInfo as `application/jwt`, signed by the realm key (and encrypted for a client with a key). */
|
|
19
|
+
userInfoAsJwt = false;
|
|
20
|
+
/** Leaves `iss` out of authorization responses, to test the RFC 9207 check. */
|
|
21
|
+
omitAuthorizationIssuer = false;
|
|
22
|
+
/** Answers connected-account exchanges. Defaults to granting a token for the provider. */
|
|
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;
|
|
26
|
+
clients = /* @__PURE__ */ new Map();
|
|
27
|
+
users = /* @__PURE__ */ new Map();
|
|
28
|
+
personalAccessTokens = /* @__PURE__ */ new Map();
|
|
29
|
+
codes = /* @__PURE__ */ new Map();
|
|
30
|
+
refreshTokens = /* @__PURE__ */ new Map();
|
|
31
|
+
issued = /* @__PURE__ */ new Map();
|
|
32
|
+
devicePolls = /* @__PURE__ */ new Map();
|
|
33
|
+
lifetime;
|
|
34
|
+
session;
|
|
35
|
+
server;
|
|
36
|
+
signingKey;
|
|
37
|
+
verificationKey;
|
|
38
|
+
untrustedKey;
|
|
39
|
+
publicJwk;
|
|
40
|
+
baseUrl;
|
|
41
|
+
realm;
|
|
42
|
+
keyId = "fake-key-1";
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.realm = options.realm ?? "fake";
|
|
45
|
+
this.lifetime = options.accessTokenLifetime ?? 300;
|
|
46
|
+
for (const client of options.clients ?? []) this.clients.set(client.clientId, client);
|
|
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);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Starts the fake on a random loopback port.
|
|
52
|
+
*/
|
|
53
|
+
static async start(options = {}) {
|
|
54
|
+
const fake = new _FakeAuthio(options);
|
|
55
|
+
await fake.listen();
|
|
56
|
+
return fake;
|
|
57
|
+
}
|
|
58
|
+
/** `http://127.0.0.1:{port}`. */
|
|
59
|
+
get authority() {
|
|
60
|
+
return this.baseUrl;
|
|
61
|
+
}
|
|
62
|
+
/** `{authority}/realms/{realm}`. */
|
|
63
|
+
get issuer() {
|
|
64
|
+
return `${this.baseUrl}/realms/${this.realm}`;
|
|
65
|
+
}
|
|
66
|
+
/** The realm's public signing key. */
|
|
67
|
+
get jwks() {
|
|
68
|
+
return { keys: [this.publicJwk] };
|
|
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
|
+
}
|
|
74
|
+
/** Registers a client. */
|
|
75
|
+
addClient(client) {
|
|
76
|
+
this.clients.set(client.clientId, client);
|
|
77
|
+
}
|
|
78
|
+
/** Registers a person. */
|
|
79
|
+
addUser(user) {
|
|
80
|
+
this.users.set(user.username, user);
|
|
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
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Registers an authorization code for a person, as the authorization endpoint would after sign-in.
|
|
92
|
+
*/
|
|
93
|
+
createAuthorizationCode(options) {
|
|
94
|
+
const user = this.requireUser(options.username);
|
|
95
|
+
const code = randomUUID();
|
|
96
|
+
this.codes.set(code, {
|
|
97
|
+
clientId: options.clientId,
|
|
98
|
+
sub: user.sub,
|
|
99
|
+
redirectUri: options.redirectUri,
|
|
100
|
+
scope: options.scope,
|
|
101
|
+
codeVerifier: options.codeChallengeVerifier,
|
|
102
|
+
resource: options.resource,
|
|
103
|
+
nonce: options.nonce
|
|
104
|
+
});
|
|
105
|
+
return code;
|
|
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
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Mints an access token signed by the realm key, recorded so introspection and revocation know it.
|
|
125
|
+
*/
|
|
126
|
+
async issueAccessToken(options = {}) {
|
|
127
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
128
|
+
const clientId = options.clientId ?? "fake-client";
|
|
129
|
+
const jti = randomUUID();
|
|
130
|
+
const payload = {
|
|
131
|
+
typ: options.type ?? "Bearer",
|
|
132
|
+
azp: clientId,
|
|
133
|
+
client_id: clientId,
|
|
134
|
+
...options.scope ? { scope: options.scope } : {},
|
|
135
|
+
...options.sub ? { sub: options.sub } : {},
|
|
136
|
+
...options.claims
|
|
137
|
+
};
|
|
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 });
|
|
141
|
+
return token;
|
|
142
|
+
}
|
|
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. */
|
|
190
|
+
revokeToken(token) {
|
|
191
|
+
const issued = this.issued.get(token);
|
|
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);
|
|
200
|
+
}
|
|
201
|
+
/** Forgets the recorded requests. */
|
|
202
|
+
clearRequests() {
|
|
203
|
+
this.requests.length = 0;
|
|
204
|
+
}
|
|
205
|
+
/** The recorded requests to one endpoint path suffix, such as `/token`. */
|
|
206
|
+
requestsTo(suffix) {
|
|
207
|
+
return this.requests.filter((request) => request.path.endsWith(suffix));
|
|
208
|
+
}
|
|
209
|
+
/** Stops the server. */
|
|
210
|
+
close() {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
this.server.closeAllConnections?.();
|
|
213
|
+
this.server.close((error) => error ? reject(error) : resolve());
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async listen() {
|
|
217
|
+
const pair = await generateKeyPair("RS256", { extractable: true });
|
|
218
|
+
const untrusted = await generateKeyPair("RS256");
|
|
219
|
+
this.signingKey = pair.privateKey;
|
|
220
|
+
this.verificationKey = pair.publicKey;
|
|
221
|
+
this.untrustedKey = untrusted.privateKey;
|
|
222
|
+
this.publicJwk = { ...await exportJWK(pair.publicKey), kid: this.keyId, alg: "RS256", use: "sig" };
|
|
223
|
+
this.server = createServer((request, response) => {
|
|
224
|
+
this.handle(request, response).catch((error) => {
|
|
225
|
+
send(response, { status: 500, body: { error: "server_error", error_description: String(error) } });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
await new Promise((resolve) => this.server.listen(0, "127.0.0.1", resolve));
|
|
229
|
+
this.baseUrl = `http://127.0.0.1:${this.server.address().port}`;
|
|
230
|
+
}
|
|
231
|
+
async handle(request, response) {
|
|
232
|
+
const url = new URL(request.url ?? "/", this.baseUrl);
|
|
233
|
+
const body = await readBody(request);
|
|
234
|
+
const form = Object.fromEntries(new URLSearchParams(request.headers["content-type"]?.includes("form") ? body : ""));
|
|
235
|
+
const headers = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name, Array.isArray(value) ? value.join(", ") : value ?? ""]));
|
|
236
|
+
const query = Object.fromEntries(url.searchParams);
|
|
237
|
+
const recorded = { method: request.method ?? "GET", path: url.pathname, headers, form, query };
|
|
238
|
+
this.requests.push(recorded);
|
|
239
|
+
const prefix = `/realms/${this.realm}`;
|
|
240
|
+
const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname;
|
|
241
|
+
const endpoint = endpointOf(path);
|
|
242
|
+
const failure = endpoint ? this.failures.get(endpoint)?.shift() : void 0;
|
|
243
|
+
if (failure) return send(response, failure);
|
|
244
|
+
switch (path) {
|
|
245
|
+
case "/.well-known/openid-configuration":
|
|
246
|
+
return send(response, { status: 200, body: this.discovery() });
|
|
247
|
+
case "/protocol/openid-connect/certs":
|
|
248
|
+
return send(response, { status: 200, body: this.jwks });
|
|
249
|
+
case "/protocol/openid-connect/auth":
|
|
250
|
+
return this.authorizeEndpoint(query, response);
|
|
251
|
+
case "/protocol/openid-connect/token":
|
|
252
|
+
return send(response, await this.token(recorded, form));
|
|
253
|
+
case "/protocol/openid-connect/token/introspect":
|
|
254
|
+
return send(response, await this.introspect(recorded, form));
|
|
255
|
+
case "/protocol/openid-connect/revoke":
|
|
256
|
+
return send(response, await this.revoke(recorded, form));
|
|
257
|
+
case "/protocol/openid-connect/userinfo":
|
|
258
|
+
return this.userInfo(headers, response);
|
|
259
|
+
case "/protocol/openid-connect/auth/device":
|
|
260
|
+
return send(response, await this.device(recorded, form));
|
|
261
|
+
case "/protocol/openid-connect/logout":
|
|
262
|
+
return this.endSessionEndpoint(query, response);
|
|
263
|
+
default:
|
|
264
|
+
return send(response, { status: 404, body: { error: "not_found" } });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
discovery() {
|
|
268
|
+
const base = `${this.issuer}/protocol/openid-connect`;
|
|
269
|
+
return {
|
|
270
|
+
issuer: this.discoveryIssuer ?? this.issuer,
|
|
271
|
+
authorization_endpoint: `${base}/auth`,
|
|
272
|
+
token_endpoint: `${base}/token`,
|
|
273
|
+
introspection_endpoint: `${base}/token/introspect`,
|
|
274
|
+
revocation_endpoint: `${base}/revoke`,
|
|
275
|
+
userinfo_endpoint: `${base}/userinfo`,
|
|
276
|
+
end_session_endpoint: `${base}/logout`,
|
|
277
|
+
device_authorization_endpoint: `${base}/auth/device`,
|
|
278
|
+
jwks_uri: `${base}/certs`,
|
|
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"],
|
|
298
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "private_key_jwt", "none"],
|
|
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
|
|
306
|
+
};
|
|
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
|
+
}
|
|
402
|
+
async authenticate(recorded, form) {
|
|
403
|
+
const invalid = (description) => ({ status: 401, body: { error: "invalid_client", error_description: description } });
|
|
404
|
+
const authorization = recorded.headers.authorization;
|
|
405
|
+
let client;
|
|
406
|
+
if (authorization?.startsWith("Basic ")) {
|
|
407
|
+
const [id, secret] = Buffer.from(authorization.slice(6), "base64").toString("utf8").split(":").map(decodeURIComponent);
|
|
408
|
+
client = this.clients.get(id ?? "");
|
|
409
|
+
if (!client || !client.clientSecret || client.clientSecret !== secret) return invalid("The client credentials are wrong.");
|
|
410
|
+
} else if (form.client_assertion) {
|
|
411
|
+
client = this.clients.get(form.client_id ?? "");
|
|
412
|
+
if (!client?.publicKey) return invalid("The client has no registered key.");
|
|
413
|
+
if (form.client_assertion_type !== "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") return invalid("Wrong assertion type.");
|
|
414
|
+
try {
|
|
415
|
+
const key = await importJWK(client.publicKey, client.publicKey.alg);
|
|
416
|
+
const { payload } = await jwtVerify(form.client_assertion, key, { issuer: client.clientId, subject: client.clientId, audience: this.issuer });
|
|
417
|
+
if (!payload.jti) return invalid("The assertion has no jti.");
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return invalid(`The assertion does not verify: ${error.message}`);
|
|
420
|
+
}
|
|
421
|
+
} else if (form.client_secret !== void 0) {
|
|
422
|
+
client = this.clients.get(form.client_id ?? "");
|
|
423
|
+
if (!client || client.clientSecret !== form.client_secret) return invalid("The client credentials are wrong.");
|
|
424
|
+
} else {
|
|
425
|
+
client = this.clients.get(form.client_id ?? "");
|
|
426
|
+
if (!client || client.clientSecret || client.publicKey) return invalid("The client must authenticate.");
|
|
427
|
+
}
|
|
428
|
+
recorded.clientId = client.clientId;
|
|
429
|
+
return client;
|
|
430
|
+
}
|
|
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
|
+
}
|
|
446
|
+
const client = await this.authenticate(recorded, form);
|
|
447
|
+
if ("status" in client) return client;
|
|
448
|
+
const scope = form.scope ?? client.defaultScopes?.join(" ");
|
|
449
|
+
switch (form.grant_type) {
|
|
450
|
+
case "client_credentials":
|
|
451
|
+
return this.tokenAnswer(client, { sub: client.clientId, scope, audience: form.resource, claims: client.claims });
|
|
452
|
+
case "password": {
|
|
453
|
+
const user = this.users.get(form.username ?? "");
|
|
454
|
+
if (!user || user.password !== form.password) return grant("invalid_grant", "Wrong username or password.");
|
|
455
|
+
return this.tokenAnswer(client, { sub: user.sub, scope, claims: { ...client.claims, ...user.claims, preferred_username: user.username } }, user);
|
|
456
|
+
}
|
|
457
|
+
case "authorization_code": {
|
|
458
|
+
const code = this.codes.get(form.code ?? "");
|
|
459
|
+
if (!code || code.clientId !== client.clientId || code.redirectUri !== form.redirect_uri) return grant("invalid_grant", "The code is not valid.");
|
|
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.");
|
|
463
|
+
this.codes.delete(form.code);
|
|
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"] };
|
|
466
|
+
return this.tokenAnswer(
|
|
467
|
+
client,
|
|
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 }
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
case "refresh_token": {
|
|
480
|
+
const refresh = this.refreshTokens.get(form.refresh_token ?? "");
|
|
481
|
+
if (!refresh || refresh.clientId !== client.clientId) return grant("invalid_grant", "The refresh token is not valid.");
|
|
482
|
+
this.refreshTokens.delete(form.refresh_token);
|
|
483
|
+
const user = [...this.users.values()].find((candidate) => candidate.sub === refresh.sub);
|
|
484
|
+
return this.tokenAnswer(
|
|
485
|
+
client,
|
|
486
|
+
{ sub: refresh.sub, scope: form.scope ?? refresh.scope, claims: { ...client.claims, ...user?.claims, ...refresh.sid ? { sid: refresh.sid } : {} } },
|
|
487
|
+
user
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
case "urn:ietf:params:oauth:grant-type:token-exchange": {
|
|
491
|
+
const subject = this.issued.get(form.subject_token ?? "");
|
|
492
|
+
if (!subject || subject.revoked) return grant("invalid_grant", "The subject token is not valid.");
|
|
493
|
+
if (form.requested_token_type === "urn:authio:params:oauth:token-type:connection-access-token") {
|
|
494
|
+
const answer = this.connectionResponder?.(form) ?? {
|
|
495
|
+
access_token: `provider-token-${form.connection_provider}`,
|
|
496
|
+
issued_token_type: form.requested_token_type,
|
|
497
|
+
token_type: "Bearer",
|
|
498
|
+
expires_in: 3600,
|
|
499
|
+
provider: form.connection_provider
|
|
500
|
+
};
|
|
501
|
+
return "status" in answer && typeof answer.status === "number" ? answer : { status: 200, body: answer };
|
|
502
|
+
}
|
|
503
|
+
const { iss: _issuer, aud: _audience, exp: _expiry, iat: _issuedAt, nbf: _notBefore, jti: _jti, scope: _scope, ...claims } = subject.claims;
|
|
504
|
+
return this.tokenAnswer(client, {
|
|
505
|
+
sub: subject.claims.sub,
|
|
506
|
+
scope: form.scope ?? subject.claims.scope,
|
|
507
|
+
audience: form.resource,
|
|
508
|
+
claims: { ...claims, act: { client_id: client.clientId } }
|
|
509
|
+
}, void 0, form.requested_token_type);
|
|
510
|
+
}
|
|
511
|
+
case "urn:ietf:params:oauth:grant-type:device_code": {
|
|
512
|
+
const polls = this.devicePolls.get(form.device_code ?? "");
|
|
513
|
+
if (polls === void 0) return grant("invalid_grant", "Unknown device code.");
|
|
514
|
+
this.devicePolls.set(form.device_code, polls + 1);
|
|
515
|
+
const outcome = this.deviceOutcomes[Math.min(polls, this.deviceOutcomes.length - 1)] ?? "approved";
|
|
516
|
+
switch (outcome) {
|
|
517
|
+
case "pending":
|
|
518
|
+
return grant("authorization_pending", "The user has not approved yet.");
|
|
519
|
+
case "slow_down":
|
|
520
|
+
return grant("slow_down", "Poll less often.");
|
|
521
|
+
case "denied":
|
|
522
|
+
return grant("access_denied", "The user denied the request.");
|
|
523
|
+
case "expired":
|
|
524
|
+
return grant("expired_token", "The device code expired.");
|
|
525
|
+
default:
|
|
526
|
+
return this.tokenAnswer(client, { sub: "device-user", scope, claims: client.claims });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
default:
|
|
530
|
+
return grant("unsupported_grant_type", `The grant ${form.grant_type} is not supported.`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
async tokenAnswer(client, options, user, issuedTokenType, idToken) {
|
|
534
|
+
const encryption = client.encryption;
|
|
535
|
+
const accessToken = await this.issueAccessToken({
|
|
536
|
+
clientId: client.clientId,
|
|
537
|
+
sub: options.sub,
|
|
538
|
+
scope: options.scope,
|
|
539
|
+
audience: options.audience ?? client.clientId,
|
|
540
|
+
claims: options.claims,
|
|
541
|
+
...encryption?.accessTokens ? { encryptTo: encryption } : {}
|
|
542
|
+
});
|
|
543
|
+
const body = {
|
|
544
|
+
access_token: accessToken,
|
|
545
|
+
token_type: "Bearer",
|
|
546
|
+
expires_in: this.lifetime,
|
|
547
|
+
...options.scope ? { scope: options.scope } : {},
|
|
548
|
+
...issuedTokenType ? { issued_token_type: issuedTokenType } : {}
|
|
549
|
+
};
|
|
550
|
+
if (user) {
|
|
551
|
+
const refreshToken = randomUUID();
|
|
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 });
|
|
554
|
+
body.refresh_token = refreshToken;
|
|
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
|
+
}
|
|
570
|
+
return { status: 200, body };
|
|
571
|
+
}
|
|
572
|
+
async introspect(recorded, form) {
|
|
573
|
+
const client = await this.authenticate(recorded, form);
|
|
574
|
+
if ("status" in client) return client;
|
|
575
|
+
const issued = this.issued.get(form.token ?? "");
|
|
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 } };
|
|
591
|
+
if (!issued || issued.revoked || (issued.claims.exp ?? 0) <= now) return { status: 200, body: { active: false } };
|
|
592
|
+
const audiences = [issued.claims.aud ?? []].flat();
|
|
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);
|
|
594
|
+
if (!allowed) return { status: 200, body: { active: false } };
|
|
595
|
+
const { typ, ...claims } = issued.claims;
|
|
596
|
+
return { status: 200, body: { active: true, token_type: typ ?? "Bearer", username: claims.preferred_username, ...claims } };
|
|
597
|
+
}
|
|
598
|
+
async revoke(recorded, form) {
|
|
599
|
+
const client = await this.authenticate(recorded, form);
|
|
600
|
+
if ("status" in client) return client;
|
|
601
|
+
this.revokeToken(form.token ?? "");
|
|
602
|
+
this.refreshTokens.delete(form.token ?? "");
|
|
603
|
+
return { status: 200 };
|
|
604
|
+
}
|
|
605
|
+
async device(recorded, form) {
|
|
606
|
+
const client = await this.authenticate(recorded, form);
|
|
607
|
+
if ("status" in client) return client;
|
|
608
|
+
const deviceCode = randomUUID();
|
|
609
|
+
this.devicePolls.set(deviceCode, 0);
|
|
610
|
+
return {
|
|
611
|
+
status: 200,
|
|
612
|
+
body: {
|
|
613
|
+
device_code: deviceCode,
|
|
614
|
+
user_code: "WDJB-MJHT",
|
|
615
|
+
verification_uri: `${this.issuer}/device`,
|
|
616
|
+
verification_uri_complete: `${this.issuer}/device?user_code=WDJB-MJHT`,
|
|
617
|
+
expires_in: 600,
|
|
618
|
+
interval: 5
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
async userInfo(headers, response) {
|
|
623
|
+
const token = headers.authorization?.replace(/^Bearer\s+/i, "");
|
|
624
|
+
const issued = token ? this.issued.get(token) : void 0;
|
|
625
|
+
if (!issued || issued.revoked)
|
|
626
|
+
return send(response, {
|
|
627
|
+
status: 401,
|
|
628
|
+
headers: { "www-authenticate": 'Bearer error="invalid_token", error_description="The access token is not valid."' }
|
|
629
|
+
});
|
|
630
|
+
const user = [...this.users.values()].find((candidate) => candidate.sub === issued.claims.sub);
|
|
631
|
+
const claims = { sub: issued.claims.sub, ...user?.userInfo };
|
|
632
|
+
if (!this.userInfoAsJwt) return send(response, { status: 200, body: claims });
|
|
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;
|
|
637
|
+
response.writeHead(200, { "content-type": "application/jwt" });
|
|
638
|
+
response.end(jwt);
|
|
639
|
+
}
|
|
640
|
+
requireUser(username) {
|
|
641
|
+
const user = this.users.get(username);
|
|
642
|
+
if (!user) throw new Error(`The fake has no user ${username}.`);
|
|
643
|
+
return user;
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
function endpointOf(path) {
|
|
647
|
+
if (path.endsWith("/token")) return "token";
|
|
648
|
+
if (path.endsWith("/introspect")) return "introspect";
|
|
649
|
+
if (path.endsWith("/revoke")) return "revoke";
|
|
650
|
+
if (path.endsWith("/userinfo")) return "userinfo";
|
|
651
|
+
if (path.endsWith("/auth/device")) return "device";
|
|
652
|
+
if (path.endsWith("/openid-configuration")) return "discovery";
|
|
653
|
+
if (path.endsWith("/auth")) return "authorize";
|
|
654
|
+
if (path.endsWith("/logout")) return "logout";
|
|
655
|
+
return void 0;
|
|
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
|
+
}
|
|
669
|
+
function send(response, answer) {
|
|
670
|
+
const headers = { ...answer.headers };
|
|
671
|
+
if (answer.body !== void 0) headers["content-type"] ??= "application/json";
|
|
672
|
+
response.writeHead(answer.status, headers);
|
|
673
|
+
response.end(answer.body === void 0 ? void 0 : JSON.stringify(answer.body));
|
|
674
|
+
}
|
|
675
|
+
function readBody(request) {
|
|
676
|
+
return new Promise((resolve, reject) => {
|
|
677
|
+
const chunks = [];
|
|
678
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
679
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
680
|
+
request.on("error", reject);
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/manual-clock.ts
|
|
685
|
+
var ManualClock = class {
|
|
686
|
+
/** Every `sleep` asked for, in milliseconds. */
|
|
687
|
+
sleeps = [];
|
|
688
|
+
time;
|
|
689
|
+
/**
|
|
690
|
+
* @param start Milliseconds since the epoch. Defaults to the current time.
|
|
691
|
+
*/
|
|
692
|
+
constructor(start = Date.now()) {
|
|
693
|
+
this.time = start;
|
|
694
|
+
}
|
|
695
|
+
now() {
|
|
696
|
+
return this.time;
|
|
697
|
+
}
|
|
698
|
+
sleep(ms, signal) {
|
|
699
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
700
|
+
this.sleeps.push(ms);
|
|
701
|
+
this.time += ms;
|
|
702
|
+
return Promise.resolve();
|
|
703
|
+
}
|
|
704
|
+
/** Moves the clock forward. */
|
|
705
|
+
advance(ms) {
|
|
706
|
+
this.time += ms;
|
|
707
|
+
}
|
|
708
|
+
/** Sets the clock. */
|
|
709
|
+
set(ms) {
|
|
710
|
+
this.time = ms;
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
async function createWebhookDelivery(type, data, secret, options = {}) {
|
|
714
|
+
const timestamp = options.timestamp ?? Date.now();
|
|
715
|
+
const id = options.id ?? randomUUID();
|
|
716
|
+
const event = {
|
|
717
|
+
id,
|
|
718
|
+
type,
|
|
719
|
+
created_at: Math.floor(timestamp / 1e3),
|
|
720
|
+
...options.realmId ? { realm_id: options.realmId } : {},
|
|
721
|
+
...options.clientId ? { client_id: options.clientId } : {},
|
|
722
|
+
data
|
|
723
|
+
};
|
|
724
|
+
const body = JSON.stringify(event);
|
|
725
|
+
const headers = {
|
|
726
|
+
"content-type": "application/json",
|
|
727
|
+
[AuthioWebhookHeaders.Signature]: await computeWebhookSignatureHeader(body, secret, timestamp),
|
|
728
|
+
[AuthioWebhookHeaders.Event]: type
|
|
729
|
+
};
|
|
730
|
+
if (options.sendEventIdHeader ?? true) headers[AuthioWebhookHeaders.EventId] = id;
|
|
731
|
+
return { body, headers, event };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export { FakeAuthio, ManualClock, createWebhookDelivery };
|
|
735
|
+
//# sourceMappingURL=index.js.map
|
|
736
|
+
//# sourceMappingURL=index.js.map
|