cauthy-auth 0.9.0 → 0.11.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/react.mjs CHANGED
@@ -3,6 +3,73 @@
3
3
  // src/react.tsx
4
4
  import { createContext, useCallback, useContext, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
5
5
 
6
+ // src/passkey.ts
7
+ function canUsePasskeys() {
8
+ return typeof window !== "undefined" && typeof window.PublicKeyCredential === "function" && typeof navigator !== "undefined" && typeof navigator.credentials?.get === "function";
9
+ }
10
+ function bytes(text2) {
11
+ const b64 = text2.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text2.length / 4) * 4, "=");
12
+ const bin = atob(b64);
13
+ const out = new Uint8Array(bin.length);
14
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
15
+ return out.buffer;
16
+ }
17
+ function text(buf) {
18
+ if (!buf) return null;
19
+ const view = new Uint8Array(buf);
20
+ let bin = "";
21
+ for (let i = 0; i < view.byteLength; i++) bin += String.fromCharCode(view[i]);
22
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
23
+ }
24
+ var withId = (list) => (list ?? []).map((c) => ({ ...c, id: bytes(c.id) }));
25
+ async function getPasskey(options) {
26
+ const publicKey = { ...options, challenge: bytes(options.challenge), allowCredentials: withId(options.allowCredentials) };
27
+ const cred = await navigator.credentials.get({ publicKey });
28
+ if (cred === null) throw new DOMException("No passkey was chosen.", "NotAllowedError");
29
+ const r = cred.response;
30
+ return {
31
+ id: cred.id,
32
+ rawId: text(cred.rawId),
33
+ type: cred.type,
34
+ response: {
35
+ authenticatorData: text(r.authenticatorData),
36
+ clientDataJSON: text(r.clientDataJSON),
37
+ signature: text(r.signature),
38
+ userHandle: text(r.userHandle)
39
+ },
40
+ clientExtensionResults: cred.getClientExtensionResults?.() ?? {}
41
+ };
42
+ }
43
+ async function makePasskey(options) {
44
+ const publicKey = {
45
+ ...options,
46
+ challenge: bytes(options.challenge),
47
+ user: { ...options.user, id: bytes(options.user.id) },
48
+ excludeCredentials: withId(options.excludeCredentials)
49
+ };
50
+ const cred = await navigator.credentials.create({ publicKey });
51
+ if (cred === null) throw new DOMException("No passkey was made.", "NotAllowedError");
52
+ const r = cred.response;
53
+ return {
54
+ id: cred.id,
55
+ rawId: text(cred.rawId),
56
+ type: cred.type,
57
+ response: {
58
+ clientDataJSON: text(r.clientDataJSON),
59
+ attestationObject: text(r.attestationObject),
60
+ transports: typeof r.getTransports === "function" ? r.getTransports() : []
61
+ },
62
+ clientExtensionResults: cred.getClientExtensionResults?.() ?? {}
63
+ };
64
+ }
65
+ function ceremonyError(e) {
66
+ const name = e instanceof Error || e instanceof DOMException ? e.name : "";
67
+ if (name === "NotAllowedError" || name === "AbortError") return { code: "cancelled", message: "The passkey was not used. Try again when you are ready." };
68
+ if (name === "InvalidStateError") return { code: "passkey_exists", message: "This device already has a passkey for this account." };
69
+ if (name === "SecurityError") return { code: "passkey_unavailable", message: "Passkeys cannot be used on this page." };
70
+ return { code: "passkey_failed", message: "The passkey could not be used. Try again, or sign in another way." };
71
+ }
72
+
6
73
  // src/theme.ts
7
74
  function themeVariables(theme) {
8
75
  const vars = {};
@@ -105,13 +172,17 @@ var UNAVAILABLE = { code: "unavailable", message: "Sign-in is unavailable right
105
172
  function createAuthClient(options = {}) {
106
173
  const base = (options.basePath ?? "/api/auth").replace(/\/+$/, "");
107
174
  const doFetch = (...args) => (options.fetch ?? fetch)(...args);
175
+ const locale = () => {
176
+ const said = typeof options.locale === "function" ? options.locale() : options.locale;
177
+ return (said ?? (typeof document === "undefined" ? "" : document.documentElement?.lang) ?? "").trim();
178
+ };
108
179
  async function post(path, form, bad) {
109
180
  if (bad) return { ok: false, error: { code: bad.code, message: bad.message, field: bad.field } };
110
181
  try {
111
182
  const res = await doFetch(`${base}${path}`, {
112
183
  method: "POST",
113
184
  credentials: "same-origin",
114
- headers: { "content-type": "application/json", accept: "application/json" },
185
+ headers: { "content-type": "application/json", accept: "application/json", ...locale() ? { "x-cauthy-locale": locale() } : {} },
115
186
  body: JSON.stringify(form)
116
187
  });
117
188
  const body = await res.json().catch(() => null);
@@ -139,6 +210,28 @@ function createAuthClient(options = {}) {
139
210
  if (o.locale) q.set("locale", o.locale);
140
211
  return `${base}/login?${q}`;
141
212
  };
213
+ async function methods() {
214
+ try {
215
+ const res = await doFetch(`${base}/methods`, { credentials: "same-origin", headers: { accept: "application/json" } });
216
+ if (res.ok) {
217
+ const m = await res.json();
218
+ return { providers: [], signUp: true, magicLink: false, passkey: false, ...m, passkeyHere: m.passkeyHere === true, passkeyOnCauthy: m.passkeyOnCauthy ?? m.passkey === true };
219
+ }
220
+ } catch {
221
+ }
222
+ return { providers: [], signUp: true, magicLink: false, passkey: false, passkeyHere: false, passkeyOnCauthy: false };
223
+ }
224
+ async function ceremony(begin, finish, form, run, more = {}) {
225
+ const begun = await post(begin, form, null);
226
+ if (!begun.ok) return begun;
227
+ let credential;
228
+ try {
229
+ credential = await run(begun.options);
230
+ } catch (e) {
231
+ return { ok: false, error: ceremonyError(e) };
232
+ }
233
+ return post(finish, { credential, ...more }, null);
234
+ }
142
235
  return {
143
236
  signIn: {
144
237
  email: (f) => post("/sign-in/email", { email: f.email, password: f.password }, firstProblem(checkEmail(f.email), checkPassword(f.password))),
@@ -147,8 +240,16 @@ function createAuthClient(options = {}) {
147
240
  magicLinkVerify: (f) => post("/sign-in/magic-link/verify", { token: f.token }, null),
148
241
  emailCode: (f) => post("/sign-in/email-code", { code: f.code }, checkCode(f.code)),
149
242
  passkeyUrl,
150
- passkey(o) {
243
+ async passkey(o) {
244
+ const m = await methods();
245
+ if (m.passkeyHere && canUsePasskeys()) {
246
+ const r = await ceremony("/sign-in/passkey/begin", "/sign-in/passkey/finish", {}, getPasskey);
247
+ if (r.ok || r.error.code !== "passkey_unavailable" || !m.passkeyOnCauthy) return r;
248
+ } else if (!m.passkeyOnCauthy) {
249
+ return { ok: false, error: { code: "passkey_unavailable", message: "Passkeys cannot be used here." } };
250
+ }
151
251
  window.location.assign(passkeyUrl(o));
252
+ return { ok: true, status: "redirected" };
152
253
  },
153
254
  socialUrl,
154
255
  social(provider, o) {
@@ -192,7 +293,16 @@ function createAuthClient(options = {}) {
192
293
  const body = await read("/me/sessions");
193
294
  return body && Array.isArray(body.sessions) ? body.sessions : [];
194
295
  },
195
- signOutEverywhere: () => post("/me/sessions/end", {}, null)
296
+ signOutEverywhere: () => post("/me/sessions/end", {}, null),
297
+ async passkeys() {
298
+ const body = await read("/me/passkeys");
299
+ return body && Array.isArray(body.passkeys) ? body.passkeys : [];
300
+ },
301
+ addPasskey(f = {}) {
302
+ if (!canUsePasskeys()) return Promise.resolve({ ok: false, error: { code: "passkey_unavailable", message: "This browser cannot make passkeys." } });
303
+ return ceremony("/me/passkeys/add/begin", "/me/passkeys/add/finish", { password: f.password ?? "" }, makePasskey, { name: f.name ?? "" });
304
+ },
305
+ removePasskey: (id) => post("/me/passkeys/remove", { id }, null)
196
306
  },
197
307
  async getSession() {
198
308
  try {
@@ -207,14 +317,7 @@ function createAuthClient(options = {}) {
207
317
  looked ??= read("/look").then((wire) => wire && typeof wire === "object" ? fromWire(wire) : null);
208
318
  return looked;
209
319
  },
210
- async methods() {
211
- try {
212
- const res = await doFetch(`${base}/methods`, { credentials: "same-origin", headers: { accept: "application/json" } });
213
- if (res.ok) return await res.json();
214
- } catch {
215
- }
216
- return { providers: [], signUp: true, magicLink: false, passkey: false };
217
- }
320
+ methods
218
321
  };
219
322
  }
220
323
 
@@ -321,6 +424,13 @@ var TEXTS = {
321
424
  keepCodes: "Keep these codes somewhere safe. Each works once, and they are not shown again.",
322
425
  passkeys: "Passkeys",
323
426
  managePasskeys: "Add or remove a passkey",
427
+ passkeysHelp: "Sign in with your fingerprint, your face or your device's lock, without a password.",
428
+ noPasskeys: "No passkey yet.",
429
+ passkeyName: "A name for this device (optional)",
430
+ addPasskey: "Add a passkey on this device",
431
+ removePasskey: "Remove",
432
+ passkeyAdded: "The passkey was added. Next time, sign in with it.",
433
+ passkeyUnnamed: "Passkey",
324
434
  sessions: "Where you are signed in",
325
435
  signOutEverywhere: "Sign out everywhere",
326
436
  unknownDevice: "Unknown device",
@@ -351,7 +461,10 @@ var TEXTS = {
351
461
  wrong_password: "The current password is not right.",
352
462
  step_up_required: "Enter your current password.",
353
463
  weak_password: "Choose a stronger password.",
354
- unauthenticated: "Sign in first."
464
+ unauthenticated: "Sign in first.",
465
+ passkey_failed: "The passkey could not be used. Try again, or sign in another way.",
466
+ passkey_exists: "This device already has a passkey for your account.",
467
+ passkey_unavailable: "Passkeys cannot be used here."
355
468
  }
356
469
  },
357
470
  es: {
@@ -400,6 +513,13 @@ var TEXTS = {
400
513
  keepCodes: "Guarda estos c\xF3digos en un lugar seguro. Cada uno sirve una vez y no se vuelven a mostrar.",
401
514
  passkeys: "Llaves de acceso",
402
515
  managePasskeys: "Agregar o quitar una llave de acceso",
516
+ passkeysHelp: "Entra con tu huella, tu rostro o el bloqueo de tu dispositivo, sin contrase\xF1a.",
517
+ noPasskeys: "Todav\xEDa no tienes llaves de acceso.",
518
+ passkeyName: "Un nombre para este dispositivo (opcional)",
519
+ addPasskey: "Agregar una llave en este dispositivo",
520
+ removePasskey: "Quitar",
521
+ passkeyAdded: "La llave de acceso qued\xF3 agregada. La pr\xF3xima vez, entra con ella.",
522
+ passkeyUnnamed: "Llave de acceso",
403
523
  sessions: "D\xF3nde tienes la sesi\xF3n abierta",
404
524
  signOutEverywhere: "Cerrar sesi\xF3n en todas partes",
405
525
  unknownDevice: "Dispositivo desconocido",
@@ -430,7 +550,10 @@ var TEXTS = {
430
550
  wrong_password: "La contrase\xF1a actual no es correcta.",
431
551
  step_up_required: "Escribe tu contrase\xF1a actual.",
432
552
  weak_password: "Elige una contrase\xF1a m\xE1s fuerte.",
433
- unauthenticated: "Inicia sesi\xF3n primero."
553
+ unauthenticated: "Inicia sesi\xF3n primero.",
554
+ passkey_failed: "No se pudo usar la llave de acceso. Intenta de nuevo o entra de otra forma.",
555
+ passkey_exists: "Este dispositivo ya tiene una llave de acceso de tu cuenta.",
556
+ passkey_unavailable: "Aqu\xED no se pueden usar llaves de acceso."
434
557
  }
435
558
  }
436
559
  };
@@ -568,6 +691,15 @@ function SignIn(props) {
568
691
  if (r.status === "mfa_required") return setStep("code");
569
692
  f.done();
570
693
  };
694
+ const usePasskey = async () => {
695
+ f.setBusy(true);
696
+ f.setError(null);
697
+ const r = await auth.signIn.passkey({ returnTo: props.returnTo, locale: props.locale });
698
+ if (r.ok && r.status === "redirected") return;
699
+ f.setBusy(false);
700
+ if (!r.ok) return r.error.code === "cancelled" ? void 0 : f.setError(r.error);
701
+ f.done();
702
+ };
571
703
  const sendLink = async () => {
572
704
  f.setBusy(true);
573
705
  f.setError(null);
@@ -602,7 +734,7 @@ function SignIn(props) {
602
734
  " ",
603
735
  p.name
604
736
  ] }, p.id)),
605
- methods?.passkey && /* @__PURE__ */ jsx(f.Button, { type: "button", className: f.cx("providerButton"), onClick: () => auth.signIn.passkey({ returnTo: props.returnTo, locale: props.locale }), children: f.t.usePasskey }),
737
+ methods?.passkey && /* @__PURE__ */ jsx(f.Button, { type: "button", disabled: f.busy, className: f.cx("providerButton"), onClick: () => void usePasskey(), children: f.t.usePasskey }),
606
738
  ((methods?.providers.length ?? 0) > 0 || methods?.passkey) && /* @__PURE__ */ jsx("p", { className: f.cx("separator"), children: f.t.orWithEmail }),
607
739
  /* @__PURE__ */ jsx(Field, { f, name: "email", type: "email", label: f.t.email, value: email, autoComplete: "username", required: true, onChange: setEmail }),
608
740
  /* @__PURE__ */ jsx(
@@ -788,10 +920,7 @@ function Account(props) {
788
920
  account.hasPassword && /* @__PURE__ */ jsx(PasswordPart, { props }),
789
921
  /* @__PURE__ */ jsx(LinkedPart, { props }),
790
922
  /* @__PURE__ */ jsx(SecondStepPart, { props, account, onChanged: (mfaEnabled) => setAccount({ ...account, mfaEnabled }) }),
791
- /* @__PURE__ */ jsxs("section", { className: cx("section"), children: [
792
- /* @__PURE__ */ jsx("h2", { className: cx("heading"), children: t.passkeys }),
793
- /* @__PURE__ */ jsx("a", { className: cx("link"), href: accountUrl(), children: t.managePasskeys })
794
- ] }),
923
+ /* @__PURE__ */ jsx(PasskeysPart, { props, account, accountUrl: accountUrl(), when }),
795
924
  /* @__PURE__ */ jsxs("section", { className: cx("section"), children: [
796
925
  /* @__PURE__ */ jsx("h2", { className: cx("heading"), children: t.sessions }),
797
926
  /* @__PURE__ */ jsx("ul", { className: cx("sessions"), children: sessions.map((s, i) => /* @__PURE__ */ jsxs("li", { className: cx("session"), children: [
@@ -805,6 +934,60 @@ function Account(props) {
805
934
  ] })
806
935
  ] });
807
936
  }
937
+ function PasskeysPart({ props, account, accountUrl, when }) {
938
+ const auth = useAuth();
939
+ const { f, done, setDone, run, general } = useSection(props);
940
+ const [here, setHere] = useState(null);
941
+ const [list, setList] = useState([]);
942
+ const [name, setName] = useState("");
943
+ const [password, setPassword] = useState("");
944
+ useEffect(() => {
945
+ void auth.methods().then((m) => {
946
+ const can2 = m.passkeyHere && canUsePasskeys();
947
+ setHere(can2);
948
+ if (can2) void auth.account.passkeys().then(setList);
949
+ });
950
+ }, []);
951
+ if (here === null) return null;
952
+ if (!here) {
953
+ return /* @__PURE__ */ jsxs("section", { className: f.cx("section"), children: [
954
+ /* @__PURE__ */ jsx("h2", { className: f.cx("heading"), children: f.t.passkeys }),
955
+ /* @__PURE__ */ jsx("a", { className: f.cx("link"), href: accountUrl, children: f.t.managePasskeys })
956
+ ] });
957
+ }
958
+ const add = async (e) => {
959
+ e.preventDefault();
960
+ const r = await run(() => auth.account.addPasskey({ name, password }));
961
+ if (!r.ok) {
962
+ if (r.error.code === "cancelled") f.setError(null);
963
+ return;
964
+ }
965
+ setList([...list, r.passkey]);
966
+ setName("");
967
+ setPassword("");
968
+ setDone(f.t.passkeyAdded);
969
+ };
970
+ const remove = async (id) => {
971
+ const r = await run(() => auth.account.removePasskey(id));
972
+ if (r.ok) setList(list.filter((p) => p.id !== id));
973
+ };
974
+ return /* @__PURE__ */ jsxs("form", { className: f.cx("section"), onSubmit: add, noValidate: true, children: [
975
+ /* @__PURE__ */ jsx("h2", { className: f.cx("heading"), children: f.t.passkeys }),
976
+ /* @__PURE__ */ jsx("p", { className: f.cx("notice"), children: f.t.passkeysHelp }),
977
+ general && /* @__PURE__ */ jsx("p", { role: "alert", className: f.cx("error"), children: f.say(general) }),
978
+ list.length === 0 ? /* @__PURE__ */ jsx("p", { className: f.cx("notice"), children: f.t.noPasskeys }) : /* @__PURE__ */ jsx("ul", { className: f.cx("sessions"), children: list.map((p) => /* @__PURE__ */ jsxs("li", { className: f.cx("session"), children: [
979
+ p.name || f.t.passkeyUnnamed,
980
+ " \xB7 ",
981
+ when(p.createdAt),
982
+ " ",
983
+ /* @__PURE__ */ jsx(f.Button, { type: "button", disabled: f.busy, className: f.cx("providerButton"), onClick: () => void remove(p.id), children: f.t.removePasskey })
984
+ ] }, p.id)) }),
985
+ /* @__PURE__ */ jsx(Field, { f, name: "name", type: "text", label: f.t.passkeyName, value: name, autoComplete: "off", onChange: setName }),
986
+ account.hasPassword && /* @__PURE__ */ jsx(Field, { f, name: "password", type: "password", label: f.t.currentPassword, value: password, autoComplete: "current-password", required: true, onChange: setPassword }),
987
+ /* @__PURE__ */ jsx(f.Button, { type: "submit", disabled: f.busy, className: f.cx("button"), children: f.t.addPasskey }),
988
+ done && /* @__PURE__ */ jsx("p", { role: "status", className: f.cx("notice"), children: done })
989
+ ] });
990
+ }
808
991
  function ProfilePart({ props, account, onSaved }) {
809
992
  const auth = useAuth();
810
993
  const { f, done, setDone, run, general } = useSection(props);
@@ -1093,6 +1276,7 @@ export {
1093
1276
  SignUp,
1094
1277
  VerifyEmail,
1095
1278
  can,
1279
+ canUsePasskeys,
1096
1280
  createAuthClient,
1097
1281
  definePolicy,
1098
1282
  useAuth,