cauthy-auth 0.11.0 → 0.13.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/client.d.ts CHANGED
@@ -6,6 +6,12 @@ import 'jose';
6
6
 
7
7
  /** Whether this browser can use a passkey at all. */
8
8
  declare function canUsePasskeys(): boolean;
9
+ /**
10
+ * Whether this browser offers passkeys in the autofill of a field marked
11
+ * `autocomplete="username webauthn"`, so the person picks one without
12
+ * looking for a button.
13
+ */
14
+ declare function canAutofillPasskeys(): Promise<boolean>;
9
15
 
10
16
  interface SessionState {
11
17
  /** The signed-in person and their organizations, or null. */
@@ -87,6 +93,13 @@ interface AuthMethods {
87
93
  * page that cannot, or a browser that refuses to here, sends the person.
88
94
  */
89
95
  passkeyOnCauthy: boolean;
96
+ /**
97
+ * The workspace's Cloudflare Turnstile site key, when it asks for the
98
+ * challenge: signing in, creating an account, and asking for a sign-in link
99
+ * or a new password then take its answer (`captchaToken`). The ready-made
100
+ * forms show it themselves.
101
+ */
102
+ captchaSiteKey?: string;
90
103
  }
91
104
  /** A passkey of the signed-in account. Moments are UTC. */
92
105
  interface Passkey {
@@ -128,6 +141,17 @@ interface LinkedIdentity {
128
141
  /** When it was linked, in UTC (ISO 8601). */
129
142
  linkedAt: string;
130
143
  }
144
+ /** How an account takes its second step. */
145
+ interface SecondStep {
146
+ totp: boolean;
147
+ /** The phone codes are sent to, masked (`+57 ••• ••• 4567`). */
148
+ phone: {
149
+ number: string;
150
+ channel: string;
151
+ } | null;
152
+ /** The ways the workspace sends codes to phones. */
153
+ phoneChannels: string[];
154
+ }
131
155
  interface AccountClient {
132
156
  /** The account, or null when nobody is signed in. */
133
157
  get(): Promise<Account | null>;
@@ -166,6 +190,30 @@ interface AccountClient {
166
190
  }): Promise<AuthResult<'renewed', {
167
191
  recoveryCodes: string[];
168
192
  }>>;
193
+ /**
194
+ * How the account takes its second step: an authenticator app, a phone
195
+ * (masked), and the ways the workspace sends codes to phones (`sms`,
196
+ * `whatsapp`; none when it has no Twilio account for it).
197
+ */
198
+ factors(): Promise<SecondStep | null>;
199
+ /** Adds a phone as the second step: a code is sent to it, for `verifyPhone`. */
200
+ addPhone(form: {
201
+ phone: string;
202
+ channel: 'sms' | 'whatsapp';
203
+ password: string;
204
+ }): Promise<AuthResult<'sent', {
205
+ sentTo: string;
206
+ channel: string;
207
+ }>>;
208
+ /** Proves the phone with the code it received. `recoveryCodes` come with a first second step, shown this once. */
209
+ verifyPhone(form: {
210
+ code: string;
211
+ }): Promise<AuthResult<'enabled', {
212
+ recoveryCodes: string[];
213
+ }>>;
214
+ removePhone(form: {
215
+ password: string;
216
+ }): Promise<AuthResult<'removed'>>;
169
217
  };
170
218
  /**
171
219
  * Asks to sign in with another address. Nothing changes yet: the new address
@@ -222,6 +270,7 @@ interface AuthClient {
222
270
  email(form: {
223
271
  email: string;
224
272
  password: string;
273
+ captchaToken?: string;
225
274
  }): Promise<AuthResult<'signed_in', {
226
275
  session: PublicSession;
227
276
  }> | AuthResult<'mfa_required'>>;
@@ -231,9 +280,23 @@ interface AuthClient {
231
280
  }): Promise<AuthResult<'signed_in', {
232
281
  session: PublicSession;
233
282
  }>>;
283
+ /** While the second step is asked for: whether the account has an authenticator app, and the phone (masked) a code can be sent to. */
284
+ mfaMethods(): Promise<AuthResult<'ok', {
285
+ totp: boolean;
286
+ phone: {
287
+ number: string;
288
+ channel: string;
289
+ } | null;
290
+ }>>;
291
+ /** Sends a code to the account's phone, by SMS or WhatsApp; `mfa` takes it. A few per sign-in. */
292
+ mfaSendCode(): Promise<AuthResult<'sent', {
293
+ sentTo: string;
294
+ channel: string;
295
+ }>>;
234
296
  /** Mails a link that signs the person in. The answer is the same whether or not the address has an account. */
235
297
  magicLink(form: {
236
298
  email: string;
299
+ captchaToken?: string;
237
300
  }): Promise<AuthResult<'sent'>>;
238
301
  /** Signs in with the token the mailed link opened the page with (`ml_token`). */
239
302
  magicLinkVerify(form: {
@@ -261,6 +324,34 @@ interface AuthClient {
261
324
  }): Promise<AuthResult<'signed_in', {
262
325
  session: PublicSession;
263
326
  }> | AuthResult<'redirected'>>;
327
+ /**
328
+ * Offers the person's passkeys in the browser's autofill of the email
329
+ * field, which must carry `autocomplete="username webauthn"`, and signs in
330
+ * with the one they pick. It waits until they pick one or `signal`
331
+ * aborts: abort it before calling `passkey` or sending the form.
332
+ * `cancelled`: aborted, or this browser or page cannot (then there is
333
+ * nothing to offer, and the passkey button still works).
334
+ */
335
+ passkeyAutofill(options?: {
336
+ signal?: AbortSignal;
337
+ }): Promise<AuthResult<'signed_in', {
338
+ session: PublicSession;
339
+ }>>;
340
+ /**
341
+ * Google One Tap: Google's prompt over the page offers the Google account
342
+ * the browser already has, and signs in with it in one tap, as the Google
343
+ * sign-in does (the same account). Needs the workspace's Google sign-in,
344
+ * and the page's address among the Google client's authorized JavaScript
345
+ * origins. Waits until the person picks an account or closes the prompt
346
+ * (`cancelled`), or `signal` aborts. `one_tap_unavailable`: the workspace
347
+ * has no Google sign-in.
348
+ */
349
+ googleOneTap(options?: {
350
+ signal?: AbortSignal;
351
+ context?: 'signin' | 'signup' | 'use';
352
+ }): Promise<AuthResult<'signed_in', {
353
+ session: PublicSession;
354
+ }>>;
264
355
  /** Where `passkey` sends the browser when this page cannot use the passkey itself. */
265
356
  passkeyUrl(options?: {
266
357
  returnTo?: string;
@@ -281,6 +372,7 @@ interface AuthClient {
281
372
  email: string;
282
373
  password: string;
283
374
  name?: string;
375
+ captchaToken?: string;
284
376
  }): Promise<AuthResult<'signed_in', {
285
377
  session: PublicSession;
286
378
  }> | AuthResult<'verification_required', {
@@ -290,6 +382,7 @@ interface AuthClient {
290
382
  /** Mails a link to choose a new password. The answer is the same whether or not the address has an account. */
291
383
  forgotPassword(form: {
292
384
  email: string;
385
+ captchaToken?: string;
293
386
  }): Promise<AuthResult<'sent'>>;
294
387
  /** Sets the new password, with the token the mailed link opened the page with (`token`). */
295
388
  resetPassword(form: {
@@ -326,4 +419,4 @@ interface AuthClientOptions {
326
419
  }
327
420
  declare function createAuthClient(options?: AuthClientOptions): AuthClient;
328
421
 
329
- export { type Account, type AccountClient, type AccountSession, type AuthClient, type AuthClientOptions, type AuthError, AuthField, type AuthMethods, type AuthResult, type LinkedIdentity, type Passkey, type SessionState, type SessionStore, type SessionStoreOptions, canUsePasskeys, createAuthClient, createSessionStore };
422
+ export { type Account, type AccountClient, type AccountSession, type AuthClient, type AuthClientOptions, type AuthError, AuthField, type AuthMethods, type AuthResult, type LinkedIdentity, type Passkey, type SecondStep, type SessionState, type SessionStore, type SessionStoreOptions, canAutofillPasskeys, canUsePasskeys, createAuthClient, createSessionStore };
package/dist/client.js CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var client_exports = {};
22
22
  __export(client_exports, {
23
23
  MIN_PASSWORD: () => MIN_PASSWORD,
24
+ canAutofillPasskeys: () => canAutofillPasskeys,
24
25
  canUsePasskeys: () => canUsePasskeys,
25
26
  checkCode: () => checkCode,
26
27
  checkEmail: () => checkEmail,
@@ -36,6 +37,15 @@ module.exports = __toCommonJS(client_exports);
36
37
  function canUsePasskeys() {
37
38
  return typeof window !== "undefined" && typeof window.PublicKeyCredential === "function" && typeof navigator !== "undefined" && typeof navigator.credentials?.get === "function";
38
39
  }
40
+ async function canAutofillPasskeys() {
41
+ if (!canUsePasskeys()) return false;
42
+ const pkc = window.PublicKeyCredential;
43
+ try {
44
+ return typeof pkc.isConditionalMediationAvailable === "function" && await pkc.isConditionalMediationAvailable() === true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
39
49
  function bytes(text2) {
40
50
  const b64 = text2.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text2.length / 4) * 4, "=");
41
51
  const bin = atob(b64);
@@ -51,9 +61,9 @@ function text(buf) {
51
61
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
52
62
  }
53
63
  var withId = (list) => (list ?? []).map((c) => ({ ...c, id: bytes(c.id) }));
54
- async function getPasskey(options) {
64
+ async function getPasskey(options, how = {}) {
55
65
  const publicKey = { ...options, challenge: bytes(options.challenge), allowCredentials: withId(options.allowCredentials) };
56
- const cred = await navigator.credentials.get({ publicKey });
66
+ const cred = await navigator.credentials.get({ publicKey, ...how });
57
67
  if (cred === null) throw new DOMException("No passkey was chosen.", "NotAllowedError");
58
68
  const r = cred.response;
59
69
  return {
@@ -281,6 +291,24 @@ function createSessionStore(options = {}) {
281
291
  }
282
292
 
283
293
  // src/client.ts
294
+ var googleScript = null;
295
+ function loadGoogleIdentity() {
296
+ const ready = () => window.google?.accounts?.id;
297
+ if (ready()) return Promise.resolve(ready());
298
+ googleScript ??= new Promise((resolve, reject) => {
299
+ const script = document.createElement("script");
300
+ script.src = "https://accounts.google.com/gsi/client";
301
+ script.async = true;
302
+ script.onload = () => ready() ? resolve(ready()) : reject(new Error("google identity services did not load"));
303
+ script.onerror = () => {
304
+ googleScript = null;
305
+ reject(new Error("google identity services did not load"));
306
+ };
307
+ document.head.appendChild(script);
308
+ });
309
+ return googleScript;
310
+ }
311
+ var PASSKEY_SECONDS = 300;
284
312
  var UNAVAILABLE = { code: "unavailable", message: "Sign-in is unavailable right now. Try again in a moment." };
285
313
  function createAuthClient(options = {}) {
286
314
  const base = (options.basePath ?? "/api/auth").replace(/\/+$/, "");
@@ -289,13 +317,13 @@ function createAuthClient(options = {}) {
289
317
  const said = typeof options.locale === "function" ? options.locale() : options.locale;
290
318
  return (said ?? (typeof document === "undefined" ? "" : document.documentElement?.lang) ?? "").trim();
291
319
  };
292
- async function post(path, form, bad) {
320
+ async function post(path, form, bad, captchaToken) {
293
321
  if (bad) return { ok: false, error: { code: bad.code, message: bad.message, field: bad.field } };
294
322
  try {
295
323
  const res = await doFetch(`${base}${path}`, {
296
324
  method: "POST",
297
325
  credentials: "same-origin",
298
- headers: { "content-type": "application/json", accept: "application/json", ...locale() ? { "x-cauthy-locale": locale() } : {} },
326
+ headers: { "content-type": "application/json", accept: "application/json", ...locale() ? { "x-cauthy-locale": locale() } : {}, ...captchaToken ? { "x-cauthy-captcha": captchaToken } : {} },
299
327
  body: JSON.stringify(form)
300
328
  });
301
329
  const body = await res.json().catch(() => null);
@@ -347,9 +375,11 @@ function createAuthClient(options = {}) {
347
375
  }
348
376
  return {
349
377
  signIn: {
350
- email: (f) => post("/sign-in/email", { email: f.email, password: f.password }, firstProblem(checkEmail(f.email), checkPassword(f.password))),
378
+ email: (f) => post("/sign-in/email", { email: f.email, password: f.password }, firstProblem(checkEmail(f.email), checkPassword(f.password)), f.captchaToken),
351
379
  mfa: (f) => post("/sign-in/mfa", { code: f.code }, checkCode(f.code)),
352
- magicLink: (f) => post("/sign-in/magic-link", { email: f.email }, checkEmail(f.email)),
380
+ mfaMethods: () => post("/sign-in/mfa/methods", {}, null),
381
+ mfaSendCode: () => post("/sign-in/mfa/send", {}, null),
382
+ magicLink: (f) => post("/sign-in/magic-link", { email: f.email }, checkEmail(f.email), f.captchaToken),
353
383
  magicLinkVerify: (f) => post("/sign-in/magic-link/verify", { token: f.token }, null),
354
384
  emailCode: (f) => post("/sign-in/email-code", { code: f.code }, checkCode(f.code)),
355
385
  passkeyUrl,
@@ -364,15 +394,75 @@ function createAuthClient(options = {}) {
364
394
  window.location.assign(passkeyUrl(o));
365
395
  return { ok: true, status: "redirected" };
366
396
  },
397
+ async passkeyAutofill(o = {}) {
398
+ const cancelled = { ok: false, error: { code: "cancelled", message: "No passkey was chosen." } };
399
+ const m = await methods();
400
+ if (!m.passkeyHere || !await canAutofillPasskeys()) return cancelled;
401
+ for (; ; ) {
402
+ if (o.signal?.aborted) return cancelled;
403
+ const begun = await post("/sign-in/passkey/begin", {}, null);
404
+ if (!begun.ok) return begun;
405
+ const renew = new AbortController();
406
+ const stop = () => renew.abort();
407
+ o.signal?.addEventListener("abort", stop, { once: true });
408
+ const timer = setTimeout(stop, (PASSKEY_SECONDS - 30) * 1e3);
409
+ let credential;
410
+ try {
411
+ credential = await getPasskey(begun.options, { mediation: "conditional", signal: renew.signal });
412
+ } catch (e) {
413
+ if (o.signal?.aborted) return cancelled;
414
+ if (renew.signal.aborted) continue;
415
+ return { ok: false, error: ceremonyError(e) };
416
+ } finally {
417
+ clearTimeout(timer);
418
+ o.signal?.removeEventListener("abort", stop);
419
+ }
420
+ return post("/sign-in/passkey/finish", { credential }, null);
421
+ }
422
+ },
423
+ async googleOneTap(o = {}) {
424
+ const cancelled = { ok: false, error: { code: "cancelled", message: "No Google account was chosen." } };
425
+ if (typeof window === "undefined" || o.signal?.aborted) return cancelled;
426
+ const begun = await post("/sign-in/google-one-tap/begin", {}, null);
427
+ if (!begun.ok) return begun;
428
+ let gis;
429
+ try {
430
+ gis = await loadGoogleIdentity();
431
+ } catch {
432
+ return { ok: false, error: { code: "unavailable", message: "Google could not be reached. Try again, or sign in another way." } };
433
+ }
434
+ const credential = await new Promise((done) => {
435
+ o.signal?.addEventListener("abort", () => {
436
+ gis.cancel();
437
+ done(null);
438
+ }, { once: true });
439
+ gis.initialize({
440
+ client_id: begun.clientId,
441
+ nonce: begun.nonce,
442
+ context: o.context ?? "signin",
443
+ callback: (r) => done(r.credential ?? null),
444
+ auto_select: false,
445
+ cancel_on_tap_outside: true,
446
+ itp_support: true,
447
+ use_fedcm_for_prompt: true
448
+ });
449
+ gis.prompt((moment) => {
450
+ if (moment.isNotDisplayed?.() || moment.isSkippedMoment?.()) done(null);
451
+ else if (moment.isDismissedMoment?.() && moment.getDismissedReason?.() !== "credential_returned") done(null);
452
+ });
453
+ });
454
+ if (credential === null) return cancelled;
455
+ return post("/sign-in/google-one-tap", { credential }, null);
456
+ },
367
457
  socialUrl,
368
458
  social(provider, o) {
369
459
  window.location.assign(socialUrl(provider, o));
370
460
  }
371
461
  },
372
462
  signUp: {
373
- email: (f) => post("/sign-up/email", { email: f.email, password: f.password, name: f.name }, firstProblem(checkName(f.name), checkEmail(f.email), checkNewPassword(f.password)))
463
+ email: (f) => post("/sign-up/email", { email: f.email, password: f.password, name: f.name }, firstProblem(checkName(f.name), checkEmail(f.email), checkNewPassword(f.password)), f.captchaToken)
374
464
  },
375
- forgotPassword: (f) => post("/forgot-password", { email: f.email }, checkEmail(f.email)),
465
+ forgotPassword: (f) => post("/forgot-password", { email: f.email }, checkEmail(f.email), f.captchaToken),
376
466
  resetPassword: (f) => post("/reset-password", { token: f.token, password: f.password }, checkNewPassword(f.password)),
377
467
  verifyEmail: (f) => post("/verify-email", { token: f.token }, null),
378
468
  signOut: () => post("/sign-out", {}, null),
@@ -390,7 +480,14 @@ function createAuthClient(options = {}) {
390
480
  setup: (f) => post("/me/mfa/setup", { password: f.password }, null),
391
481
  verify: (f) => post("/me/mfa/verify", { code: f.code }, checkCode(f.code)),
392
482
  disable: (f) => post("/me/mfa/disable", { password: f.password }, null),
393
- recoveryCodes: (f) => post("/me/mfa/recovery-codes", { password: f.password }, null)
483
+ recoveryCodes: (f) => post("/me/mfa/recovery-codes", { password: f.password }, null),
484
+ async factors() {
485
+ const body = await read("/me/mfa/factors");
486
+ return body && typeof body.totp === "boolean" ? body : null;
487
+ },
488
+ addPhone: (f) => post("/me/mfa/phone", { phone: f.phone, channel: f.channel, password: f.password }, null),
489
+ verifyPhone: (f) => post("/me/mfa/phone/verify", { code: f.code }, checkCode(f.code)),
490
+ removePhone: (f) => post("/me/mfa/phone/remove", { password: f.password }, null)
394
491
  },
395
492
  changeEmail: (f) => post("/me/email", { email: f.email, password: f.password }, checkEmail(f.email)),
396
493
  async identities() {
@@ -436,6 +533,7 @@ function createAuthClient(options = {}) {
436
533
  // Annotate the CommonJS export names for ESM import in node:
437
534
  0 && (module.exports = {
438
535
  MIN_PASSWORD,
536
+ canAutofillPasskeys,
439
537
  canUsePasskeys,
440
538
  checkCode,
441
539
  checkEmail,