cauthy-auth 0.1.0 → 0.3.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/index.js CHANGED
@@ -20,10 +20,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ CauthyApiError: () => CauthyApiError,
23
24
  CauthyConfigError: () => CauthyConfigError,
25
+ CauthyWebhookError: () => CauthyWebhookError,
26
+ can: () => can,
27
+ connectSystem: () => connectSystem,
24
28
  createCauthy: () => createCauthy,
29
+ createSystem: () => createSystem,
30
+ pushPolicy: () => pushPolicy,
25
31
  pushTheme: () => pushTheme,
26
- themeFromStackmyth: () => themeFromStackmyth
32
+ themeFromStackmyth: () => themeFromStackmyth,
33
+ verifyWebhook: () => verifyWebhook
27
34
  });
28
35
  module.exports = __toCommonJS(src_exports);
29
36
 
@@ -83,6 +90,9 @@ async function unseal(sealed, key) {
83
90
  }
84
91
  }
85
92
 
93
+ // src/system.ts
94
+ var import_jose2 = require("jose");
95
+
86
96
  // src/types.ts
87
97
  var CauthyConfigError = class extends Error {
88
98
  constructor(message) {
@@ -91,12 +101,243 @@ var CauthyConfigError = class extends Error {
91
101
  }
92
102
  };
93
103
 
104
+ // src/system.ts
105
+ var ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
106
+ var SKEW_MS = 3e4;
107
+ var CauthyApiError = class extends Error {
108
+ constructor(status, code, message) {
109
+ super(`cauthy-auth: ${message}`);
110
+ this.status = status;
111
+ this.code = code;
112
+ this.name = "CauthyApiError";
113
+ }
114
+ status;
115
+ code;
116
+ };
117
+ function env(name) {
118
+ return typeof process !== "undefined" ? process.env?.[name] : void 0;
119
+ }
120
+ function toOrganization(o) {
121
+ return {
122
+ id: o.id,
123
+ system: o.system,
124
+ name: o.name,
125
+ kind: o.kind,
126
+ externalId: o.external_id,
127
+ status: o.status,
128
+ metadata: o.metadata ?? {},
129
+ members: o.members,
130
+ createdAt: o.created_at,
131
+ updatedAt: o.updated_at
132
+ };
133
+ }
134
+ function toMember(m) {
135
+ return { userId: m.user_id, email: m.email, name: m.name, status: m.status, role: m.role, ...m.invited === void 0 ? {} : { invited: m.invited } };
136
+ }
137
+ function asJwk(k) {
138
+ if (typeof k.privateKey !== "string") return k.privateKey;
139
+ return k.privateKey.includes("BEGIN") ? void 0 : JSON.parse(k.privateKey);
140
+ }
141
+ async function loadKey(k) {
142
+ const jwk = asJwk(k);
143
+ return jwk ? (0, import_jose2.importJWK)(jwk, "RS256") : (0, import_jose2.importPKCS8)(k.privateKey, "RS256", { extractable: true });
144
+ }
145
+ function createSystem(given = {}) {
146
+ const issuer = (given.issuer ?? env("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
147
+ const clientId = given.clientId ?? env("CAUTHY_CLIENT_ID") ?? "";
148
+ const clientSecret = given.clientSecret ?? env("CAUTHY_CLIENT_SECRET") ?? "";
149
+ const envKey = env("CAUTHY_SYSTEM_KEY");
150
+ const key = given.key ?? (envKey ? { privateKey: envKey, keyId: env("CAUTHY_SYSTEM_KEY_ID") ?? "" } : void 0);
151
+ const doFetch = (...args) => (given.fetch ?? fetch)(...args);
152
+ const tokenEndpoint = `${issuer}/v1/oauth/token`;
153
+ function configured() {
154
+ if (!/^https?:\/\//.test(issuer)) throw new CauthyConfigError("issuer (CAUTHY_ISSUER) must be Cauthy's address");
155
+ if (clientId === "") throw new CauthyConfigError("clientId (CAUTHY_CLIENT_ID) is required");
156
+ if (key ? key.keyId === "" : clientSecret === "") {
157
+ throw new CauthyConfigError(key ? "the key needs its id (CAUTHY_SYSTEM_KEY_ID)" : "to act on its own the application needs its secret (CAUTHY_CLIENT_SECRET) or a key (CAUTHY_SYSTEM_KEY)");
158
+ }
159
+ }
160
+ let imported;
161
+ const signingKey = () => imported ??= loadKey(key);
162
+ async function fail(res, what) {
163
+ const body = await res.json().catch(() => null);
164
+ const first = body?.errors?.[0];
165
+ throw new CauthyApiError(res.status, first?.code ?? "error", `Cauthy refused ${what} (${res.status}): ${first?.message ?? res.statusText}`);
166
+ }
167
+ let held;
168
+ let asking;
169
+ async function ask() {
170
+ configured();
171
+ const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId });
172
+ if (key) {
173
+ const assertion = await new import_jose2.SignJWT({}).setProtectedHeader({ alg: "RS256", kid: key.keyId }).setIssuer(clientId).setSubject(clientId).setAudience(tokenEndpoint).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime("2m").sign(await signingKey());
174
+ form.set("client_assertion_type", ASSERTION_TYPE);
175
+ form.set("client_assertion", assertion);
176
+ } else {
177
+ form.set("client_secret", clientSecret);
178
+ }
179
+ const res = await doFetch(tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form });
180
+ if (!res.ok) await fail(res, "the application's own token");
181
+ const t = await res.json();
182
+ held = { value: t.access_token, until: Date.now() + t.expires_in * 1e3 - SKEW_MS };
183
+ return t.access_token;
184
+ }
185
+ function token() {
186
+ if (held && Date.now() < held.until) return Promise.resolve(held.value);
187
+ return asking ??= ask().finally(() => {
188
+ asking = void 0;
189
+ });
190
+ }
191
+ async function call(method, path, body) {
192
+ const send = async () => doFetch(`${issuer}${path}`, {
193
+ method,
194
+ headers: { authorization: `Bearer ${await token()}`, ...body === void 0 ? {} : { "content-type": "application/json" } },
195
+ body: body === void 0 ? void 0 : JSON.stringify(body)
196
+ });
197
+ let res = await send();
198
+ if (res.status === 401 && held) {
199
+ held = void 0;
200
+ res = await send();
201
+ }
202
+ if (!res.ok) await fail(res, `${method} ${path}`);
203
+ return res.status === 204 ? void 0 : res.json();
204
+ }
205
+ const data = async (method, path, body) => (await call(method, path, body))?.data;
206
+ const id = encodeURIComponent;
207
+ return {
208
+ token,
209
+ request: data,
210
+ organizations: {
211
+ async list(query = {}) {
212
+ const q = new URLSearchParams();
213
+ for (const [k, v] of Object.entries(query)) if (v !== void 0) q.set(k, String(v));
214
+ const out2 = await call("GET", `/v1/organizations${q.size ? `?${q}` : ""}`);
215
+ return { items: out2.data.map(toOrganization), total: out2.total_count };
216
+ },
217
+ async create(input) {
218
+ return toOrganization(await data(
219
+ "POST",
220
+ "/v1/organizations",
221
+ { name: input.name, kind: input.kind, external_id: input.externalId, metadata: input.metadata }
222
+ ));
223
+ },
224
+ async get(orgId) {
225
+ return toOrganization(await data("GET", `/v1/organizations/${id(orgId)}`));
226
+ },
227
+ async update(orgId, patch) {
228
+ return toOrganization(await data("PATCH", `/v1/organizations/${id(orgId)}`, patch));
229
+ },
230
+ async remove(orgId) {
231
+ await call("DELETE", `/v1/organizations/${id(orgId)}`);
232
+ }
233
+ },
234
+ members: {
235
+ async list(orgId) {
236
+ return (await data("GET", `/v1/organizations/${id(orgId)}/members`)).map(toMember);
237
+ },
238
+ async add(orgId, m) {
239
+ return toMember(await data(
240
+ "POST",
241
+ `/v1/organizations/${id(orgId)}/members`,
242
+ { role: m.role, user_id: m.userId, email: m.email, name: m.name, locale: m.locale, client_id: clientId }
243
+ ));
244
+ },
245
+ async setRole(orgId, userId, role) {
246
+ return toMember(await data("PUT", `/v1/organizations/${id(orgId)}/members/${id(userId)}`, { role }));
247
+ },
248
+ async remove(orgId, userId) {
249
+ await call("DELETE", `/v1/organizations/${id(orgId)}/members/${id(userId)}`);
250
+ }
251
+ },
252
+ async invite(person) {
253
+ const u = await data("POST", "/v1/users", { ...person, client_id: clientId });
254
+ return { id: u.id, email: u.email };
255
+ },
256
+ async publicJwks() {
257
+ if (!key) return { keys: [] };
258
+ const { kty, n, e } = asJwk(key) ?? await (0, import_jose2.exportJWK)(await signingKey());
259
+ return { keys: [{ kty, n, e, kid: key.keyId, alg: "RS256", use: "sig" }] };
260
+ }
261
+ };
262
+ }
263
+
264
+ // src/webhook.ts
265
+ var import_jose3 = require("jose");
266
+ var CauthyWebhookError = class extends Error {
267
+ constructor(reason) {
268
+ super(`cauthy-auth: webhook refused: ${reason}`);
269
+ this.reason = reason;
270
+ this.name = "CauthyWebhookError";
271
+ }
272
+ reason;
273
+ };
274
+ var keys = /* @__PURE__ */ new Map();
275
+ function keysOf(issuer) {
276
+ let set = keys.get(issuer);
277
+ if (set === void 0) {
278
+ set = (0, import_jose3.createRemoteJWKSet)(new URL(`${issuer}/.well-known/jwks.json`), { cooldownDuration: 3e4 });
279
+ keys.set(issuer, set);
280
+ }
281
+ return set;
282
+ }
283
+ var hex = (s) => typeof s === "string" && s !== "" ? s : void 0;
284
+ async function verifyWebhook(request, options = {}) {
285
+ const env3 = typeof process !== "undefined" ? process.env?.CAUTHY_ISSUER : void 0;
286
+ const issuer = (options.issuer ?? env3 ?? "").replace(/\/+$/, "");
287
+ if (issuer === "") throw new CauthyWebhookError("issuer (CAUTHY_ISSUER) is not set");
288
+ const signature = request.headers.get("x-cauthy-signature-jwt");
289
+ if (!signature) throw new CauthyWebhookError("no signature");
290
+ const raw = new Uint8Array(await request.arrayBuffer());
291
+ const audience = options.url ?? request.url.split("?")[0];
292
+ let claims;
293
+ try {
294
+ const verified = await (0, import_jose3.jwtVerify)(signature, options.jwks ?? keysOf(issuer), {
295
+ algorithms: ["RS256"],
296
+ issuer,
297
+ audience,
298
+ typ: "cauthy-webhook+jwt",
299
+ requiredClaims: ["exp", "iat", "jti", "body_sha256"],
300
+ currentDate: options.now,
301
+ clockTolerance: 30
302
+ });
303
+ claims = verified.payload;
304
+ } catch (err) {
305
+ throw new CauthyWebhookError(err instanceof Error ? err.message : "bad signature");
306
+ }
307
+ const now = Math.floor((options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
308
+ if (typeof claims.iat !== "number" || claims.iat > now + 30) throw new CauthyWebhookError("signed in the future");
309
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
310
+ let b64 = "";
311
+ for (const b of digest) b64 += String.fromCharCode(b);
312
+ const hash = btoa(b64).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
313
+ if (hash !== claims.body_sha256) throw new CauthyWebhookError("the body is not the one that was signed");
314
+ let body;
315
+ try {
316
+ body = JSON.parse(new TextDecoder().decode(raw));
317
+ } catch {
318
+ throw new CauthyWebhookError("the body is not JSON");
319
+ }
320
+ if (body.id !== claims.jti || body.event !== claims.evt || body.tenant_id !== claims.tid) {
321
+ throw new CauthyWebhookError("the body and its signature disagree");
322
+ }
323
+ const inner = body.data ?? {};
324
+ return {
325
+ id: String(body.id),
326
+ type: String(body.event),
327
+ tenantId: String(body.tenant_id),
328
+ createdAt: String(inner.created_at ?? body.created_at ?? ""),
329
+ ...hex(inner.user_id) ? { userId: inner.user_id } : {},
330
+ ...hex(inner.actor_id) ? { actorId: inner.actor_id } : {},
331
+ data: inner.data ?? {}
332
+ };
333
+ }
334
+
94
335
  // src/cauthy.ts
95
336
  var SESSION_DAYS = 30;
96
337
  var FLOW_SECONDS = 600;
97
338
  var SKEW_SECONDS = 30;
98
339
  var ORGANIZATIONS_TTL_MS = 6e4;
99
- function env(name) {
340
+ function env2(name) {
100
341
  return typeof process !== "undefined" ? process.env?.[name] : void 0;
101
342
  }
102
343
  function b64url(bytes) {
@@ -123,11 +364,11 @@ function redirect(location, cookies = []) {
123
364
  return new Response(null, { status: 302, headers: h });
124
365
  }
125
366
  function createCauthy(given = {}) {
126
- const issuer = (given.issuer ?? env("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
127
- const clientId = given.clientId ?? env("CAUTHY_CLIENT_ID") ?? "";
128
- const clientSecret = given.clientSecret ?? env("CAUTHY_CLIENT_SECRET");
129
- const secret = given.secret ?? env("CAUTHY_SECRET") ?? "";
130
- const system = given.system ?? env("CAUTHY_SYSTEM") ?? "";
367
+ const issuer = (given.issuer ?? env2("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
368
+ const clientId = given.clientId ?? env2("CAUTHY_CLIENT_ID") ?? "";
369
+ const clientSecret = given.clientSecret ?? env2("CAUTHY_CLIENT_SECRET");
370
+ const secret = given.secret ?? env2("CAUTHY_SECRET") ?? "";
371
+ const system = given.system ?? env2("CAUTHY_SYSTEM") ?? "";
131
372
  if (!/^https?:\/\//.test(issuer)) throw new CauthyConfigError("issuer (CAUTHY_ISSUER) must be Cauthy's address, e.g. https://id.example.com");
132
373
  if (clientId === "") throw new CauthyConfigError("clientId (CAUTHY_CLIENT_ID) is required");
133
374
  if (secret.length < 32) throw new CauthyConfigError("secret (CAUTHY_SECRET) must be at least 32 characters: it encrypts the session cookie");
@@ -265,7 +506,8 @@ function createCauthy(given = {}) {
265
506
  kind: String(o.kind ?? ""),
266
507
  role: String(o.role ?? ""),
267
508
  metadata: o.metadata ?? {},
268
- ...o.external_id ? { externalId: String(o.external_id) } : {}
509
+ ...o.external_id ? { externalId: String(o.external_id) } : {},
510
+ ...Array.isArray(o.permissions) ? { permissions: o.permissions.map(String), denied: Array.isArray(o.denied) ? o.denied.map(String) : [] } : {}
269
511
  })
270
512
  );
271
513
  if (orgCache.size > 5e3) orgCache.clear();
@@ -391,8 +633,11 @@ function createCauthy(given = {}) {
391
633
  const to = value === null ? urls.signIn(back) : `${basePath}/refresh?return_to=${encodeURIComponent(back)}`;
392
634
  return { response: redirect(to) };
393
635
  }
636
+ const webhook = (request, o = {}) => verifyWebhook(request, { issuer, url: o.url ?? `${originOf(request)}${new URL(request.url).pathname}`, jwks: o.jwks, now: o.now });
394
637
  return {
395
638
  handler,
639
+ webhook,
640
+ system: createSystem({ issuer, clientId, clientSecret, key: given.systemKey, fetch: given.fetch }),
396
641
  getSession,
397
642
  status,
398
643
  resolve: resolve2,
@@ -605,11 +850,11 @@ function toWire(theme) {
605
850
  return wire;
606
851
  }
607
852
  async function pushTheme(o) {
608
- const env2 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
609
- const issuer = (o.issuer ?? env2("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
610
- const clientId = o.clientId ?? env2("CAUTHY_CLIENT_ID") ?? "";
611
- const tenant = o.tenant ?? env2("CAUTHY_TENANT") ?? "";
612
- const token = o.token ?? env2("CAUTHY_TOKEN") ?? "";
853
+ const env3 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
854
+ const issuer = (o.issuer ?? env3("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
855
+ const clientId = o.clientId ?? env3("CAUTHY_CLIENT_ID") ?? "";
856
+ const tenant = o.tenant ?? env3("CAUTHY_TENANT") ?? "";
857
+ const token = o.token ?? env3("CAUTHY_TOKEN") ?? "";
613
858
  for (const [name, v] of Object.entries({ CAUTHY_ISSUER: issuer, CAUTHY_CLIENT_ID: clientId, CAUTHY_TENANT: tenant, CAUTHY_TOKEN: token })) {
614
859
  if (v === "") throw new Error(`cauthy-auth: ${name} is required to push a theme`);
615
860
  }
@@ -623,11 +868,95 @@ async function pushTheme(o) {
623
868
  throw new Error(`cauthy-auth: Cauthy refused the theme (${res.status}): ${body?.errors?.[0]?.message ?? res.statusText}`);
624
869
  }
625
870
  }
871
+
872
+ // src/permissions.ts
873
+ function matches(entry, resource, action) {
874
+ const i = entry.lastIndexOf(":");
875
+ if (i < 0) return false;
876
+ const r = entry.slice(0, i);
877
+ const a = entry.slice(i + 1);
878
+ return (r === "*" || r === resource) && (a === "*" || a === action);
879
+ }
880
+ function can(organization, resource, action) {
881
+ if (!organization?.permissions) return false;
882
+ if ((organization.denied ?? []).some((d) => matches(d, resource, action))) return false;
883
+ return organization.permissions.some((p) => matches(p, resource, action));
884
+ }
885
+ async function pushPolicy(o) {
886
+ const env3 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
887
+ const issuer = (o.issuer ?? env3("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
888
+ const system = o.system ?? env3("CAUTHY_SYSTEM") ?? "";
889
+ const tenant = o.tenant ?? env3("CAUTHY_TENANT") ?? "";
890
+ const token = o.token ?? env3("CAUTHY_TOKEN") ?? "";
891
+ for (const [name, v] of Object.entries({ CAUTHY_ISSUER: issuer, CAUTHY_SYSTEM: system, CAUTHY_TENANT: tenant, CAUTHY_TOKEN: token })) {
892
+ if (v === "") throw new Error(`cauthy-auth: ${name} is required to push a policy`);
893
+ }
894
+ const res = await (o.fetch ?? fetch)(`${issuer}/v1/systems/${encodeURIComponent(system)}/policy`, {
895
+ method: "PUT",
896
+ headers: { authorization: `Bearer ${token}`, "x-tenant-key": tenant, "content-type": "application/json" },
897
+ body: JSON.stringify({ roles: o.roles })
898
+ });
899
+ if (!res.ok) {
900
+ const body = await res.json().catch(() => null);
901
+ throw new Error(`cauthy-auth: Cauthy refused the policy (${res.status}): ${body?.errors?.[0]?.message ?? res.statusText}`);
902
+ }
903
+ }
904
+
905
+ // src/connect.ts
906
+ function sessionSecret() {
907
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
908
+ let s = "";
909
+ for (const b of bytes) s += String.fromCharCode(b);
910
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
911
+ }
912
+ async function connectSystem(o) {
913
+ const env3 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
914
+ const issuer = (o.issuer ?? env3("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
915
+ const tenant = o.tenant ?? env3("CAUTHY_TENANT") ?? "";
916
+ const token = o.token ?? env3("CAUTHY_TOKEN") ?? "";
917
+ for (const [name, v] of Object.entries({ CAUTHY_ISSUER: issuer, CAUTHY_TENANT: tenant, CAUTHY_TOKEN: token })) {
918
+ if (v === "") throw new Error(`cauthy-auth: ${name} is required to connect a system`);
919
+ }
920
+ const res = await (o.fetch ?? fetch)(`${issuer}/v1/connect`, {
921
+ method: "POST",
922
+ headers: { authorization: `Bearer ${token}`, "x-tenant-key": tenant, "content-type": "application/json" },
923
+ body: JSON.stringify({
924
+ system: o.system,
925
+ app_url: o.appUrl,
926
+ name: o.name,
927
+ callback_path: o.callbackPath,
928
+ events_url: o.eventsUrl,
929
+ events: o.events,
930
+ jwks_uri: o.jwksUri
931
+ })
932
+ });
933
+ const body = await res.json().catch(() => null);
934
+ if (!res.ok || !body?.data) {
935
+ const why = (body?.errors ?? []).map((e) => e.message).filter(Boolean).join("; ") || res.statusText;
936
+ throw new Error(`cauthy-auth: Cauthy did not connect the system (${res.status}): ${why}`);
937
+ }
938
+ const d = body.data;
939
+ return {
940
+ system: d.system,
941
+ clientId: d.client.client_id,
942
+ clientSecret: d.client_secret,
943
+ redirectUris: d.client.redirect_uris,
944
+ webhook: d.webhook,
945
+ env: { ...d.env, CAUTHY_SECRET: sessionSecret() }
946
+ };
947
+ }
626
948
  // Annotate the CommonJS export names for ESM import in node:
627
949
  0 && (module.exports = {
950
+ CauthyApiError,
628
951
  CauthyConfigError,
952
+ CauthyWebhookError,
953
+ can,
954
+ connectSystem,
629
955
  createCauthy,
956
+ createSystem,
957
+ pushPolicy,
630
958
  pushTheme,
631
- themeFromStackmyth
959
+ themeFromStackmyth,
960
+ verifyWebhook
632
961
  });
633
962
  //# sourceMappingURL=index.js.map