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.mjs CHANGED
@@ -54,6 +54,9 @@ async function unseal(sealed, key) {
54
54
  }
55
55
  }
56
56
 
57
+ // src/system.ts
58
+ import { exportJWK, importJWK, importPKCS8, SignJWT } from "jose";
59
+
57
60
  // src/types.ts
58
61
  var CauthyConfigError = class extends Error {
59
62
  constructor(message) {
@@ -62,12 +65,243 @@ var CauthyConfigError = class extends Error {
62
65
  }
63
66
  };
64
67
 
68
+ // src/system.ts
69
+ var ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
70
+ var SKEW_MS = 3e4;
71
+ var CauthyApiError = class extends Error {
72
+ constructor(status, code, message) {
73
+ super(`cauthy-auth: ${message}`);
74
+ this.status = status;
75
+ this.code = code;
76
+ this.name = "CauthyApiError";
77
+ }
78
+ status;
79
+ code;
80
+ };
81
+ function env(name) {
82
+ return typeof process !== "undefined" ? process.env?.[name] : void 0;
83
+ }
84
+ function toOrganization(o) {
85
+ return {
86
+ id: o.id,
87
+ system: o.system,
88
+ name: o.name,
89
+ kind: o.kind,
90
+ externalId: o.external_id,
91
+ status: o.status,
92
+ metadata: o.metadata ?? {},
93
+ members: o.members,
94
+ createdAt: o.created_at,
95
+ updatedAt: o.updated_at
96
+ };
97
+ }
98
+ function toMember(m) {
99
+ return { userId: m.user_id, email: m.email, name: m.name, status: m.status, role: m.role, ...m.invited === void 0 ? {} : { invited: m.invited } };
100
+ }
101
+ function asJwk(k) {
102
+ if (typeof k.privateKey !== "string") return k.privateKey;
103
+ return k.privateKey.includes("BEGIN") ? void 0 : JSON.parse(k.privateKey);
104
+ }
105
+ async function loadKey(k) {
106
+ const jwk = asJwk(k);
107
+ return jwk ? importJWK(jwk, "RS256") : importPKCS8(k.privateKey, "RS256", { extractable: true });
108
+ }
109
+ function createSystem(given = {}) {
110
+ const issuer = (given.issuer ?? env("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
111
+ const clientId = given.clientId ?? env("CAUTHY_CLIENT_ID") ?? "";
112
+ const clientSecret = given.clientSecret ?? env("CAUTHY_CLIENT_SECRET") ?? "";
113
+ const envKey = env("CAUTHY_SYSTEM_KEY");
114
+ const key = given.key ?? (envKey ? { privateKey: envKey, keyId: env("CAUTHY_SYSTEM_KEY_ID") ?? "" } : void 0);
115
+ const doFetch = (...args) => (given.fetch ?? fetch)(...args);
116
+ const tokenEndpoint = `${issuer}/v1/oauth/token`;
117
+ function configured() {
118
+ if (!/^https?:\/\//.test(issuer)) throw new CauthyConfigError("issuer (CAUTHY_ISSUER) must be Cauthy's address");
119
+ if (clientId === "") throw new CauthyConfigError("clientId (CAUTHY_CLIENT_ID) is required");
120
+ if (key ? key.keyId === "" : clientSecret === "") {
121
+ 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)");
122
+ }
123
+ }
124
+ let imported;
125
+ const signingKey = () => imported ??= loadKey(key);
126
+ async function fail(res, what) {
127
+ const body = await res.json().catch(() => null);
128
+ const first = body?.errors?.[0];
129
+ throw new CauthyApiError(res.status, first?.code ?? "error", `Cauthy refused ${what} (${res.status}): ${first?.message ?? res.statusText}`);
130
+ }
131
+ let held;
132
+ let asking;
133
+ async function ask() {
134
+ configured();
135
+ const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId });
136
+ if (key) {
137
+ const assertion = await new SignJWT({}).setProtectedHeader({ alg: "RS256", kid: key.keyId }).setIssuer(clientId).setSubject(clientId).setAudience(tokenEndpoint).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime("2m").sign(await signingKey());
138
+ form.set("client_assertion_type", ASSERTION_TYPE);
139
+ form.set("client_assertion", assertion);
140
+ } else {
141
+ form.set("client_secret", clientSecret);
142
+ }
143
+ const res = await doFetch(tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form });
144
+ if (!res.ok) await fail(res, "the application's own token");
145
+ const t = await res.json();
146
+ held = { value: t.access_token, until: Date.now() + t.expires_in * 1e3 - SKEW_MS };
147
+ return t.access_token;
148
+ }
149
+ function token() {
150
+ if (held && Date.now() < held.until) return Promise.resolve(held.value);
151
+ return asking ??= ask().finally(() => {
152
+ asking = void 0;
153
+ });
154
+ }
155
+ async function call(method, path, body) {
156
+ const send = async () => doFetch(`${issuer}${path}`, {
157
+ method,
158
+ headers: { authorization: `Bearer ${await token()}`, ...body === void 0 ? {} : { "content-type": "application/json" } },
159
+ body: body === void 0 ? void 0 : JSON.stringify(body)
160
+ });
161
+ let res = await send();
162
+ if (res.status === 401 && held) {
163
+ held = void 0;
164
+ res = await send();
165
+ }
166
+ if (!res.ok) await fail(res, `${method} ${path}`);
167
+ return res.status === 204 ? void 0 : res.json();
168
+ }
169
+ const data = async (method, path, body) => (await call(method, path, body))?.data;
170
+ const id = encodeURIComponent;
171
+ return {
172
+ token,
173
+ request: data,
174
+ organizations: {
175
+ async list(query = {}) {
176
+ const q = new URLSearchParams();
177
+ for (const [k, v] of Object.entries(query)) if (v !== void 0) q.set(k, String(v));
178
+ const out2 = await call("GET", `/v1/organizations${q.size ? `?${q}` : ""}`);
179
+ return { items: out2.data.map(toOrganization), total: out2.total_count };
180
+ },
181
+ async create(input) {
182
+ return toOrganization(await data(
183
+ "POST",
184
+ "/v1/organizations",
185
+ { name: input.name, kind: input.kind, external_id: input.externalId, metadata: input.metadata }
186
+ ));
187
+ },
188
+ async get(orgId) {
189
+ return toOrganization(await data("GET", `/v1/organizations/${id(orgId)}`));
190
+ },
191
+ async update(orgId, patch) {
192
+ return toOrganization(await data("PATCH", `/v1/organizations/${id(orgId)}`, patch));
193
+ },
194
+ async remove(orgId) {
195
+ await call("DELETE", `/v1/organizations/${id(orgId)}`);
196
+ }
197
+ },
198
+ members: {
199
+ async list(orgId) {
200
+ return (await data("GET", `/v1/organizations/${id(orgId)}/members`)).map(toMember);
201
+ },
202
+ async add(orgId, m) {
203
+ return toMember(await data(
204
+ "POST",
205
+ `/v1/organizations/${id(orgId)}/members`,
206
+ { role: m.role, user_id: m.userId, email: m.email, name: m.name, locale: m.locale, client_id: clientId }
207
+ ));
208
+ },
209
+ async setRole(orgId, userId, role) {
210
+ return toMember(await data("PUT", `/v1/organizations/${id(orgId)}/members/${id(userId)}`, { role }));
211
+ },
212
+ async remove(orgId, userId) {
213
+ await call("DELETE", `/v1/organizations/${id(orgId)}/members/${id(userId)}`);
214
+ }
215
+ },
216
+ async invite(person) {
217
+ const u = await data("POST", "/v1/users", { ...person, client_id: clientId });
218
+ return { id: u.id, email: u.email };
219
+ },
220
+ async publicJwks() {
221
+ if (!key) return { keys: [] };
222
+ const { kty, n, e } = asJwk(key) ?? await exportJWK(await signingKey());
223
+ return { keys: [{ kty, n, e, kid: key.keyId, alg: "RS256", use: "sig" }] };
224
+ }
225
+ };
226
+ }
227
+
228
+ // src/webhook.ts
229
+ import { createRemoteJWKSet, jwtVerify } from "jose";
230
+ var CauthyWebhookError = class extends Error {
231
+ constructor(reason) {
232
+ super(`cauthy-auth: webhook refused: ${reason}`);
233
+ this.reason = reason;
234
+ this.name = "CauthyWebhookError";
235
+ }
236
+ reason;
237
+ };
238
+ var keys = /* @__PURE__ */ new Map();
239
+ function keysOf(issuer) {
240
+ let set = keys.get(issuer);
241
+ if (set === void 0) {
242
+ set = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`), { cooldownDuration: 3e4 });
243
+ keys.set(issuer, set);
244
+ }
245
+ return set;
246
+ }
247
+ var hex = (s) => typeof s === "string" && s !== "" ? s : void 0;
248
+ async function verifyWebhook(request, options = {}) {
249
+ const env3 = typeof process !== "undefined" ? process.env?.CAUTHY_ISSUER : void 0;
250
+ const issuer = (options.issuer ?? env3 ?? "").replace(/\/+$/, "");
251
+ if (issuer === "") throw new CauthyWebhookError("issuer (CAUTHY_ISSUER) is not set");
252
+ const signature = request.headers.get("x-cauthy-signature-jwt");
253
+ if (!signature) throw new CauthyWebhookError("no signature");
254
+ const raw = new Uint8Array(await request.arrayBuffer());
255
+ const audience = options.url ?? request.url.split("?")[0];
256
+ let claims;
257
+ try {
258
+ const verified = await jwtVerify(signature, options.jwks ?? keysOf(issuer), {
259
+ algorithms: ["RS256"],
260
+ issuer,
261
+ audience,
262
+ typ: "cauthy-webhook+jwt",
263
+ requiredClaims: ["exp", "iat", "jti", "body_sha256"],
264
+ currentDate: options.now,
265
+ clockTolerance: 30
266
+ });
267
+ claims = verified.payload;
268
+ } catch (err) {
269
+ throw new CauthyWebhookError(err instanceof Error ? err.message : "bad signature");
270
+ }
271
+ const now = Math.floor((options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
272
+ if (typeof claims.iat !== "number" || claims.iat > now + 30) throw new CauthyWebhookError("signed in the future");
273
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
274
+ let b64 = "";
275
+ for (const b of digest) b64 += String.fromCharCode(b);
276
+ const hash = btoa(b64).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
277
+ if (hash !== claims.body_sha256) throw new CauthyWebhookError("the body is not the one that was signed");
278
+ let body;
279
+ try {
280
+ body = JSON.parse(new TextDecoder().decode(raw));
281
+ } catch {
282
+ throw new CauthyWebhookError("the body is not JSON");
283
+ }
284
+ if (body.id !== claims.jti || body.event !== claims.evt || body.tenant_id !== claims.tid) {
285
+ throw new CauthyWebhookError("the body and its signature disagree");
286
+ }
287
+ const inner = body.data ?? {};
288
+ return {
289
+ id: String(body.id),
290
+ type: String(body.event),
291
+ tenantId: String(body.tenant_id),
292
+ createdAt: String(inner.created_at ?? body.created_at ?? ""),
293
+ ...hex(inner.user_id) ? { userId: inner.user_id } : {},
294
+ ...hex(inner.actor_id) ? { actorId: inner.actor_id } : {},
295
+ data: inner.data ?? {}
296
+ };
297
+ }
298
+
65
299
  // src/cauthy.ts
66
300
  var SESSION_DAYS = 30;
67
301
  var FLOW_SECONDS = 600;
68
302
  var SKEW_SECONDS = 30;
69
303
  var ORGANIZATIONS_TTL_MS = 6e4;
70
- function env(name) {
304
+ function env2(name) {
71
305
  return typeof process !== "undefined" ? process.env?.[name] : void 0;
72
306
  }
73
307
  function b64url(bytes) {
@@ -94,11 +328,11 @@ function redirect(location, cookies = []) {
94
328
  return new Response(null, { status: 302, headers: h });
95
329
  }
96
330
  function createCauthy(given = {}) {
97
- const issuer = (given.issuer ?? env("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
98
- const clientId = given.clientId ?? env("CAUTHY_CLIENT_ID") ?? "";
99
- const clientSecret = given.clientSecret ?? env("CAUTHY_CLIENT_SECRET");
100
- const secret = given.secret ?? env("CAUTHY_SECRET") ?? "";
101
- const system = given.system ?? env("CAUTHY_SYSTEM") ?? "";
331
+ const issuer = (given.issuer ?? env2("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
332
+ const clientId = given.clientId ?? env2("CAUTHY_CLIENT_ID") ?? "";
333
+ const clientSecret = given.clientSecret ?? env2("CAUTHY_CLIENT_SECRET");
334
+ const secret = given.secret ?? env2("CAUTHY_SECRET") ?? "";
335
+ const system = given.system ?? env2("CAUTHY_SYSTEM") ?? "";
102
336
  if (!/^https?:\/\//.test(issuer)) throw new CauthyConfigError("issuer (CAUTHY_ISSUER) must be Cauthy's address, e.g. https://id.example.com");
103
337
  if (clientId === "") throw new CauthyConfigError("clientId (CAUTHY_CLIENT_ID) is required");
104
338
  if (secret.length < 32) throw new CauthyConfigError("secret (CAUTHY_SECRET) must be at least 32 characters: it encrypts the session cookie");
@@ -236,7 +470,8 @@ function createCauthy(given = {}) {
236
470
  kind: String(o.kind ?? ""),
237
471
  role: String(o.role ?? ""),
238
472
  metadata: o.metadata ?? {},
239
- ...o.external_id ? { externalId: String(o.external_id) } : {}
473
+ ...o.external_id ? { externalId: String(o.external_id) } : {},
474
+ ...Array.isArray(o.permissions) ? { permissions: o.permissions.map(String), denied: Array.isArray(o.denied) ? o.denied.map(String) : [] } : {}
240
475
  })
241
476
  );
242
477
  if (orgCache.size > 5e3) orgCache.clear();
@@ -362,8 +597,11 @@ function createCauthy(given = {}) {
362
597
  const to = value === null ? urls.signIn(back) : `${basePath}/refresh?return_to=${encodeURIComponent(back)}`;
363
598
  return { response: redirect(to) };
364
599
  }
600
+ const webhook = (request, o = {}) => verifyWebhook(request, { issuer, url: o.url ?? `${originOf(request)}${new URL(request.url).pathname}`, jwks: o.jwks, now: o.now });
365
601
  return {
366
602
  handler,
603
+ webhook,
604
+ system: createSystem({ issuer, clientId, clientSecret, key: given.systemKey, fetch: given.fetch }),
367
605
  getSession,
368
606
  status,
369
607
  resolve: resolve2,
@@ -576,11 +814,11 @@ function toWire(theme) {
576
814
  return wire;
577
815
  }
578
816
  async function pushTheme(o) {
579
- const env2 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
580
- const issuer = (o.issuer ?? env2("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
581
- const clientId = o.clientId ?? env2("CAUTHY_CLIENT_ID") ?? "";
582
- const tenant = o.tenant ?? env2("CAUTHY_TENANT") ?? "";
583
- const token = o.token ?? env2("CAUTHY_TOKEN") ?? "";
817
+ const env3 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
818
+ const issuer = (o.issuer ?? env3("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
819
+ const clientId = o.clientId ?? env3("CAUTHY_CLIENT_ID") ?? "";
820
+ const tenant = o.tenant ?? env3("CAUTHY_TENANT") ?? "";
821
+ const token = o.token ?? env3("CAUTHY_TOKEN") ?? "";
584
822
  for (const [name, v] of Object.entries({ CAUTHY_ISSUER: issuer, CAUTHY_CLIENT_ID: clientId, CAUTHY_TENANT: tenant, CAUTHY_TOKEN: token })) {
585
823
  if (v === "") throw new Error(`cauthy-auth: ${name} is required to push a theme`);
586
824
  }
@@ -594,10 +832,94 @@ async function pushTheme(o) {
594
832
  throw new Error(`cauthy-auth: Cauthy refused the theme (${res.status}): ${body?.errors?.[0]?.message ?? res.statusText}`);
595
833
  }
596
834
  }
835
+
836
+ // src/permissions.ts
837
+ function matches(entry, resource, action) {
838
+ const i = entry.lastIndexOf(":");
839
+ if (i < 0) return false;
840
+ const r = entry.slice(0, i);
841
+ const a = entry.slice(i + 1);
842
+ return (r === "*" || r === resource) && (a === "*" || a === action);
843
+ }
844
+ function can(organization, resource, action) {
845
+ if (!organization?.permissions) return false;
846
+ if ((organization.denied ?? []).some((d) => matches(d, resource, action))) return false;
847
+ return organization.permissions.some((p) => matches(p, resource, action));
848
+ }
849
+ async function pushPolicy(o) {
850
+ const env3 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
851
+ const issuer = (o.issuer ?? env3("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
852
+ const system = o.system ?? env3("CAUTHY_SYSTEM") ?? "";
853
+ const tenant = o.tenant ?? env3("CAUTHY_TENANT") ?? "";
854
+ const token = o.token ?? env3("CAUTHY_TOKEN") ?? "";
855
+ for (const [name, v] of Object.entries({ CAUTHY_ISSUER: issuer, CAUTHY_SYSTEM: system, CAUTHY_TENANT: tenant, CAUTHY_TOKEN: token })) {
856
+ if (v === "") throw new Error(`cauthy-auth: ${name} is required to push a policy`);
857
+ }
858
+ const res = await (o.fetch ?? fetch)(`${issuer}/v1/systems/${encodeURIComponent(system)}/policy`, {
859
+ method: "PUT",
860
+ headers: { authorization: `Bearer ${token}`, "x-tenant-key": tenant, "content-type": "application/json" },
861
+ body: JSON.stringify({ roles: o.roles })
862
+ });
863
+ if (!res.ok) {
864
+ const body = await res.json().catch(() => null);
865
+ throw new Error(`cauthy-auth: Cauthy refused the policy (${res.status}): ${body?.errors?.[0]?.message ?? res.statusText}`);
866
+ }
867
+ }
868
+
869
+ // src/connect.ts
870
+ function sessionSecret() {
871
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
872
+ let s = "";
873
+ for (const b of bytes) s += String.fromCharCode(b);
874
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
875
+ }
876
+ async function connectSystem(o) {
877
+ const env3 = (n) => typeof process !== "undefined" ? process.env?.[n] : void 0;
878
+ const issuer = (o.issuer ?? env3("CAUTHY_ISSUER") ?? "").replace(/\/+$/, "");
879
+ const tenant = o.tenant ?? env3("CAUTHY_TENANT") ?? "";
880
+ const token = o.token ?? env3("CAUTHY_TOKEN") ?? "";
881
+ for (const [name, v] of Object.entries({ CAUTHY_ISSUER: issuer, CAUTHY_TENANT: tenant, CAUTHY_TOKEN: token })) {
882
+ if (v === "") throw new Error(`cauthy-auth: ${name} is required to connect a system`);
883
+ }
884
+ const res = await (o.fetch ?? fetch)(`${issuer}/v1/connect`, {
885
+ method: "POST",
886
+ headers: { authorization: `Bearer ${token}`, "x-tenant-key": tenant, "content-type": "application/json" },
887
+ body: JSON.stringify({
888
+ system: o.system,
889
+ app_url: o.appUrl,
890
+ name: o.name,
891
+ callback_path: o.callbackPath,
892
+ events_url: o.eventsUrl,
893
+ events: o.events,
894
+ jwks_uri: o.jwksUri
895
+ })
896
+ });
897
+ const body = await res.json().catch(() => null);
898
+ if (!res.ok || !body?.data) {
899
+ const why = (body?.errors ?? []).map((e) => e.message).filter(Boolean).join("; ") || res.statusText;
900
+ throw new Error(`cauthy-auth: Cauthy did not connect the system (${res.status}): ${why}`);
901
+ }
902
+ const d = body.data;
903
+ return {
904
+ system: d.system,
905
+ clientId: d.client.client_id,
906
+ clientSecret: d.client_secret,
907
+ redirectUris: d.client.redirect_uris,
908
+ webhook: d.webhook,
909
+ env: { ...d.env, CAUTHY_SECRET: sessionSecret() }
910
+ };
911
+ }
597
912
  export {
913
+ CauthyApiError,
598
914
  CauthyConfigError,
915
+ CauthyWebhookError,
916
+ can,
917
+ connectSystem,
599
918
  createCauthy,
919
+ createSystem,
920
+ pushPolicy,
600
921
  pushTheme,
601
- themeFromStackmyth
922
+ themeFromStackmyth,
923
+ verifyWebhook
602
924
  };
603
925
  //# sourceMappingURL=index.mjs.map