@cauthyhq/node 1.1.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,5 +1,290 @@
1
1
  // src/testing.ts
2
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
3
288
  var ADMIN_SETTINGS = ["allow_signup", "allow_password", "allow_magic_link", "allow_passkeys", "confirm_email", "verify_grace_days"];
4
289
  var FakeCauthy = class {
5
290
  issuer = "https://id.test";
@@ -30,7 +315,22 @@ var FakeCauthy = class {
30
315
  adminToken = "";
31
316
  /** The application's client, as the admin API lists it; its allow_persistent_sessions is settings.persistent_sessions. */
32
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 = [];
33
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();
34
334
  constructor() {
35
335
  this.users.set("u1", { sub: "u1", email: "ana@acme.com", name: "Ana", active: true, restricted: false });
36
336
  }
@@ -42,6 +342,75 @@ var FakeCauthy = class {
42
342
  this.codes.set(code, { sub, challenge: q.get("code_challenge") ?? "", redirectUri: q.get("redirect_uri") ?? "" });
43
343
  return `${q.get("redirect_uri")}?code=${code}&state=${q.get("state")}`;
44
344
  }
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
+ }
45
414
  /** A token answer. `kind` is what a sign-in asked for (remember) when the client allows long sessions; `persistent` wins when set. */
46
415
  issue(sub, kind) {
47
416
  const at = `at-${++this.n}`;
@@ -61,7 +430,33 @@ var FakeCauthy = class {
61
430
  const admin = adminAnswer(this, url, init);
62
431
  if (admin) return admin;
63
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
+ }
64
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
+ }
65
460
  if (form.get("grant_type") === "authorization_code") {
66
461
  const c = this.codes.get(form.get("code") ?? "");
67
462
  this.codes.delete(form.get("code") ?? "");