@cauthyhq/node 1.0.0 → 1.2.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/dist/testing.mjs CHANGED
@@ -1,4 +1,291 @@
1
1
  // src/testing.ts
2
+ import { createServer } from "http";
3
+
4
+ // src/sha256.ts
5
+ var K = new Uint32Array([
6
+ 1116352408,
7
+ 1899447441,
8
+ 3049323471,
9
+ 3921009573,
10
+ 961987163,
11
+ 1508970993,
12
+ 2453635748,
13
+ 2870763221,
14
+ 3624381080,
15
+ 310598401,
16
+ 607225278,
17
+ 1426881987,
18
+ 1925078388,
19
+ 2162078206,
20
+ 2614888103,
21
+ 3248222580,
22
+ 3835390401,
23
+ 4022224774,
24
+ 264347078,
25
+ 604807628,
26
+ 770255983,
27
+ 1249150122,
28
+ 1555081692,
29
+ 1996064986,
30
+ 2554220882,
31
+ 2821834349,
32
+ 2952996808,
33
+ 3210313671,
34
+ 3336571891,
35
+ 3584528711,
36
+ 113926993,
37
+ 338241895,
38
+ 666307205,
39
+ 773529912,
40
+ 1294757372,
41
+ 1396182291,
42
+ 1695183700,
43
+ 1986661051,
44
+ 2177026350,
45
+ 2456956037,
46
+ 2730485921,
47
+ 2820302411,
48
+ 3259730800,
49
+ 3345764771,
50
+ 3516065817,
51
+ 3600352804,
52
+ 4094571909,
53
+ 275423344,
54
+ 430227734,
55
+ 506948616,
56
+ 659060556,
57
+ 883997877,
58
+ 958139571,
59
+ 1322822218,
60
+ 1537002063,
61
+ 1747873779,
62
+ 1955562222,
63
+ 2024104815,
64
+ 2227730452,
65
+ 2361852424,
66
+ 2428436474,
67
+ 2756734187,
68
+ 3204031479,
69
+ 3329325298
70
+ ]);
71
+ function sha256(data) {
72
+ const h = new Uint32Array([1779033703, 3144134277, 1013904242, 2773480762, 1359893119, 2600822924, 528734635, 1541459225]);
73
+ const len = data.length;
74
+ const padded = new Uint8Array(len + 9 + 63 >> 6 << 6);
75
+ padded.set(data);
76
+ padded[len] = 128;
77
+ const view = new DataView(padded.buffer);
78
+ view.setUint32(padded.length - 8, Math.floor(len / 536870912));
79
+ view.setUint32(padded.length - 4, len << 3 >>> 0);
80
+ const w = new Uint32Array(64);
81
+ const rotr = (x, n) => x >>> n | x << 32 - n;
82
+ for (let off = 0; off < padded.length; off += 64) {
83
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
84
+ for (let i = 16; i < 64; i++) {
85
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ w[i - 15] >>> 3;
86
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ w[i - 2] >>> 10;
87
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
88
+ }
89
+ let [a, b, c, d, e, f, g, hh] = h;
90
+ for (let i = 0; i < 64; i++) {
91
+ const t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[i] + w[i] >>> 0;
92
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
93
+ hh = g;
94
+ g = f;
95
+ f = e;
96
+ e = d + t1 >>> 0;
97
+ d = c;
98
+ c = b;
99
+ b = a;
100
+ a = t1 + t2 >>> 0;
101
+ }
102
+ h[0] += a;
103
+ h[1] += b;
104
+ h[2] += c;
105
+ h[3] += d;
106
+ h[4] += e;
107
+ h[5] += f;
108
+ h[6] += g;
109
+ h[7] += hh;
110
+ }
111
+ const out = new Uint8Array(32);
112
+ const ov = new DataView(out.buffer);
113
+ for (let i = 0; i < 8; i++) ov.setUint32(i * 4, h[i]);
114
+ return out;
115
+ }
116
+
117
+ // src/types.ts
118
+ var CauthyConfigError = class extends Error {
119
+ constructor(message) {
120
+ super(`@cauthyhq/node: ${message}`);
121
+ this.name = "CauthyConfigError";
122
+ }
123
+ };
124
+ var CauthyKeyError = class extends CauthyConfigError {
125
+ constructor(reason, message) {
126
+ super(`CAUTHY_KEY: ${message} (${reason})`);
127
+ this.reason = reason;
128
+ this.name = "CauthyKeyError";
129
+ }
130
+ reason;
131
+ };
132
+
133
+ // src/cauthykey.ts
134
+ var KEY_PREFIX = "cauthy1";
135
+ var MAX_LEN = 512;
136
+ var enc = new TextEncoder();
137
+ var dec = new TextDecoder("utf-8", { fatal: true });
138
+ function b64uEncode(b) {
139
+ let bin = "";
140
+ for (const x of b) bin += String.fromCharCode(x);
141
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
142
+ }
143
+ function b64uDecode(s) {
144
+ if (!/^[A-Za-z0-9_-]*$/.test(s) || s.length % 4 === 1) return null;
145
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - s.length % 4) % 4));
146
+ const out = Uint8Array.from(bin, (c) => c.charCodeAt(0));
147
+ return b64uEncode(out) === s ? out : null;
148
+ }
149
+ function checksum(body) {
150
+ return b64uEncode(sha256(enc.encode(body)).slice(0, 6));
151
+ }
152
+ function printableAscii(s) {
153
+ return /^[\x21-\x7e]*$/.test(s);
154
+ }
155
+ function validIssuer(s) {
156
+ if (s === "" || !printableAscii(s)) return false;
157
+ let u;
158
+ try {
159
+ u = new URL(s);
160
+ } catch {
161
+ return false;
162
+ }
163
+ if (u.origin !== s) return false;
164
+ if (/^\[::ffff:/i.test(u.host)) return false;
165
+ if (u.protocol === "https:") return true;
166
+ return u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
167
+ }
168
+ var fail = (reason, message) => {
169
+ throw new CauthyKeyError(reason, message);
170
+ };
171
+ var NOT_A_KEY = "not a Cauthy key (copy it again from the dashboard)";
172
+ function parseKey(text) {
173
+ const s = text.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
174
+ if (s === "" || s.length > MAX_LEN || !printableAscii(s)) fail("key_format", NOT_A_KEY);
175
+ const head = s.split(".", 1)[0];
176
+ if (head !== KEY_PREFIX) {
177
+ if (/^cauthy[0-9]+$/.test(head)) fail("key_version", `this key is for a newer SDK (${head}): update @cauthyhq/node`);
178
+ fail("key_format", NOT_A_KEY);
179
+ }
180
+ const parts = s.split(".");
181
+ if (parts.length !== 5 || parts.some((p) => p === "")) fail("key_format", NOT_A_KEY);
182
+ if (!constantTimeEqual(checksum(parts.slice(0, 4).join(".")), parts[4])) {
183
+ fail("key_checksum", "the key is damaged (part of it is missing or changed): copy it again from the dashboard");
184
+ }
185
+ const issuerBytes = b64uDecode(parts[1]);
186
+ let issuer = "";
187
+ try {
188
+ issuer = issuerBytes ? dec.decode(issuerBytes) : "";
189
+ } catch {
190
+ issuer = "";
191
+ }
192
+ if (!validIssuer(issuer)) fail("key_issuer", "the key names no valid Cauthy address");
193
+ const clientBytes = b64uDecode(parts[2]);
194
+ const clientId = clientBytes ? String.fromCharCode(...clientBytes) : "";
195
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(clientId)) fail("key_client", "the key names no valid client");
196
+ const secret = b64uDecode(parts[3]);
197
+ if (parts[3].length !== 43 || !secret || secret.length !== 32) fail("key_secret", "the key's secret is not 32 bytes");
198
+ return { issuer, clientId, secret: parts[3] };
199
+ }
200
+ function composeKey(issuer, clientId, secret) {
201
+ const body = `${KEY_PREFIX}.${b64uEncode(enc.encode(issuer))}.${b64uEncode(enc.encode(clientId))}.${secret}`;
202
+ const key = `${body}.${checksum(body)}`;
203
+ parseKey(key);
204
+ return key;
205
+ }
206
+ function constantTimeEqual(a, b) {
207
+ if (a.length !== b.length) return false;
208
+ let d = 0;
209
+ for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
210
+ return d === 0;
211
+ }
212
+
213
+ // src/rootkey.ts
214
+ var encoder = new TextEncoder();
215
+ var MIN_ROOT_KEY = 32;
216
+ async function derive(root, purpose) {
217
+ const ikm = await crypto.subtle.importKey("raw", encoder.encode(root), "HKDF", false, ["deriveBits"]);
218
+ const bits = await crypto.subtle.deriveBits(
219
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(`pact/${purpose}/v1`) },
220
+ ikm,
221
+ 256
222
+ );
223
+ return new Uint8Array(bits);
224
+ }
225
+ function deriveKeys(root, previous) {
226
+ if (root.length < MIN_ROOT_KEY) {
227
+ throw new CauthyConfigError(`rootKey (PACT_ROOT_KEY) must be at least ${MIN_ROOT_KEY} characters: 32 random bytes, e.g. openssl rand -base64 32`);
228
+ }
229
+ if (previous !== void 0 && previous !== "" && previous.length < MIN_ROOT_KEY) {
230
+ throw new CauthyConfigError(`the previous root key (PACT_ROOT_KEY_PREVIOUS) must be at least ${MIN_ROOT_KEY} characters`);
231
+ }
232
+ const roots = previous && previous !== root ? [root, previous] : [root];
233
+ const ring = (purpose) => Promise.all(roots.map((r) => derive(r, purpose)));
234
+ return { seal: ring("seal"), cookie: ring("cookie"), flow: ring("flow") };
235
+ }
236
+
237
+ // src/keyderive.ts
238
+ var enc2 = new TextEncoder();
239
+ var PKCS8_ED25519 = Uint8Array.from([48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 112, 4, 34, 4, 32]);
240
+ var ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
241
+ async function seedOf(secret) {
242
+ const ikm = await crypto.subtle.importKey("raw", enc2.encode(secret), "HKDF", false, ["deriveBits"]);
243
+ return new Uint8Array(await crypto.subtle.deriveBits(
244
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc2.encode("cauthy/sign/v1") },
245
+ ikm,
246
+ 256
247
+ ));
248
+ }
249
+ async function thumbprint(x) {
250
+ return b64uEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", enc2.encode(`{"crv":"Ed25519","kty":"OKP","x":"${x}"}`))));
251
+ }
252
+ async function signerOf(secret) {
253
+ const seed = await seedOf(secret);
254
+ const der = new Uint8Array(PKCS8_ED25519.length + 32);
255
+ der.set(PKCS8_ED25519);
256
+ der.set(seed, PKCS8_ED25519.length);
257
+ const readable = await crypto.subtle.importKey("pkcs8", der, { name: "Ed25519" }, true, ["sign"]);
258
+ const { x } = await crypto.subtle.exportKey("jwk", readable);
259
+ const signing = await crypto.subtle.importKey("pkcs8", der, { name: "Ed25519" }, false, ["sign"]);
260
+ der.fill(0);
261
+ seed.fill(0);
262
+ const publicJwk = { kty: "OKP", crv: "Ed25519", x };
263
+ const kid = await thumbprint(publicJwk.x);
264
+ const header = b64uEncode(enc2.encode(JSON.stringify({ alg: "EdDSA", kid })));
265
+ return {
266
+ kid,
267
+ publicJwk,
268
+ async assertion(clientId, tokenEndpoint, jti = crypto.randomUUID(), iat = Math.floor(Date.now() / 1e3)) {
269
+ const claims = { iss: clientId, sub: clientId, aud: tokenEndpoint, jti, iat, exp: iat + 120 };
270
+ const input = `${header}.${b64uEncode(enc2.encode(JSON.stringify(claims)))}`;
271
+ const sig = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, signing, enc2.encode(input)));
272
+ return `${input}.${b64uEncode(sig)}`;
273
+ }
274
+ };
275
+ }
276
+ function deriveFromKey(key, previous) {
277
+ const other = previous && previous.secret !== key.secret ? previous : void 0;
278
+ const rings = deriveKeys(key.secret, other?.secret);
279
+ return {
280
+ cookie: rings.cookie,
281
+ flow: rings.flow,
282
+ signer: signerOf(key.secret),
283
+ ...other ? { previousJwk: signerOf(other.secret).then((s) => ({ ...s.publicJwk, kid: s.kid })) } : {}
284
+ };
285
+ }
286
+
287
+ // src/testing.ts
288
+ var ADMIN_SETTINGS = ["allow_signup", "allow_password", "allow_magic_link", "allow_passkeys", "confirm_email", "verify_grace_days"];
2
289
  var FakeCauthy = class {
3
290
  issuer = "https://id.test";
4
291
  users = /* @__PURE__ */ new Map();
@@ -24,7 +311,26 @@ var FakeCauthy = class {
24
311
  passwords = /* @__PURE__ */ new Map();
25
312
  /** What GET /v1/auth/settings answers. */
26
313
  settings = { allow_password: true, allow_signup: false, allow_passkeys: false, persistent_sessions: false };
314
+ /** The bearer the admin API takes (settings, the application's client, accounts); '' refuses every admin call. */
315
+ adminToken = "";
316
+ /** The application's client, as the admin API lists it; its allow_persistent_sessions is settings.persistent_sessions. */
317
+ clientId = "acme-crm";
318
+ /** The application's registered origins, as GET /v1/connect/self answers them (app_urls). */
319
+ appUrls = ["http://localhost:3000"];
320
+ /** The application's system, as GET /v1/connect/self answers it. */
321
+ system = "acme-crm";
322
+ /** The workspace's id, as GET /v1/connect/self answers it. */
323
+ tenantId = "t-acme";
324
+ /** What GET /v1/connect/self answers as `contract`. */
325
+ contract = null;
326
+ /** Every call to the token and revoke endpoints, with its form and headers: what a test reads of how the application authenticated. */
327
+ oauthRequests = [];
27
328
  n = 0;
329
+ keys = /* @__PURE__ */ new Map();
330
+ jtis = /* @__PURE__ */ new Set();
331
+ systemTokens = /* @__PURE__ */ new Map();
332
+ // access token → kid
333
+ keyClients = /* @__PURE__ */ new Set();
28
334
  constructor() {
29
335
  this.users.set("u1", { sub: "u1", email: "ana@acme.com", name: "Ana", active: true, restricted: false });
30
336
  }
@@ -36,12 +342,83 @@ var FakeCauthy = class {
36
342
  this.codes.set(code, { sub, challenge: q.get("code_challenge") ?? "", redirectUri: q.get("redirect_uri") ?? "" });
37
343
  return `${q.get("redirect_uri")}?code=${code}&state=${q.get("state")}`;
38
344
  }
39
- issue(sub) {
345
+ /**
346
+ * Makes a CAUTHY_KEY for the application's client and registers its public
347
+ * half, as Cauthy's dashboard does in the browser. `issuer` is the address
348
+ * the application will reach this Cauthy at (the served one, over HTTP).
349
+ * From then on that client authenticates with assertions only.
350
+ */
351
+ async issueKey(issuer = this.issuer, options = {}) {
352
+ const clientId = options.clientId ?? this.clientId;
353
+ const key = composeKey(issuer.replace(/\/+$/, ""), clientId, b64uEncode(crypto.getRandomValues(new Uint8Array(32))));
354
+ const signer = await deriveFromKey(parseKey(key)).signer;
355
+ this.keys.set(signer.kid, { x: signer.publicJwk.x, clientId, issuer: parseKey(key).issuer, revoked: false });
356
+ this.keyClients.add(clientId);
357
+ return key;
358
+ }
359
+ /** Revokes a registered key, as the dashboard's Revoke does: refused from now on. */
360
+ revokeKey(kid) {
361
+ const k = this.keys.get(kid);
362
+ if (k) k.revoked = true;
363
+ }
364
+ /** The kids registered, revoked or not. */
365
+ registeredKids() {
366
+ return [...this.keys.keys()];
367
+ }
368
+ /** Checks an RFC 7523 assertion of a registered Ed25519 key: the kid it signed with, or null. */
369
+ async assertionOf(form) {
370
+ if (form.get("client_assertion_type") !== ASSERTION_TYPE) return null;
371
+ const jws = form.get("client_assertion") ?? "";
372
+ const [h, p, sig, extra] = jws.split(".");
373
+ if (!h || !p || !sig || extra !== void 0) return null;
374
+ const dec2 = new TextDecoder();
375
+ let header;
376
+ let claims;
377
+ try {
378
+ header = JSON.parse(dec2.decode(b64uDecode(h) ?? new Uint8Array()));
379
+ claims = JSON.parse(dec2.decode(b64uDecode(p) ?? new Uint8Array()));
380
+ } catch {
381
+ return null;
382
+ }
383
+ const kid = typeof header.kid === "string" ? header.kid : "";
384
+ const k = this.keys.get(kid);
385
+ const clientId = form.get("client_id") ?? "";
386
+ if (header.alg !== "EdDSA" || !k || k.revoked || k.clientId !== clientId) return null;
387
+ if (claims.iss !== clientId || claims.sub !== clientId || claims.aud !== `${k.issuer}/v1/oauth/token`) return null;
388
+ const now = Math.floor(Date.now() / 1e3);
389
+ if (typeof claims.exp !== "number" || claims.exp <= now || claims.exp - now > 330) return null;
390
+ if (typeof claims.jti !== "string" || claims.jti === "" || this.jtis.has(claims.jti)) return null;
391
+ const signature = b64uDecode(sig);
392
+ if (!signature) return null;
393
+ const pub = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: k.x }, { name: "Ed25519" }, false, ["verify"]);
394
+ if (!await crypto.subtle.verify({ name: "Ed25519" }, pub, new Uint8Array(signature), new TextEncoder().encode(`${h}.${p}`))) return null;
395
+ this.jtis.add(claims.jti);
396
+ return kid;
397
+ }
398
+ /** What GET /v1/connect/self answers for a key. */
399
+ identityOf(kid) {
400
+ const k = this.keys.get(kid);
401
+ return {
402
+ issuer: k.issuer,
403
+ audience: k.issuer,
404
+ tenant: { id: this.tenantId, slug: this.tenant },
405
+ system: this.system,
406
+ client_id: k.clientId,
407
+ sign_in_client_id: k.clientId,
408
+ app_urls: [...this.appUrls],
409
+ callback_path: "/api/auth/callback",
410
+ key: { kid, retires_at: null },
411
+ contract: this.contract
412
+ };
413
+ }
414
+ /** A token answer. `kind` is what a sign-in asked for (remember) when the client allows long sessions; `persistent` wins when set. */
415
+ issue(sub, kind) {
40
416
  const at = `at-${++this.n}`;
41
417
  const rt = `rt-${++this.n}`;
42
418
  this.access.set(at, sub);
43
419
  this.refresh.set(rt, sub);
44
- return { access_token: at, refresh_token: rt, token_type: "Bearer", expires_in: this.expiresIn, scope: "", ...this.persistent === void 0 ? {} : { persistent: this.persistent } };
420
+ const persistent = this.persistent ?? kind;
421
+ return { access_token: at, refresh_token: rt, token_type: "Bearer", expires_in: this.expiresIn, scope: "", ...persistent === void 0 ? {} : { persistent } };
45
422
  }
46
423
  fetch = async (input, init) => {
47
424
  const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
@@ -50,8 +427,36 @@ var FakeCauthy = class {
50
427
  const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
51
428
  const form = new URLSearchParams(init?.body instanceof URLSearchParams ? init.body : String(init?.body ?? ""));
52
429
  const bearer = new Headers(init?.headers).get("authorization")?.replace("Bearer ", "") ?? "";
430
+ const admin = adminAnswer(this, url, init);
431
+ if (admin) return admin;
53
432
  if (this.ownScreens && url.pathname.startsWith("/v1/auth/")) return this.ownScreen(url, init);
433
+ const oauth = url.pathname === "/v1/oauth/token" || url.pathname === "/v1/oauth/revoke";
434
+ if (oauth) {
435
+ this.oauthRequests.push({ path: url.pathname, form: Object.fromEntries(form), headers: Object.fromEntries(new Headers(init?.headers)) });
436
+ }
437
+ const asserted = oauth && form.has("client_assertion") ? await this.assertionOf(form) : null;
438
+ const keyClient = this.keyClients.has(form.get("client_id") ?? "");
439
+ if (oauth && (keyClient || form.has("client_assertion")) && asserted === null) {
440
+ return reply(401, { error: "invalid_client", error_description: "client authentication failed" });
441
+ }
442
+ if (url.pathname === "/v1/connect/self") {
443
+ const kid = this.systemTokens.get(bearer);
444
+ if (kid === void 0) {
445
+ return reply(this.access.has(bearer) ? 403 : 401, { errors: [{ code: this.access.has(bearer) ? "not_a_system_token" : "invalid_token", message: "a system token is required" }] });
446
+ }
447
+ if (this.keys.get(kid)?.revoked) return reply(401, { errors: [{ code: "invalid_token", message: "the key was revoked" }] });
448
+ const body = JSON.stringify({ data: this.identityOf(kid) });
449
+ const etag = `"${b64uEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)))).slice(0, 16)}"`;
450
+ const headers = { etag, "cache-control": "private, max-age=300" };
451
+ if (new Headers(init?.headers).get("if-none-match") === etag) return new Response(null, { status: 304, headers });
452
+ return new Response(body, { status: 200, headers: { "content-type": "application/json", ...headers } });
453
+ }
54
454
  if (url.pathname === "/v1/oauth/token") {
455
+ if (form.get("grant_type") === "client_credentials" && asserted !== null) {
456
+ const token = `sys-${++this.n}`;
457
+ this.systemTokens.set(token, asserted);
458
+ return reply(200, { access_token: token, token_type: "Bearer", expires_in: this.expiresIn });
459
+ }
55
460
  if (form.get("grant_type") === "authorization_code") {
56
461
  const c = this.codes.get(form.get("code") ?? "");
57
462
  this.codes.delete(form.get("code") ?? "");
@@ -104,13 +509,27 @@ var FakeCauthy = class {
104
509
  if (new Headers(init?.headers).get("x-tenant-key") !== this.tenant) return refuse(404, "tenant_not_found", "no such workspace");
105
510
  const method = init?.method ?? "GET";
106
511
  if (method === "POST" && url.pathname === "/v1/auth/login") {
512
+ if (this.settings.allow_password === false) return refuse(403, "password_disabled", "this workspace does not take passwords: sign in another way");
107
513
  const body = JSON.parse(String(init?.body ?? "{}"));
108
514
  const user = [...this.users.values()].find((u) => u.email === body.email);
109
515
  const password = body.email === void 0 ? void 0 : this.passwords.get(body.email);
110
516
  if (!user || !user.active || password === void 0 || password !== body.password) {
111
517
  return refuse(401, "invalid_credentials", "invalid email or password");
112
518
  }
113
- return reply(200, { data: this.issue(user.sub) });
519
+ const kind = this.settings.persistent_sessions === true ? new Headers(init?.headers).get("x-cauthy-remember") === "1" : void 0;
520
+ return reply(200, { data: this.issue(user.sub, kind) });
521
+ }
522
+ if (method === "POST" && url.pathname === "/v1/auth/register") {
523
+ if (this.settings.allow_signup === false) return refuse(403, "signups_disabled", "this workspace is not accepting new accounts");
524
+ if (this.settings.allow_password === false) return refuse(403, "password_disabled", "this workspace does not take passwords: sign in another way");
525
+ const body = JSON.parse(String(init?.body ?? "{}"));
526
+ if (!body.email || !body.password) return refuse(400, "validation_error", "email and password are required");
527
+ if ([...this.users.values()].some((u) => u.email === body.email)) return refuse(409, "email_taken", "an account with this email exists");
528
+ const confirm = this.settings.confirm_email === true;
529
+ const sub = `u-${++this.n}`;
530
+ this.users.set(sub, { sub, email: body.email, name: body.display_name ?? "", active: !confirm, restricted: false, verified: !confirm });
531
+ this.passwords.set(body.email, body.password);
532
+ return reply(201, { data: { user_id: sub, email: body.email, ...confirm ? { verification_required: true } : {} } });
114
533
  }
115
534
  if (method === "GET" && url.pathname === "/v1/auth/settings") return reply(200, { data: this.settings });
116
535
  if (method === "GET" && (url.pathname === "/v1/auth/social/providers" || url.pathname === "/v1/auth/sso/connections")) return reply(200, { data: [] });
@@ -118,6 +537,68 @@ var FakeCauthy = class {
118
537
  return refuse(404, "not_found", "not found");
119
538
  }
120
539
  };
540
+ function adminAnswer(fake, url, init) {
541
+ const method = (init?.method ?? "GET").toUpperCase();
542
+ const route = `${method} ${url.pathname}`;
543
+ const client = method === "PATCH" ? url.pathname.match(/^\/v1\/oauth\/clients\/([^/]+)$/) : null;
544
+ const user = method === "DELETE" ? url.pathname.match(/^\/v1\/users\/([^/]+)$/) : null;
545
+ const known = ["GET /v1/settings/auth", "PUT /v1/settings/auth", "GET /v1/oauth/clients", "GET /v1/users"].includes(route);
546
+ if (!known && !client && !user) return null;
547
+ const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
548
+ const refuse = (status, code, message) => reply(status, { errors: [{ code, message }] });
549
+ const headers = new Headers(init?.headers);
550
+ if (fake.adminToken === "" || headers.get("authorization") !== `Bearer ${fake.adminToken}`) return refuse(401, "unauthorized", "invalid or expired token");
551
+ if (headers.get("x-tenant-key") !== fake.tenant) return refuse(404, "tenant_not_found", "no such workspace");
552
+ if (route === "GET /v1/settings/auth") return reply(200, { data: fake.settings });
553
+ if (route === "PUT /v1/settings/auth") {
554
+ const change = JSON.parse(String(init?.body ?? "{}"));
555
+ const next = { ...fake.settings };
556
+ for (const key of ADMIN_SETTINGS) if (change[key] !== void 0 && change[key] !== null) next[key] = change[key];
557
+ if (next.allow_password === false && next.allow_magic_link !== true && next.allow_passkeys !== true) {
558
+ return refuse(409, "last_method", "the change would leave no way to sign in");
559
+ }
560
+ fake.settings = next;
561
+ return reply(200, { data: fake.settings });
562
+ }
563
+ const persistent = () => fake.settings.persistent_sessions === true;
564
+ if (route === "GET /v1/oauth/clients") return reply(200, { data: [{ client_id: fake.clientId, allow_persistent_sessions: persistent() }], total_count: 1 });
565
+ if (client) {
566
+ if (decodeURIComponent(client[1]) !== fake.clientId) return refuse(404, "client_not_found", "no such client");
567
+ const change = JSON.parse(String(init?.body ?? "{}"));
568
+ if (typeof change.allow_persistent_sessions === "boolean") fake.settings = { ...fake.settings, persistent_sessions: change.allow_persistent_sessions };
569
+ return reply(200, { data: { client_id: fake.clientId, allow_persistent_sessions: persistent() } });
570
+ }
571
+ if (route === "GET /v1/users") {
572
+ const q = (url.searchParams.get("q") ?? "").toLowerCase();
573
+ const data = [...fake.users.values()].filter((u) => u.email.toLowerCase().includes(q)).map((u) => ({ id: u.sub, email: u.email, name: u.name }));
574
+ return reply(200, { data, total_count: data.length });
575
+ }
576
+ const id = decodeURIComponent(user[1]);
577
+ const gone = fake.users.get(id);
578
+ if (!gone) return refuse(404, "user_not_found", "no such user");
579
+ fake.users.delete(id);
580
+ fake.passwords.delete(gone.email);
581
+ return new Response(null, { status: 204 });
582
+ }
583
+ function serveFakeCauthy(fake, port) {
584
+ const server = createServer(async (req, res) => {
585
+ const chunks = [];
586
+ for await (const chunk of req) chunks.push(chunk);
587
+ const headers = new Headers();
588
+ for (const [k, v] of Object.entries(req.headers)) if (typeof v === "string") headers.set(k, v);
589
+ const answer = await fake.fetch(`${fake.issuer}${req.url ?? "/"}`, {
590
+ method: req.method,
591
+ headers,
592
+ ...chunks.length > 0 ? { body: Buffer.concat(chunks).toString("utf8") } : {}
593
+ });
594
+ res.writeHead(answer.status, Object.fromEntries(answer.headers));
595
+ res.end(Buffer.from(await answer.arrayBuffer()));
596
+ });
597
+ return new Promise((resolve, reject) => {
598
+ server.once("error", reject);
599
+ server.listen(port, "127.0.0.1", () => resolve(server));
600
+ });
601
+ }
121
602
  var Browser = class {
122
603
  /** `requestHeader: false`: a browser whose client sends no X-Cauthy-Request, as cauthy-auth 0.42's does not. */
123
604
  constructor(origin = "https://app.test", options = {}) {
@@ -151,6 +632,7 @@ var Browser = class {
151
632
  };
152
633
  export {
153
634
  Browser,
154
- FakeCauthy
635
+ FakeCauthy,
636
+ serveFakeCauthy
155
637
  };
156
638
  //# sourceMappingURL=testing.mjs.map