@wtfalch/auth 0.16.0 → 0.19.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @wtfalch/auth
2
2
 
3
- Sign in against [auth.wtfalch.dev](https://auth.wtfalch.dev) from a Next.js app.
3
+ Sign in against a self-hosted ZITADEL issuer from a Next.js app.
4
4
 
5
5
  A thin OIDC client for one self-hosted ZITADEL instance, plus a client for the
6
6
  sign-in service that sits in front of it. Apps host their own password sign-in
@@ -25,6 +25,7 @@ import { nextAuth } from '@wtfalch/auth/next';
25
25
 
26
26
  export const { handlers, proxy, getUser, requireUser, signIn } = nextAuth({
27
27
  appUrl: process.env.APP_URL,
28
+ issuer: process.env.AUTH_ISSUER,
28
29
  clientId: process.env.AUTH_CLIENT_ID,
29
30
  organizationId: process.env.AUTH_ORGANIZATION_ID,
30
31
  cookieSecret: process.env.AUTH_COOKIE_SECRET,
@@ -40,6 +41,39 @@ middleware for the paths that need a session, and read the person with
40
41
  `createAuth` from `@wtfalch/auth` is the same thing without the framework, for
41
42
  a CLI or a worker that has no router to import.
42
43
 
44
+ ## One-time passwords
45
+
46
+ An account created with `changeRequired: true` holds a one-time password.
47
+ Signing in with it gives no session and no token. `signIn`,
48
+ `signInWithPassword` and `signUp` answer
49
+ `{ ok: false, error: 'password_change_required', changeToken }` (in `nextAuth`,
50
+ `{ error, changeToken }`). Show a "choose a new password" form and call
51
+ `setNewPassword({ authRequestId, changeToken, newPassword })` (or
52
+ `setNewPasswordHeadless({ changeToken, newPassword })`, as `signInWithPassword`
53
+ is to `signIn`). It needs no old password and signs the person in. The
54
+ `changeToken` is good for 5 minutes. The one-time password itself stops working
55
+ 72 hours after it was set (or after the account was created, when the issuer
56
+ reports no password date): sign-in then answers `invalid_credentials`. The
57
+ person recovers through an owner or Archon reset, which sets the flag again, or
58
+ their own forgot-password reset. The limit covers every sign-in that goes
59
+ through the broker, including hosted-login handoffs; a sign-in made at the
60
+ issuer itself, which never reaches the broker, is not covered. `changeToken`s
61
+ live in the broker's memory, so the broker must run as one replica. The browser `SignInOutcome` is unchanged: it never
62
+ sees the password.
63
+
64
+ ## Clearing cookies in Server Actions
65
+
66
+ The Next adapter writes every core cookie description with its attributes,
67
+ including an empty value and `maxAge: 0` for clearing. Transaction, QR, email-link
68
+ and session cookies keep their root path, host-only scope, HttpOnly and SameSite
69
+ settings. HTTPS cookies also keep Secure and the `__Host-` prefix; local HTTP
70
+ cookies use ordinary names without Secure. This applies to callback completion,
71
+ password reset and QR actions, including namespace sessions.
72
+
73
+ Next supports expiring cookies through
74
+ [`cookies().set`](https://nextjs.org/docs/app/api-reference/functions/cookies#deleting-cookies).
75
+ The adapter passes the core attributes through for clearing as well as writes.
76
+
43
77
  ## MFA and hosted login
44
78
 
45
79
  The broker checks the organisation's login policy and enrolled authentication
@@ -70,7 +104,7 @@ across the estate's subdomains**.
70
104
  import { createBrowserAuth } from '@wtfalch/auth/browser';
71
105
 
72
106
  const auth = createBrowserAuth({
73
- issuer: 'https://auth.wtfalch.dev',
107
+ issuer: 'https://auth.example.com',
74
108
  clientId: '…', // this app's client id at the issuer
75
109
  callbackPath: '/signed-in', // when `/auth/callback` is not yours to host
76
110
  });
@@ -108,7 +142,7 @@ behaviour above exactly, so a caller that sets none of them sees no change.
108
142
 
109
143
  ```ts
110
144
  const auth = createBrowserAuth({
111
- issuer: 'https://auth.wtfalch.dev',
145
+ issuer: 'https://auth.example.com',
112
146
  clientId: '…',
113
147
  open: (url) => Browser.open({ url }), // Capacitor's system-browser opener
114
148
  store: capacitorSecureStore, // see "Storage stays synchronous" below
@@ -122,9 +156,11 @@ const auth = createBrowserAuth({
122
156
  - **`store`** replaces the `sessionStorage` wrapper that holds the token,
123
157
  verifier, return path and silent-tried mark, with a plain
124
158
  `{ get, set, drop }` shape — `get` synchronous, `set`/`drop` allowed to
125
- return a promise (see "Storage: `get` stays synchronous" below). Two
126
- `createBrowserAuth` instances given different stores never see each other's
127
- values — one app's session cannot leak into another's held in the same tab.
159
+ return a promise (see "Storage: `get` stays synchronous" below). Custom
160
+ stores retain the existing `wtfalch.auth.*` keys. Supply separate
161
+ backing storage per client context; different wrappers around the same
162
+ backing keys share values. Reuse the same store object for instances that
163
+ should coordinate pending operations.
128
164
  - **`refresh()`**, a new method on the returned client, posts the
129
165
  `refresh_token` grant and stores the session it gets back — awaiting the
130
166
  store's write before it resolves, so an app that awaits `refresh()` knows
@@ -147,7 +183,36 @@ const auth = createBrowserAuth({
147
183
  sign-in, so this only matters there.
148
184
  - **`forget()`** is now `Promise<void>` rather than `void`, the one signature
149
185
  change here: it awaits the store's writes, the same as `completeSignIn()`
150
- and `refresh()`.
186
+ and `refresh()`. It also cancels pending authorization, completion and
187
+ refresh operations in the same store/context before cleanup starts.
188
+
189
+ **Default storage and clients on one origin.** Legacy browser clients now use
190
+ versioned `sessionStorage` keys bound to issuer, client ID, origin, callback URI
191
+ and the requested scope set (including extra scopes). Matching contexts share
192
+ sessions, pending proof/return path and the silent-attempt mark; different
193
+ contexts keep each of those separate. Scope order and repeated scopes do not
194
+ create a new context. Old unbound `wtfalch.auth.*` default values are ignored,
195
+ with no automatic migration: users sign in once after upgrading. Custom stores
196
+ keep their established keys and need separate backing storage per context.
197
+
198
+ Instances using the same default context or the same custom store object share
199
+ write coordination and refresh requests within this loaded SDK. `forget()`
200
+ invalidates pending generations immediately, removes the token and pending
201
+ proof/return path, and sets the silent-attempt mark. Network responses are
202
+ checked before committing. If an async store write has already started,
203
+ `forget()` waits for it and then removes its result; await `forget()` before
204
+ reading the final state. A cancelled completion returns `kind: 'error'`; a
205
+ cancelled refresh returns `kind: 'refused'` without deleting a newer session.
206
+ A deliberate new sign-in replaces the previous pending generation. Custom
207
+ stores must resolve their write promises only after the writes have landed;
208
+ a stalled store can also stall cleanup. This is local cancellation; issuer
209
+ requests already sent may still complete upstream.
210
+
211
+ Namespace clients keep their existing coordination: per-context session keys,
212
+ a shared service marker and pending transaction state across tabs. A late
213
+ response must still own that pending state. Forgetting a stale namespace
214
+ context preserves a different active context. Namespace mode continues to
215
+ reject custom stores and retain no refresh token.
151
216
 
152
217
  **Storage: `get` stays synchronous; `set` and `drop` may be async.**
153
218
  `currentSession()` and `silentSignInAvailable()` are synchronous methods on
@@ -250,3 +315,24 @@ against a real issuer.
250
315
  ## Licence
251
316
 
252
317
  Private to the wtfalch estate.
318
+
319
+ ## Checking HTTPS transaction removal
320
+
321
+ From the repository root, build the SDK and run the isolated browser fixture:
322
+
323
+ ```sh
324
+ pnpm --filter @wtfalch/auth build
325
+ pnpm --filter @wtfalch/auth test:cookie-browser
326
+ ```
327
+
328
+ This needs OpenSSL and an installed Chromium browser. The fixture uses Google
329
+ Chrome on macOS when available, otherwise Playwright's installed Chromium.
330
+ Set `AUTH_COOKIE_BROWSER_EXECUTABLE` to use another Chromium executable. It
331
+ creates a temporary self-signed certificate, listens only on loopback and
332
+ maps a test hostname to that server in the test browser. Certificate errors
333
+ are ignored only in the isolated browser context. Pages can contact only the
334
+ fixture origin; no issuer or deployed account is used. The adapter and Next
335
+ cookie serializer are real, while authentication results use dummy fixtures.
336
+ Legacy, namespace and ordinary-cookie cases check transaction removal and
337
+ session preservation. The browser, server and temporary certificate files
338
+ are cleaned up after the run. This browser check is separate from `pnpm check`.
package/dist/auth.d.ts CHANGED
@@ -63,6 +63,18 @@ export type Prompt = 'select_account' | 'login';
63
63
  export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
64
64
  export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
65
65
  export type SignUpError = 'registration_closed' | 'email_taken' | 'username_taken' | 'username_invalid' | 'invalid' | 'request' | 'unavailable';
66
+ /**
67
+ * The credential was right, but it is a one-time password: the person holds
68
+ * no session and no token, and nothing else is let through, until
69
+ * `setNewPassword` (or `setNewPasswordHeadless`) is called with `changeToken`.
70
+ * The token proves the issuer just verified that password. It is single use,
71
+ * bound to this app and good for a few minutes.
72
+ */
73
+ export interface PasswordChangeRequired {
74
+ ok: false;
75
+ error: 'password_change_required';
76
+ changeToken: string;
77
+ }
66
78
  export type SignInResult = {
67
79
  ok: true;
68
80
  redirectTo: string;
@@ -70,7 +82,7 @@ export type SignInResult = {
70
82
  } | {
71
83
  ok: false;
72
84
  error: SignInError;
73
- };
85
+ } | PasswordChangeRequired;
74
86
  export type SignUpResult = {
75
87
  ok: true;
76
88
  redirectTo: string;
@@ -79,6 +91,18 @@ export type SignUpResult = {
79
91
  ok: false;
80
92
  error: SignUpError;
81
93
  message: string;
94
+ } | (PasswordChangeRequired & {
95
+ message: string;
96
+ });
97
+ /** `invalid_token`: unknown, spent, expired or another app's. `invalid_password`: the issuer refused the new one; the token still works for a few more tries. */
98
+ export type SetNewPasswordError = 'invalid_token' | 'invalid_password' | 'unavailable';
99
+ export type SetNewPasswordResult = {
100
+ ok: true;
101
+ redirectTo: string;
102
+ hosted?: true;
103
+ } | {
104
+ ok: false;
105
+ error: SetNewPasswordError;
82
106
  };
83
107
  export type EmailChangeError = 'invalid' | 'email_taken' | 'too_many' | 'unavailable';
84
108
  export type EmailChangeResult = {
@@ -207,7 +231,9 @@ export interface Auth {
207
231
  sessionId: string;
208
232
  code: string;
209
233
  next?: string;
210
- }, requestHeaders?: Headers): Promise<SignedIn>;
234
+ }, requestHeaders?: Headers): Promise<SignedIn | (PasswordChangeRequired & {
235
+ message: string;
236
+ })>;
211
237
  /**
212
238
  * `signIn` as data, for a caller with no browser to carry through the OIDC
213
239
  * redirect -- a CLI, a desktop app, or a server signing in on a person's
@@ -222,7 +248,9 @@ export interface Auth {
222
248
  email: string;
223
249
  password: string;
224
250
  next?: string;
225
- }, requestHeaders?: Headers): Promise<SignedIn<SignInError>>;
251
+ }, requestHeaders?: Headers): Promise<SignedIn<SignInError> | (PasswordChangeRequired & {
252
+ message: string;
253
+ })>;
226
254
  /** Emails a fresh verification link. Says nothing about whether the address exists. */
227
255
  resendVerification(email: string, next?: string): Promise<void>;
228
256
  /** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
@@ -330,6 +358,23 @@ export interface Auth {
330
358
  email: string;
331
359
  password: string;
332
360
  }): Promise<SignInResult>;
361
+ /**
362
+ * After `signIn` answered `password_change_required`: sets the person's own
363
+ * password from that state, with no old password, then signs them in
364
+ * exactly as `signIn` does. Only a `changeToken` from a sign-in this app
365
+ * just saw succeed works.
366
+ */
367
+ setNewPassword(input: {
368
+ authRequestId: string;
369
+ changeToken: string;
370
+ newPassword: string;
371
+ }): Promise<SetNewPasswordResult>;
372
+ /** `setNewPassword` for a caller with no browser, as `signInWithPassword` is to `signIn`. */
373
+ setNewPasswordHeadless(input: {
374
+ changeToken: string;
375
+ newPassword: string;
376
+ next?: string;
377
+ }, requestHeaders?: Headers): Promise<SignedIn<SetNewPasswordError>>;
333
378
  signUp(input: {
334
379
  authRequestId: string;
335
380
  } & NewUser): Promise<SignUpResult>;
@@ -440,4 +485,14 @@ export interface Auth {
440
485
  accessToken(cookieValue: string): Promise<AccessTokenResult>;
441
486
  }
442
487
  export declare function createAuth(input: AuthOptions): Auth;
488
+ /**
489
+ * The freshness bound `startEmailChange`, `completeEmailChange`,
490
+ * `changePassword`, `startPasskeyRegistration`, `finishPasskeyRegistration`
491
+ * and `deletePasskey` enforce internally (`currentFreshUser`) -- the same
492
+ * five minutes docs/adopting.md's Step-up section demonstrates for
493
+ * `requireFreshUser`. Exported so a page gating one of these calls with its
494
+ * own `requireFreshUser` can pass the identical bound rather than a second
495
+ * number that might drift from this one.
496
+ */
497
+ export declare const STEP_UP_MAX_AGE: MaxAge;
443
498
  export type { AuthRequest, NewUser, SetCookie };
package/dist/auth.js CHANGED
@@ -2,7 +2,7 @@ import { errors } from 'jose';
2
2
  import * as client from 'openid-client';
3
3
  import { Broker, BrokerError } from './broker.js';
4
4
  import { resolveOptions, } from './config.js';
5
- import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
5
+ import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openSilent, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealSilent, sealTransaction, sessionCookieName, silentCookieName, transactionCookieName, } from './cookies.js';
6
6
  import { namespaceSessions } from './namespace-session.js';
7
7
  import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
8
8
  import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
@@ -122,12 +122,19 @@ export function createAuth(input) {
122
122
  return unavailable();
123
123
  const params = new URL(request.url).searchParams;
124
124
  const next = safeNextPath(params.get('next'), options().afterLogin);
125
+ // Only the gate can ask for a silent pass: its sealed marker is the sole way
126
+ // in, and the query's own `prompt` never reaches `none`. An explicit choice
127
+ // (register, a prompt, max_age) is interactive by definition.
128
+ const silent = (await openSilent(options(), cookieFrom(request.headers.get('cookie'), silentCookieName(options())))) === 'try' &&
129
+ !params.has('intent') &&
130
+ !params.has('prompt') &&
131
+ !params.has('max_age');
125
132
  const state = client.randomState();
126
133
  const nonce = client.randomNonce();
127
134
  const codeVerifier = client.randomPKCECodeVerifier();
128
135
  const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
129
136
  create: params.get('intent') === 'register',
130
- prompt: promptFrom(params.get('prompt')),
137
+ prompt: silent ? 'none' : promptFrom(params.get('prompt')),
131
138
  maxAge: maxAgeFrom(params.get('max_age')),
132
139
  });
133
140
  const transaction = await sealTransaction(options(), {
@@ -135,8 +142,13 @@ export function createAuth(input) {
135
142
  nc: nonce,
136
143
  cv: codeVerifier,
137
144
  nx: next,
145
+ ...(silent ? { sl: true } : {}),
138
146
  });
139
- return redirect(target.href, [transaction]);
147
+ // Whatever a silent pass returns, it is the gate's last: `done` sends the next sign-in to the interactive path.
148
+ return redirect(target.href, [
149
+ transaction,
150
+ ...(silent ? [await sealSilent(options(), 'done')] : []),
151
+ ]);
140
152
  };
141
153
  const complete = async (callbackUrl, cookieHeader, requestHeaders) => {
142
154
  if (!validCallback(callbackUrl))
@@ -150,6 +162,15 @@ export function createAuth(input) {
150
162
  return failure('state', options().namespace ? [] : [cleared]);
151
163
  if (options().namespace && !(await sessions.bindingActive()))
152
164
  return failure('unavailable', [cleared]);
165
+ // A silent pass the issuer refused (login_required, interaction_required,
166
+ // consent_required, or any other error) is not a failure: sign in the normal
167
+ // way, once. `done` is already set, so `/start` will not go silent again.
168
+ if (transaction.sl && params.has('error')) {
169
+ return {
170
+ location: startUrl(transaction.nx),
171
+ cookies: [...(options().namespace ? [] : [cleared]), await sealSilent(options(), 'done')],
172
+ };
173
+ }
153
174
  let session;
154
175
  try {
155
176
  const tokens = await oidc().exchange(params, {
@@ -221,13 +242,15 @@ export function createAuth(input) {
221
242
  */
222
243
  const endIssuerSession = async (idToken, cookies) => {
223
244
  const afterLogout = new URL(options().afterLogout, options().appUrl).href;
245
+ // A signed-out person is never silently signed back in: `done` keeps the gate interactive for the marker's life, even if the issuer's own logout fails.
246
+ const all = [...cookies, await sealSilent(options(), 'done')];
224
247
  if (!idToken) {
225
- return redirect(new URL('/logout', options().issuer).href, cookies, 303);
248
+ return redirect(new URL('/logout', options().issuer).href, all, 303);
226
249
  }
227
250
  const target = await oidc()
228
251
  .endSessionUrl(idToken, afterLogout)
229
252
  .catch(() => null);
230
- return redirect(target?.href ?? afterLogout, cookies, 303);
253
+ return redirect(target?.href ?? afterLogout, all, 303);
231
254
  };
232
255
  const logout = async (request) => {
233
256
  if (!isSameOrigin(request, options().appUrl.origin)) {
@@ -370,7 +393,15 @@ export function createAuth(input) {
370
393
  return { kind: 'deny', cookies, reason: 'unavailable' };
371
394
  if (!isNavigation(request))
372
395
  return { kind: 'deny', cookies };
373
- return { kind: 'redirect', location: startUrl(`${url.pathname}${url.search}`), cookies };
396
+ // First visit without a session: ask for one silent pass. The marker is
397
+ // sealed, so only the gate can set it, and any marker (even a spent one)
398
+ // keeps the next redirect interactive.
399
+ const tried = await openSilent(options(), cookieFrom(request.headers.get('cookie'), silentCookieName(options())));
400
+ return {
401
+ kind: 'redirect',
402
+ location: startUrl(`${url.pathname}${url.search}`),
403
+ cookies: tried ? cookies : [...cookies, await sealSilent(options(), 'try')],
404
+ };
374
405
  };
375
406
  /**
376
407
  * The person signed in on `request`, for the passkey routes -- never a
@@ -388,6 +419,29 @@ export function createAuth(input) {
388
419
  throw new AuthError('unauthenticated', 'sign in to use a passkey');
389
420
  return user;
390
421
  };
422
+ /**
423
+ * `currentUser`, plus the same freshness bound `requireFreshUser` enforces
424
+ * at the page level -- `STEP_UP_MAX_AGE` old at most. The six calls below
425
+ * that change a credential or an enrolled factor take this instead of
426
+ * `currentUser`, because a leaked-but-still-live session is enough to
427
+ * reach any of them directly (a Server Action, or any other route that
428
+ * never rendered a page gated by `requireFreshUser`) -- only the app's own
429
+ * page code stood between that session and the change, and nothing here
430
+ * required it to.
431
+ *
432
+ * `requireFreshUser`'s redirect-then-retry only makes sense with a browser
433
+ * navigation behind it, which a direct call has none of, so a stale
434
+ * session here is `AuthError('stale', ...)` outright rather than a
435
+ * redirect: the app shows something to click, exactly as it must already
436
+ * do for `unauthenticated`.
437
+ */
438
+ const currentFreshUser = async (request) => {
439
+ const user = await currentUser(request);
440
+ if (!fresh(user, STEP_UP_MAX_AGE)) {
441
+ throw new AuthError('stale', 'Confirm your identity again before this change');
442
+ }
443
+ return user;
444
+ };
391
445
  const people = (options) => broker().people(options);
392
446
  const setPersonActive = async (userId, active) => {
393
447
  const result = await broker().setPersonActive(userId, active);
@@ -508,6 +562,9 @@ export function createAuth(input) {
508
562
  return { ok: true, ...completed };
509
563
  }
510
564
  catch (error) {
565
+ const change = changeRequired(error);
566
+ if (change)
567
+ return { ...change, message: 'choose a new password to continue' };
511
568
  if (error instanceof BrokerError && error.status < 500) {
512
569
  return { ok: false, error: 'invalid_code', message: error.detail ?? error.error };
513
570
  }
@@ -525,7 +582,12 @@ export function createAuth(input) {
525
582
  code,
526
583
  next: params.get('next') ?? undefined,
527
584
  }, request.headers);
528
- return result.ok ? redirect(result.location, result.cookies) : failed('link', []);
585
+ if (result.ok)
586
+ return redirect(result.location, result.cookies);
587
+ // A redirect cannot carry the token (it would sit in a URL), so the
588
+ // person is sent to the error page to sign in with their password, which
589
+ // answers with the token as data.
590
+ return failed(result.error === 'password_change_required' ? result.error : 'link', []);
529
591
  };
530
592
  const signIn = async ({ authRequestId, email, password, }) => {
531
593
  try {
@@ -538,9 +600,40 @@ export function createAuth(input) {
538
600
  emit({ type: 'signIn', email });
539
601
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
540
602
  }
603
+ const change = changeRequired(error);
604
+ if (change)
605
+ return change;
541
606
  return { ok: false, error: signInError(error) };
542
607
  }
543
608
  };
609
+ const setNewPassword = async ({ authRequestId, changeToken, newPassword, }) => {
610
+ try {
611
+ const redirectTo = await broker().setNewPassword({ authRequestId, changeToken, newPassword });
612
+ return { ok: true, redirectTo };
613
+ }
614
+ catch (error) {
615
+ if (needsHostedLogin(error))
616
+ return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
617
+ return { ok: false, error: setNewPasswordError(error) };
618
+ }
619
+ };
620
+ const setNewPasswordHeadless = async ({ changeToken, newPassword, next }, requestHeaders) => {
621
+ try {
622
+ const flow = await serverFlow();
623
+ const completed = await finishOrHandoff(() => broker().setNewPassword({ authRequestId: flow.id, changeToken, newPassword }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
624
+ return { ok: true, ...completed };
625
+ }
626
+ catch (error) {
627
+ const code = setNewPasswordError(error);
628
+ return {
629
+ ok: false,
630
+ error: code,
631
+ message: code !== 'unavailable' && error instanceof BrokerError
632
+ ? (error.detail ?? error.error)
633
+ : 'the sign-in service did not answer',
634
+ };
635
+ }
636
+ };
544
637
  const signInWithPassword = async ({ email, password, next }, requestHeaders) => {
545
638
  try {
546
639
  const flow = await serverFlow();
@@ -549,6 +642,9 @@ export function createAuth(input) {
549
642
  return { ok: true, ...completed };
550
643
  }
551
644
  catch (error) {
645
+ const change = changeRequired(error);
646
+ if (change)
647
+ return { ...change, message: 'choose a new password to continue' };
552
648
  const code = signInError(error);
553
649
  // `signInError` collapses everything the broker can say into one of
554
650
  // four codes; only a real `BrokerError` carries a sentence worth
@@ -571,6 +667,9 @@ export function createAuth(input) {
571
667
  emit({ type: 'signUp', email: user.email });
572
668
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
573
669
  }
670
+ const change = changeRequired(error);
671
+ if (change)
672
+ return { ...change, message: 'choose a new password to continue' };
574
673
  if (error instanceof BrokerError && error.error === 'email_taken') {
575
674
  return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
576
675
  }
@@ -594,11 +693,11 @@ export function createAuth(input) {
594
693
  }
595
694
  };
596
695
  const startPasskeyRegistration = async (request) => {
597
- const user = await currentUser(request);
696
+ const user = await currentFreshUser(request);
598
697
  return broker().startPasskeyRegistration(user.id);
599
698
  };
600
699
  const finishPasskeyRegistration = async (request, input) => {
601
- const user = await currentUser(request);
700
+ const user = await currentFreshUser(request);
602
701
  // Built from named fields, never spread: `input` crosses a trust
603
702
  // boundary (a Next.js Server Action does not enforce its TS parameter
604
703
  // types on the wire), and a spread after `userId: user.id` would let a
@@ -626,11 +725,11 @@ export function createAuth(input) {
626
725
  }
627
726
  };
628
727
  const startEmailChange = async (request, email) => {
629
- const user = await currentUser(request);
728
+ const user = await currentFreshUser(request);
630
729
  await broker().startEmailChange(user.id, email);
631
730
  };
632
731
  const completeEmailChange = async (request, code) => {
633
- const user = await currentUser(request);
732
+ const user = await currentFreshUser(request);
634
733
  try {
635
734
  await broker().completeEmailChange(user.id, code);
636
735
  return { ok: true };
@@ -649,7 +748,7 @@ export function createAuth(input) {
649
748
  }
650
749
  };
651
750
  const changePassword = async (request, input) => {
652
- const user = await currentUser(request);
751
+ const user = await currentFreshUser(request);
653
752
  try {
654
753
  await broker().changePassword({ userId: user.id, ...input });
655
754
  return { ok: true };
@@ -670,7 +769,7 @@ export function createAuth(input) {
670
769
  return result.passkeys;
671
770
  };
672
771
  const deletePasskey = async (request, id) => {
673
- const user = await currentUser(request);
772
+ const user = await currentFreshUser(request);
674
773
  try {
675
774
  await broker().deletePasskey(user.id, id);
676
775
  return { ok: true };
@@ -700,7 +799,7 @@ export function createAuth(input) {
700
799
  catch (error) {
701
800
  if (needsHostedLogin(error))
702
801
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
703
- return { ok: false, error: signInError(error) };
802
+ return changeRequired(error) ?? { ok: false, error: signInError(error) };
704
803
  }
705
804
  };
706
805
  const startQrSignIn = async () => {
@@ -833,6 +932,8 @@ export function createAuth(input) {
833
932
  endSession,
834
933
  authRequest,
835
934
  signIn,
935
+ setNewPassword,
936
+ setNewPasswordHeadless,
836
937
  signInWithPassword,
837
938
  signUp,
838
939
  startPasskeyRegistration,
@@ -908,6 +1009,16 @@ const MAX_AGE_CEILING = 31_536_000;
908
1009
  * forever. The tolerance is why the round trip can ever satisfy the bound.
909
1010
  */
910
1011
  const CLOCK_TOLERANCE = 60;
1012
+ /**
1013
+ * The freshness bound `startEmailChange`, `completeEmailChange`,
1014
+ * `changePassword`, `startPasskeyRegistration`, `finishPasskeyRegistration`
1015
+ * and `deletePasskey` enforce internally (`currentFreshUser`) -- the same
1016
+ * five minutes docs/adopting.md's Step-up section demonstrates for
1017
+ * `requireFreshUser`. Exported so a page gating one of these calls with its
1018
+ * own `requireFreshUser` can pass the identical bound rather than a second
1019
+ * number that might drift from this one.
1020
+ */
1021
+ export const STEP_UP_MAX_AGE = 300;
911
1022
  function maxAgeFrom(raw) {
912
1023
  if (raw === null || raw.trim() === '')
913
1024
  return undefined;
@@ -975,6 +1086,23 @@ async function quietly(act) {
975
1086
  throw error;
976
1087
  }
977
1088
  }
1089
+ /** The broker's refusal for a verified one-time password, with the proof of that to send back. */
1090
+ function changeRequired(error) {
1091
+ return error instanceof BrokerError &&
1092
+ error.error === 'password_change_required' &&
1093
+ error.changeToken
1094
+ ? { ok: false, error: 'password_change_required', changeToken: error.changeToken }
1095
+ : null;
1096
+ }
1097
+ function setNewPasswordError(error) {
1098
+ if (error instanceof BrokerError && error.error === 'invalid_token')
1099
+ return 'invalid_token';
1100
+ if (error instanceof BrokerError && error.error === 'invalid_password')
1101
+ return 'invalid_password';
1102
+ if (error instanceof BrokerError && error.status === 401)
1103
+ return 'invalid_token';
1104
+ return 'unavailable';
1105
+ }
978
1106
  function signInError(error) {
979
1107
  if (error instanceof BrokerError && error.error === 'request')
980
1108
  return 'request';
package/dist/broker.d.ts CHANGED
@@ -15,6 +15,12 @@ export interface NewUser {
15
15
  * and words it will not have. Without it the email is the login name.
16
16
  */
17
17
  username?: string;
18
+ /**
19
+ * Creates the account with `password` as a one-time password: the person
20
+ * is signed in on nothing until they choose their own (`signUp` answers
21
+ * `password_change_required`, then `setNewPassword`).
22
+ */
23
+ changeRequired?: boolean;
18
24
  }
19
25
  /** One of the organisation's people, as the sign-in service reports them. */
20
26
  export interface Person {
@@ -35,7 +41,11 @@ export declare class BrokerError extends Error {
35
41
  readonly status: number;
36
42
  readonly error: string;
37
43
  readonly detail?: string | undefined;
38
- constructor(status: number, error: string, detail?: string | undefined);
44
+ /** Only `password_change_required` carries one: see `setNewPassword`. */
45
+ readonly changeToken?: string | undefined;
46
+ constructor(status: number, error: string, detail?: string | undefined,
47
+ /** Only `password_change_required` carries one: see `setNewPassword`. */
48
+ changeToken?: string | undefined);
39
49
  }
40
50
  /**
41
51
  * The privileged half, over HTTP. The app holds a key that reaches its own
@@ -60,6 +70,12 @@ export declare class Broker {
60
70
  email: string;
61
71
  password: string;
62
72
  }): Promise<string>;
73
+ /** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
74
+ setNewPassword(input: {
75
+ authRequestId: string;
76
+ changeToken: string;
77
+ newPassword: string;
78
+ }): Promise<string>;
63
79
  signUp(input: {
64
80
  authRequestId: string;
65
81
  } & NewUser): Promise<string>;
package/dist/broker.js CHANGED
@@ -2,11 +2,15 @@ export class BrokerError extends Error {
2
2
  status;
3
3
  error;
4
4
  detail;
5
- constructor(status, error, detail) {
5
+ changeToken;
6
+ constructor(status, error, detail,
7
+ /** Only `password_change_required` carries one: see `setNewPassword`. */
8
+ changeToken) {
6
9
  super(detail ?? error);
7
10
  this.status = status;
8
11
  this.error = error;
9
12
  this.detail = detail;
13
+ this.changeToken = changeToken;
10
14
  this.name = 'BrokerError';
11
15
  }
12
16
  }
@@ -48,7 +52,7 @@ export class Broker {
48
52
  throw new BrokerError(response.status, 'unavailable', `non-JSON response from ${path}`);
49
53
  }
50
54
  if (!response.ok) {
51
- throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message);
55
+ throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message, typeof data.changeToken === 'string' ? data.changeToken : undefined);
52
56
  }
53
57
  return data;
54
58
  }
@@ -70,6 +74,11 @@ export class Broker {
70
74
  const { callbackUrl } = await this.call('POST', '/sign-in', input);
71
75
  return callbackUrl;
72
76
  }
77
+ /** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
78
+ async setNewPassword(input) {
79
+ const { callbackUrl } = await this.call('POST', '/password/set', input);
80
+ return callbackUrl;
81
+ }
73
82
  async signUp(input) {
74
83
  const { callbackUrl } = await this.call('POST', '/sign-up', {
75
84
  ...input,