@wtfalch/auth 0.16.0 → 0.20.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
@@ -60,6 +94,15 @@ Auth-request lookup failures lead to the configured error page with
60
94
  `auth_error=request` or `auth_error=unavailable`. That page should show a
61
95
  retry link; it must not automatically restart authorization.
62
96
 
97
+ When a sign-in fails after it started (reasons such as `state`, `unavailable`,
98
+ `exchange`, `denied`, `organization`, `cookie`), the error URL is
99
+ `<onError>?auth_error=<reason>&next=<path>`. `next` is the page the person came
100
+ for, always a path starting with one `/`, URL-encoded as a single parameter, and
101
+ present only when the sealed value is a safe same-origin path. An `onError` that
102
+ already has a query string keeps it. `expired` and `request` carry no `next`:
103
+ there is no transaction to read it from. Use it for the retry link
104
+ (`/auth/start?next=<path>`); a page that ignores it behaves as before.
105
+
63
106
  ## In a browser app
64
107
 
65
108
  `@wtfalch/auth/browser` is the other half: a public PKCE client for an app that
@@ -70,7 +113,7 @@ across the estate's subdomains**.
70
113
  import { createBrowserAuth } from '@wtfalch/auth/browser';
71
114
 
72
115
  const auth = createBrowserAuth({
73
- issuer: 'https://auth.wtfalch.dev',
116
+ issuer: 'https://auth.example.com',
74
117
  clientId: '…', // this app's client id at the issuer
75
118
  callbackPath: '/signed-in', // when `/auth/callback` is not yours to host
76
119
  });
@@ -108,7 +151,7 @@ behaviour above exactly, so a caller that sets none of them sees no change.
108
151
 
109
152
  ```ts
110
153
  const auth = createBrowserAuth({
111
- issuer: 'https://auth.wtfalch.dev',
154
+ issuer: 'https://auth.example.com',
112
155
  clientId: '…',
113
156
  open: (url) => Browser.open({ url }), // Capacitor's system-browser opener
114
157
  store: capacitorSecureStore, // see "Storage stays synchronous" below
@@ -122,9 +165,11 @@ const auth = createBrowserAuth({
122
165
  - **`store`** replaces the `sessionStorage` wrapper that holds the token,
123
166
  verifier, return path and silent-tried mark, with a plain
124
167
  `{ 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.
168
+ return a promise (see "Storage: `get` stays synchronous" below). Custom
169
+ stores retain the existing `wtfalch.auth.*` keys. Supply separate
170
+ backing storage per client context; different wrappers around the same
171
+ backing keys share values. Reuse the same store object for instances that
172
+ should coordinate pending operations.
128
173
  - **`refresh()`**, a new method on the returned client, posts the
129
174
  `refresh_token` grant and stores the session it gets back — awaiting the
130
175
  store's write before it resolves, so an app that awaits `refresh()` knows
@@ -147,7 +192,36 @@ const auth = createBrowserAuth({
147
192
  sign-in, so this only matters there.
148
193
  - **`forget()`** is now `Promise<void>` rather than `void`, the one signature
149
194
  change here: it awaits the store's writes, the same as `completeSignIn()`
150
- and `refresh()`.
195
+ and `refresh()`. It also cancels pending authorization, completion and
196
+ refresh operations in the same store/context before cleanup starts.
197
+
198
+ **Default storage and clients on one origin.** Legacy browser clients now use
199
+ versioned `sessionStorage` keys bound to issuer, client ID, origin, callback URI
200
+ and the requested scope set (including extra scopes). Matching contexts share
201
+ sessions, pending proof/return path and the silent-attempt mark; different
202
+ contexts keep each of those separate. Scope order and repeated scopes do not
203
+ create a new context. Old unbound `wtfalch.auth.*` default values are ignored,
204
+ with no automatic migration: users sign in once after upgrading. Custom stores
205
+ keep their established keys and need separate backing storage per context.
206
+
207
+ Instances using the same default context or the same custom store object share
208
+ write coordination and refresh requests within this loaded SDK. `forget()`
209
+ invalidates pending generations immediately, removes the token and pending
210
+ proof/return path, and sets the silent-attempt mark. Network responses are
211
+ checked before committing. If an async store write has already started,
212
+ `forget()` waits for it and then removes its result; await `forget()` before
213
+ reading the final state. A cancelled completion returns `kind: 'error'`; a
214
+ cancelled refresh returns `kind: 'refused'` without deleting a newer session.
215
+ A deliberate new sign-in replaces the previous pending generation. Custom
216
+ stores must resolve their write promises only after the writes have landed;
217
+ a stalled store can also stall cleanup. This is local cancellation; issuer
218
+ requests already sent may still complete upstream.
219
+
220
+ Namespace clients keep their existing coordination: per-context session keys,
221
+ a shared service marker and pending transaction state across tabs. A late
222
+ response must still own that pending state. Forgetting a stale namespace
223
+ context preserves a different active context. Namespace mode continues to
224
+ reject custom stores and retain no refresh token.
151
225
 
152
226
  **Storage: `get` stays synchronous; `set` and `drop` may be async.**
153
227
  `currentSession()` and `silentSignInAvailable()` are synchronous methods on
@@ -250,3 +324,24 @@ against a real issuer.
250
324
  ## Licence
251
325
 
252
326
  Private to the wtfalch estate.
327
+
328
+ ## Checking HTTPS transaction removal
329
+
330
+ From the repository root, build the SDK and run the isolated browser fixture:
331
+
332
+ ```sh
333
+ pnpm --filter @wtfalch/auth build
334
+ pnpm --filter @wtfalch/auth test:cookie-browser
335
+ ```
336
+
337
+ This needs OpenSSL and an installed Chromium browser. The fixture uses Google
338
+ Chrome on macOS when available, otherwise Playwright's installed Chromium.
339
+ Set `AUTH_COOKIE_BROWSER_EXECUTABLE` to use another Chromium executable. It
340
+ creates a temporary self-signed certificate, listens only on loopback and
341
+ maps a test hostname to that server in the test browser. Certificate errors
342
+ are ignored only in the isolated browser context. Pages can contact only the
343
+ fixture origin; no issuer or deployed account is used. The adapter and Next
344
+ cookie serializer are real, while authentication results use dummy fixtures.
345
+ Legacy, namespace and ordinary-cookie cases check transaction removal and
346
+ session preservation. The browser, server and temporary certificate files
347
+ 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. */
@@ -250,6 +278,7 @@ export interface Auth {
250
278
  }): Promise<{
251
279
  userId: string;
252
280
  invited: boolean;
281
+ personalEmailStored: boolean;
253
282
  }>;
254
283
  handle(request: Request): Promise<Response>;
255
284
  /**
@@ -303,6 +332,17 @@ export interface Auth {
303
332
  userId: string;
304
333
  state: string;
305
334
  }>;
335
+ /**
336
+ * An admin's reset for somebody in this app's own organisation whom
337
+ * `requestPasswordReset` cannot otherwise get back in -- returns a fresh
338
+ * one-time password the caller is trusted to deliver some other way,
339
+ * rather than mailing it. Refused for a user outside this app's
340
+ * organisation.
341
+ */
342
+ adminResetPassword(userId: string): Promise<{
343
+ userId: string;
344
+ password: string;
345
+ }>;
306
346
  /**
307
347
  * Namespace mode: end every session this person has in this service, on
308
348
  * every replica, at their next request. Deactivating someone does this too.
@@ -330,6 +370,23 @@ export interface Auth {
330
370
  email: string;
331
371
  password: string;
332
372
  }): Promise<SignInResult>;
373
+ /**
374
+ * After `signIn` answered `password_change_required`: sets the person's own
375
+ * password from that state, with no old password, then signs them in
376
+ * exactly as `signIn` does. Only a `changeToken` from a sign-in this app
377
+ * just saw succeed works.
378
+ */
379
+ setNewPassword(input: {
380
+ authRequestId: string;
381
+ changeToken: string;
382
+ newPassword: string;
383
+ }): Promise<SetNewPasswordResult>;
384
+ /** `setNewPassword` for a caller with no browser, as `signInWithPassword` is to `signIn`. */
385
+ setNewPasswordHeadless(input: {
386
+ changeToken: string;
387
+ newPassword: string;
388
+ next?: string;
389
+ }, requestHeaders?: Headers): Promise<SignedIn<SetNewPasswordError>>;
333
390
  signUp(input: {
334
391
  authRequestId: string;
335
392
  } & NewUser): Promise<SignUpResult>;
@@ -440,4 +497,14 @@ export interface Auth {
440
497
  accessToken(cookieValue: string): Promise<AccessTokenResult>;
441
498
  }
442
499
  export declare function createAuth(input: AuthOptions): Auth;
500
+ /**
501
+ * The freshness bound `startEmailChange`, `completeEmailChange`,
502
+ * `changePassword`, `startPasskeyRegistration`, `finishPasskeyRegistration`
503
+ * and `deletePasskey` enforce internally (`currentFreshUser`) -- the same
504
+ * five minutes docs/adopting.md's Step-up section demonstrates for
505
+ * `requireFreshUser`. Exported so a page gating one of these calls with its
506
+ * own `requireFreshUser` can pass the identical bound rather than a second
507
+ * number that might drift from this one.
508
+ */
509
+ export declare const STEP_UP_MAX_AGE: MaxAge;
443
510
  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))
@@ -147,9 +159,18 @@ export function createAuth(input) {
147
159
  return failure('expired', options().namespace ? [] : [cleared]);
148
160
  const params = new URL(callbackUrl).searchParams;
149
161
  if (params.get('state') !== transaction.st)
150
- return failure('state', options().namespace ? [] : [cleared]);
162
+ return failure('state', options().namespace ? [] : [cleared], transaction.nx);
151
163
  if (options().namespace && !(await sessions.bindingActive()))
152
- return failure('unavailable', [cleared]);
164
+ return failure('unavailable', [cleared], transaction.nx);
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, {
@@ -160,7 +181,7 @@ export function createAuth(input) {
160
181
  session = await sessionCookie(tokens, requestHeaders);
161
182
  }
162
183
  catch (error) {
163
- return failure(reasonOf(error), [cleared]);
184
+ return failure(reasonOf(error), [cleared], transaction.nx);
164
185
  }
165
186
  await replacePreviousSession(cookieHeader);
166
187
  return {
@@ -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);
@@ -485,6 +539,20 @@ export function createAuth(input) {
485
539
  emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
486
540
  return result;
487
541
  };
542
+ /**
543
+ * An admin's reset for somebody in this app's own organisation whom
544
+ * `requestPasswordReset` cannot otherwise get back in -- nobody at the
545
+ * mailbox a personal address or the login address itself would reach.
546
+ * Refused for a user outside this app's organisation, exactly as
547
+ * `setPersonActive` is. The password is a one-time one: the person must
548
+ * choose their own at next sign-in, same as an invite's. Audited with the
549
+ * target's id and never the password itself.
550
+ */
551
+ const adminResetPassword = async (userId) => {
552
+ const result = await broker().adminResetPassword(userId);
553
+ emit({ type: 'adminResetPassword', userId: result.userId });
554
+ return result;
555
+ };
488
556
  const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
489
557
  try {
490
558
  const flow = await serverFlow();
@@ -508,6 +576,9 @@ export function createAuth(input) {
508
576
  return { ok: true, ...completed };
509
577
  }
510
578
  catch (error) {
579
+ const change = changeRequired(error);
580
+ if (change)
581
+ return { ...change, message: 'choose a new password to continue' };
511
582
  if (error instanceof BrokerError && error.status < 500) {
512
583
  return { ok: false, error: 'invalid_code', message: error.detail ?? error.error };
513
584
  }
@@ -525,7 +596,12 @@ export function createAuth(input) {
525
596
  code,
526
597
  next: params.get('next') ?? undefined,
527
598
  }, request.headers);
528
- return result.ok ? redirect(result.location, result.cookies) : failed('link', []);
599
+ if (result.ok)
600
+ return redirect(result.location, result.cookies);
601
+ // A redirect cannot carry the token (it would sit in a URL), so the
602
+ // person is sent to the error page to sign in with their password, which
603
+ // answers with the token as data.
604
+ return failed(result.error === 'password_change_required' ? result.error : 'link', []);
529
605
  };
530
606
  const signIn = async ({ authRequestId, email, password, }) => {
531
607
  try {
@@ -538,9 +614,40 @@ export function createAuth(input) {
538
614
  emit({ type: 'signIn', email });
539
615
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
540
616
  }
617
+ const change = changeRequired(error);
618
+ if (change)
619
+ return change;
541
620
  return { ok: false, error: signInError(error) };
542
621
  }
543
622
  };
623
+ const setNewPassword = async ({ authRequestId, changeToken, newPassword, }) => {
624
+ try {
625
+ const redirectTo = await broker().setNewPassword({ authRequestId, changeToken, newPassword });
626
+ return { ok: true, redirectTo };
627
+ }
628
+ catch (error) {
629
+ if (needsHostedLogin(error))
630
+ return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
631
+ return { ok: false, error: setNewPasswordError(error) };
632
+ }
633
+ };
634
+ const setNewPasswordHeadless = async ({ changeToken, newPassword, next }, requestHeaders) => {
635
+ try {
636
+ const flow = await serverFlow();
637
+ const completed = await finishOrHandoff(() => broker().setNewPassword({ authRequestId: flow.id, changeToken, newPassword }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
638
+ return { ok: true, ...completed };
639
+ }
640
+ catch (error) {
641
+ const code = setNewPasswordError(error);
642
+ return {
643
+ ok: false,
644
+ error: code,
645
+ message: code !== 'unavailable' && error instanceof BrokerError
646
+ ? (error.detail ?? error.error)
647
+ : 'the sign-in service did not answer',
648
+ };
649
+ }
650
+ };
544
651
  const signInWithPassword = async ({ email, password, next }, requestHeaders) => {
545
652
  try {
546
653
  const flow = await serverFlow();
@@ -549,6 +656,9 @@ export function createAuth(input) {
549
656
  return { ok: true, ...completed };
550
657
  }
551
658
  catch (error) {
659
+ const change = changeRequired(error);
660
+ if (change)
661
+ return { ...change, message: 'choose a new password to continue' };
552
662
  const code = signInError(error);
553
663
  // `signInError` collapses everything the broker can say into one of
554
664
  // four codes; only a real `BrokerError` carries a sentence worth
@@ -571,6 +681,9 @@ export function createAuth(input) {
571
681
  emit({ type: 'signUp', email: user.email });
572
682
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
573
683
  }
684
+ const change = changeRequired(error);
685
+ if (change)
686
+ return { ...change, message: 'choose a new password to continue' };
574
687
  if (error instanceof BrokerError && error.error === 'email_taken') {
575
688
  return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
576
689
  }
@@ -594,11 +707,11 @@ export function createAuth(input) {
594
707
  }
595
708
  };
596
709
  const startPasskeyRegistration = async (request) => {
597
- const user = await currentUser(request);
710
+ const user = await currentFreshUser(request);
598
711
  return broker().startPasskeyRegistration(user.id);
599
712
  };
600
713
  const finishPasskeyRegistration = async (request, input) => {
601
- const user = await currentUser(request);
714
+ const user = await currentFreshUser(request);
602
715
  // Built from named fields, never spread: `input` crosses a trust
603
716
  // boundary (a Next.js Server Action does not enforce its TS parameter
604
717
  // types on the wire), and a spread after `userId: user.id` would let a
@@ -626,11 +739,11 @@ export function createAuth(input) {
626
739
  }
627
740
  };
628
741
  const startEmailChange = async (request, email) => {
629
- const user = await currentUser(request);
742
+ const user = await currentFreshUser(request);
630
743
  await broker().startEmailChange(user.id, email);
631
744
  };
632
745
  const completeEmailChange = async (request, code) => {
633
- const user = await currentUser(request);
746
+ const user = await currentFreshUser(request);
634
747
  try {
635
748
  await broker().completeEmailChange(user.id, code);
636
749
  return { ok: true };
@@ -649,7 +762,7 @@ export function createAuth(input) {
649
762
  }
650
763
  };
651
764
  const changePassword = async (request, input) => {
652
- const user = await currentUser(request);
765
+ const user = await currentFreshUser(request);
653
766
  try {
654
767
  await broker().changePassword({ userId: user.id, ...input });
655
768
  return { ok: true };
@@ -670,7 +783,7 @@ export function createAuth(input) {
670
783
  return result.passkeys;
671
784
  };
672
785
  const deletePasskey = async (request, id) => {
673
- const user = await currentUser(request);
786
+ const user = await currentFreshUser(request);
674
787
  try {
675
788
  await broker().deletePasskey(user.id, id);
676
789
  return { ok: true };
@@ -700,7 +813,7 @@ export function createAuth(input) {
700
813
  catch (error) {
701
814
  if (needsHostedLogin(error))
702
815
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
703
- return { ok: false, error: signInError(error) };
816
+ return changeRequired(error) ?? { ok: false, error: signInError(error) };
704
817
  }
705
818
  };
706
819
  const startQrSignIn = async () => {
@@ -792,9 +905,13 @@ export function createAuth(input) {
792
905
  cookies,
793
906
  };
794
907
  };
795
- const failure = (reason, cookies) => {
908
+ /** `next`, when it is a safe same-origin path, rides on the error URL so the error page can offer a retry to it. */
909
+ const failure = (reason, cookies, next) => {
796
910
  const url = new URL(options().onError, options().appUrl);
797
911
  url.searchParams.set('auth_error', reason);
912
+ const retry = safeNextPath(next, '');
913
+ if (retry)
914
+ url.searchParams.set('next', retry);
798
915
  return { location: url.href, cookies };
799
916
  };
800
917
  /** `failure`'s location on its own, for a caller that redirects rather than answering. */
@@ -828,11 +945,14 @@ export function createAuth(input) {
828
945
  errorUrl,
829
946
  people,
830
947
  setPersonActive,
948
+ adminResetPassword,
831
949
  revokeSessions,
832
950
  sessions: listSessions,
833
951
  endSession,
834
952
  authRequest,
835
953
  signIn,
954
+ setNewPassword,
955
+ setNewPasswordHeadless,
836
956
  signInWithPassword,
837
957
  signUp,
838
958
  startPasskeyRegistration,
@@ -908,6 +1028,16 @@ const MAX_AGE_CEILING = 31_536_000;
908
1028
  * forever. The tolerance is why the round trip can ever satisfy the bound.
909
1029
  */
910
1030
  const CLOCK_TOLERANCE = 60;
1031
+ /**
1032
+ * The freshness bound `startEmailChange`, `completeEmailChange`,
1033
+ * `changePassword`, `startPasskeyRegistration`, `finishPasskeyRegistration`
1034
+ * and `deletePasskey` enforce internally (`currentFreshUser`) -- the same
1035
+ * five minutes docs/adopting.md's Step-up section demonstrates for
1036
+ * `requireFreshUser`. Exported so a page gating one of these calls with its
1037
+ * own `requireFreshUser` can pass the identical bound rather than a second
1038
+ * number that might drift from this one.
1039
+ */
1040
+ export const STEP_UP_MAX_AGE = 300;
911
1041
  function maxAgeFrom(raw) {
912
1042
  if (raw === null || raw.trim() === '')
913
1043
  return undefined;
@@ -975,6 +1105,23 @@ async function quietly(act) {
975
1105
  throw error;
976
1106
  }
977
1107
  }
1108
+ /** The broker's refusal for a verified one-time password, with the proof of that to send back. */
1109
+ function changeRequired(error) {
1110
+ return error instanceof BrokerError &&
1111
+ error.error === 'password_change_required' &&
1112
+ error.changeToken
1113
+ ? { ok: false, error: 'password_change_required', changeToken: error.changeToken }
1114
+ : null;
1115
+ }
1116
+ function setNewPasswordError(error) {
1117
+ if (error instanceof BrokerError && error.error === 'invalid_token')
1118
+ return 'invalid_token';
1119
+ if (error instanceof BrokerError && error.error === 'invalid_password')
1120
+ return 'invalid_password';
1121
+ if (error instanceof BrokerError && error.status === 401)
1122
+ return 'invalid_token';
1123
+ return 'unavailable';
1124
+ }
978
1125
  function signInError(error) {
979
1126
  if (error instanceof BrokerError && error.error === 'request')
980
1127
  return 'request';