@wtfalch/auth 0.9.1 → 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/auth.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type JWTPayload } from 'jose';
2
2
  import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
3
3
  import { type AuthOptions } from './config.js';
4
4
  import { type SetCookie } from './cookies.js';
5
+ import type { SessionRecord } from './sessions.js';
5
6
  export interface User {
6
7
  /** Present only for validated namespace sessions. Membership is app-owned. */
7
8
  namespaceId?: string;
@@ -14,6 +15,16 @@ export interface User {
14
15
  familyName: string | null;
15
16
  preferredUsername: string | null;
16
17
  organizationId: string;
18
+ /**
19
+ * When the person last proved who they were to the issuer, in seconds since
20
+ * the epoch, from the id token's `auth_time`. Null when the issuer did not
21
+ * send it, which OIDC allows unless the request carried `max_age` -- so a
22
+ * null is not evidence of freshness and `fresh()` treats it as stale.
23
+ *
24
+ * A refresh does not move it. It is when they authenticated, not when the
25
+ * token was minted.
26
+ */
27
+ authTime: number | null;
17
28
  claims: JWTPayload;
18
29
  }
19
30
  export interface ReadResult {
@@ -45,6 +56,10 @@ export type Gate = {
45
56
  reason?: 'account_changed' | 'unavailable';
46
57
  };
47
58
  export type Intent = 'login' | 'register';
59
+ /** Seconds. `0` makes the issuer re-authenticate whatever it did a moment ago. */
60
+ export type MaxAge = number;
61
+ /** Allowlisted so a caller cannot smuggle an arbitrary OIDC prompt value through. */
62
+ export type Prompt = 'select_account' | 'login';
48
63
  export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
49
64
  export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
50
65
  export type SignUpError = 'registration_closed' | 'email_taken' | 'username_taken' | 'username_invalid' | 'invalid' | 'request' | 'unavailable';
@@ -65,6 +80,33 @@ export type SignUpResult = {
65
80
  error: SignUpError;
66
81
  message: string;
67
82
  };
83
+ export type EmailChangeError = 'invalid' | 'email_taken' | 'too_many' | 'unavailable';
84
+ export type EmailChangeResult = {
85
+ ok: true;
86
+ } | {
87
+ ok: false;
88
+ error: EmailChangeError;
89
+ };
90
+ export type ChangePasswordError = 'invalid_current_password' | 'invalid_password' | 'unavailable';
91
+ export type ChangePasswordResult = {
92
+ ok: true;
93
+ } | {
94
+ ok: false;
95
+ error: ChangePasswordError;
96
+ };
97
+ export type DeletePasskeyResult = {
98
+ ok: true;
99
+ } | {
100
+ ok: false;
101
+ error: 'invalid';
102
+ };
103
+ /** One passkey as the issuer reports it -- `ready` or still mid-registration. */
104
+ export interface Passkey {
105
+ readonly id: string;
106
+ readonly name: string;
107
+ /** `ready`, `not_ready` for a registration nobody finished, or `unknown` for a state a newer issuer added. Only `ready` signs anyone in. */
108
+ readonly state: string;
109
+ }
68
110
  export interface PasskeyRegistrationStart {
69
111
  passkeyId: string;
70
112
  /** ZITADEL's `publicKeyCredentialCreationOptions`, opaque here -- `@wtfalch/auth/passkey`'s `createPasskey` knows its shape. */
@@ -127,8 +169,13 @@ export interface Auth {
127
169
  start(request: Request): Promise<Response>;
128
170
  /** GET {basePath}/callback */
129
171
  callback(request: Request): Promise<Response>;
130
- /** The callback as data, for a server action that finishes the sign-in itself and sets the cookies. */
131
- complete(callbackUrl: string, cookieHeader: string | null): Promise<Completed>;
172
+ /**
173
+ * The callback as data, for a server action that finishes the sign-in
174
+ * itself and sets the cookies. `requestHeaders`, when given, is the
175
+ * signing-in request's own headers -- namespace mode keeps its user agent
176
+ * and address on the new session record.
177
+ */
178
+ complete(callbackUrl: string, cookieHeader: string | null, requestHeaders?: Headers): Promise<Completed>;
132
179
  /** POST {basePath}/logout */
133
180
  logout(request: Request): Promise<Response>;
134
181
  /** GET {basePath}/verify?userId=&code= */
@@ -154,7 +201,7 @@ export interface Auth {
154
201
  sessionId: string;
155
202
  code: string;
156
203
  next?: string;
157
- }): Promise<SignedIn>;
204
+ }, requestHeaders?: Headers): Promise<SignedIn>;
158
205
  /** Emails a fresh verification link. Says nothing about whether the address exists. */
159
206
  resendVerification(email: string, next?: string): Promise<void>;
160
207
  /** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
@@ -165,7 +212,7 @@ export interface Auth {
165
212
  code: string;
166
213
  password: string;
167
214
  next?: string;
168
- }): Promise<SignedIn>;
215
+ }, requestHeaders?: Headers): Promise<SignedIn>;
169
216
  /** Emails a sign-in link. Needs a verified address. Says nothing about whether the address exists. */
170
217
  sendLink(email: string, next?: string): Promise<void>;
171
218
  /**
@@ -195,7 +242,19 @@ export interface Auth {
195
242
  refresh?: boolean;
196
243
  }): Promise<ReadResult>;
197
244
  gate(request: Request, rules?: GateRules): Promise<Gate>;
198
- startUrl(next?: string | null, intent?: Intent): string;
245
+ /**
246
+ * `prompt` is allowlisted server-side too -- an unrecognised value is dropped, not forwarded.
247
+ * `maxAge` becomes OIDC `max_age`: the issuer re-authenticates anybody whose
248
+ * last authentication is older than that many seconds.
249
+ */
250
+ startUrl(next?: string | null, intent?: Intent, prompt?: Prompt, maxAge?: MaxAge): string;
251
+ /**
252
+ * Whether `user` authenticated within `maxAge` seconds. A user with no
253
+ * `authTime` is never fresh: the issuer only has to send the claim when the
254
+ * request asked for it, so its absence says nothing, and the safe reading of
255
+ * nothing is "send them back".
256
+ */
257
+ fresh(user: User | null, maxAge: MaxAge): boolean;
199
258
  /**
200
259
  * Where to send somebody when a sign-in step failed for a reason they
201
260
  * cannot fix by retrying it automatically: `onError` with `?auth_error=`,
@@ -230,6 +289,19 @@ export interface Auth {
230
289
  * straight back in.
231
290
  */
232
291
  revokeSessions(userId: string): Promise<number>;
292
+ /**
293
+ * The devices page: every live session belonging to whoever is signed in
294
+ * on `request`, newest first. Namespace mode only. Throws `AuthError`
295
+ * exactly as `startPasskeyRegistration` does for nobody signed in.
296
+ */
297
+ sessions(request: Request): Promise<SessionRecord[]>;
298
+ /**
299
+ * Ends one of the signed-in person's own sessions. A key for somebody
300
+ * else, another binding, or nothing at all is refused the same way --
301
+ * `AuthError('request', ...)` -- so a guess cannot tell an id that exists
302
+ * from one that does not.
303
+ */
304
+ endSession(request: Request, key: string): Promise<void>;
233
305
  /** The auth request the issuer sent the browser here with. Refuses one for another application. */
234
306
  authRequest(id: string): Promise<AuthRequest>;
235
307
  signIn(input: {
@@ -253,6 +325,39 @@ export interface Auth {
253
325
  credential: unknown;
254
326
  name: string;
255
327
  }): Promise<PasskeyRegistrationResult>;
328
+ /**
329
+ * Starts changing the address of whoever is signed in on `request` to
330
+ * `email`. A code is mailed to that NEW address, never to the old one --
331
+ * it is what proves they hold it -- and only `completeEmailChange` moves
332
+ * the live address once that code checks out. Until then the person's
333
+ * current address is untouched and keeps working, so a code that is lost
334
+ * or never used costs nothing. Same signed-in requirement as
335
+ * `startPasskeyRegistration`.
336
+ */
337
+ startEmailChange(request: Request, email: string): Promise<void>;
338
+ /** The other half of `startEmailChange`; moves the live address once `code` checks out. */
339
+ completeEmailChange(request: Request, code: string): Promise<EmailChangeResult>;
340
+ /**
341
+ * Changes the password of whoever is signed in on `request`. The issuer
342
+ * checks `currentPassword` itself, so this never opens a session just to
343
+ * find out -- a wrong one comes back as its own error, distinct from a
344
+ * `newPassword` the issuer's own policy rejects.
345
+ */
346
+ changePassword(request: Request, input: {
347
+ currentPassword: string;
348
+ newPassword: string;
349
+ }): Promise<ChangePasswordResult>;
350
+ /**
351
+ * Every passkey this person has, `ready` or not. A person with no
352
+ * password who deletes their only `ready` passkey can lock themselves
353
+ * out of their own account -- the broker does not guard this and neither
354
+ * does the issuer, deliberately: whether that is a problem for this app
355
+ * is its call to make, not the SDK's. This returns enough to make that
356
+ * call -- an app can warn when the list holds exactly one `ready` entry.
357
+ */
358
+ passkeys(request: Request): Promise<Passkey[]>;
359
+ /** Removes one of this person's own passkeys. See `passkeys` for the lockout this does not guard against. */
360
+ deletePasskey(request: Request, id: string): Promise<DeletePasskeyResult>;
256
361
  /** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
257
362
  startPasskeySignIn(input: {
258
363
  authRequestId: string;
package/dist/auth.js CHANGED
@@ -2,9 +2,9 @@ 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, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
5
+ import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
6
6
  import { namespaceSessions } from './namespace-session.js';
7
- import { AuthError, ORG_CLAIM, Oidc, scopeFor } from './oidc.js';
7
+ import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
8
8
  import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
9
9
  import { safeNextPath } from './redirect.js';
10
10
  export function createAuth(input) {
@@ -57,8 +57,8 @@ export function createAuth(input) {
57
57
  return brokerInstance;
58
58
  };
59
59
  const sessions = namespaceSessions({ options, oidc, broker });
60
- const sessionCookie = (tokens) => options().namespace
61
- ? sessions.create(tokens)
60
+ const sessionCookie = (tokens, requestHeaders) => options().namespace
61
+ ? sessions.create(tokens, requestHeaders)
62
62
  : sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
63
63
  /**
64
64
  * Signing in again replaces this binding's previous session outright: in
@@ -74,15 +74,27 @@ export function createAuth(input) {
74
74
  .catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
75
75
  };
76
76
  const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
77
- const startUrl = (next, intent = 'login') => {
77
+ const startUrl = (next, intent = 'login', prompt, maxAge) => {
78
78
  const url = new URL(`${options().basePath}/start`, options().appUrl);
79
79
  const path = safeNextPath(next, '');
80
80
  if (path)
81
81
  url.searchParams.set('next', path);
82
82
  if (intent === 'register')
83
83
  url.searchParams.set('intent', 'register');
84
+ if (prompt)
85
+ url.searchParams.set('prompt', prompt);
86
+ if (maxAge !== undefined) {
87
+ assertMaxAge(maxAge);
88
+ url.searchParams.set('max_age', String(maxAge));
89
+ }
84
90
  return url.href;
85
91
  };
92
+ const fresh = (user, maxAge) => {
93
+ assertMaxAge(maxAge);
94
+ if (!user || user.authTime === null)
95
+ return false;
96
+ return Date.now() / 1000 - user.authTime <= maxAge + CLOCK_TOLERANCE;
97
+ };
86
98
  const start = async (request) => {
87
99
  if (!isNavigation(request))
88
100
  return new Response('Unauthorized', { status: 401 });
@@ -95,6 +107,8 @@ export function createAuth(input) {
95
107
  const codeVerifier = client.randomPKCECodeVerifier();
96
108
  const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
97
109
  create: params.get('intent') === 'register',
110
+ prompt: promptFrom(params.get('prompt')),
111
+ maxAge: maxAgeFrom(params.get('max_age')),
98
112
  });
99
113
  const transaction = await sealTransaction(options(), {
100
114
  st: state,
@@ -104,7 +118,7 @@ export function createAuth(input) {
104
118
  });
105
119
  return redirect(target.href, [transaction]);
106
120
  };
107
- const complete = async (callbackUrl, cookieHeader) => {
121
+ const complete = async (callbackUrl, cookieHeader, requestHeaders) => {
108
122
  if (!validCallback(callbackUrl))
109
123
  return failure('request', []);
110
124
  const cleared = clearTransaction(options());
@@ -123,7 +137,7 @@ export function createAuth(input) {
123
137
  nonce: transaction.nc,
124
138
  codeVerifier: transaction.cv,
125
139
  });
126
- session = await sessionCookie(tokens);
140
+ session = await sessionCookie(tokens, requestHeaders);
127
141
  }
128
142
  catch (error) {
129
143
  return failure(reasonOf(error), [cleared]);
@@ -140,26 +154,72 @@ export function createAuth(input) {
140
154
  // path and query are what it asked for, on the app's own origin.
141
155
  const { pathname, search } = new URL(request.url);
142
156
  const callbackUrl = new URL(`${pathname}${search}`, options().appUrl).href;
143
- const { location, cookies } = await complete(callbackUrl, request.headers.get('cookie'));
157
+ const { location, cookies } = await complete(callbackUrl, request.headers.get('cookie'), request.headers);
144
158
  return redirect(location, cookies);
145
159
  };
160
+ /**
161
+ * The record's own id token, read before `sessions.end` revokes it. Namespace
162
+ * sessions never hand the raw token back to a caller -- `sessions.read`
163
+ * answers with claims, not the token itself -- so logout, the one place that
164
+ * needs it for `id_token_hint`, opens the record the same way `end` does.
165
+ */
166
+ const namespaceIdToken = async (value) => {
167
+ const store = options().sessionStore;
168
+ const key = store ? await sessions.keyOf(value) : null;
169
+ if (!store || !key)
170
+ return undefined;
171
+ const record = await store.get(key).catch(() => null);
172
+ const payload = record && (await openRecord(options(), key, record.payload).catch(() => null));
173
+ return payload?.idt;
174
+ };
175
+ /**
176
+ * Where a sign-out lands: the issuer's own logout, so its session ends and
177
+ * the next sign-in asks who again, rather than straight back to
178
+ * `afterLogout`. What this function can see fail -- no id token to hint
179
+ * with, no `end_session_endpoint` published, the issuer not answering --
180
+ * falls back to the plain redirect a sign-out has always ended with.
181
+ * Losing the issuer's co-operation is never a reason to fail somebody's
182
+ * sign-out.
183
+ *
184
+ * `afterLogout` not being a registered `post_logout_redirect_uri` is a
185
+ * failure this function cannot see: that request reaches the issuer, which
186
+ * is free to answer with its own error page instead of a redirect back.
187
+ * Registering it (docs/adopting.md) is what prevents that, not a check
188
+ * here -- cookie mode has no registry to check against at runtime, and
189
+ * namespace mode already refuses to start with a binding whose
190
+ * `afterLogout` is not registered.
191
+ */
192
+ const endIssuerSession = async (idToken, cookies) => {
193
+ const afterLogout = new URL(options().afterLogout, options().appUrl).href;
194
+ const target = idToken
195
+ ? await oidc()
196
+ .endSessionUrl(idToken, afterLogout)
197
+ .catch(() => null)
198
+ : null;
199
+ return redirect(target?.href ?? afterLogout, cookies, 303);
200
+ };
146
201
  const logout = async (request) => {
147
202
  if (!isSameOrigin(request, options().appUrl.origin)) {
148
203
  return new Response('Forbidden', { status: 403 });
149
204
  }
150
205
  const value = cookieFrom(request.headers.get('cookie'), sessionCookieName(options()));
151
206
  if (options().namespace) {
207
+ const idToken = await namespaceIdToken(value);
152
208
  // Revoked in the store first: that is what ends copies of the cookie too.
153
- if ((await sessions.end(value)) === 'changed')
209
+ // `sessions.end` reaches the store, which can be briefly unreachable --
210
+ // and a sign-out that 500s over that leaves the browser's own cookie
211
+ // standing, which is worse than the record surviving a moment longer.
212
+ const status = await sessions.end(value).catch(() => 'ended');
213
+ if (status === 'changed')
154
214
  return new Response('Account changed', { status: 409 });
155
- return redirect(new URL(options().afterLogout, options().appUrl).href, [clearSession(options())], 303);
215
+ return endIssuerSession(idToken, [clearSession(options())]);
156
216
  }
157
217
  const session = await openSession(options(), value);
158
218
  if (session?.rt)
159
219
  await broker()
160
220
  .revoke(session.rt)
161
221
  .catch(() => { });
162
- return redirect(new URL(options().afterLogout, options().appUrl).href, [clearSession(options())], 303);
222
+ return endIssuerSession(session?.idt, [clearSession(options())]);
163
223
  };
164
224
  const verify = async (request) => {
165
225
  const params = new URL(request.url).searchParams;
@@ -227,8 +287,10 @@ export function createAuth(input) {
227
287
  return { user: null, cookies: [] };
228
288
  const now = Math.floor(Date.now() / 1000);
229
289
  const exp = expiryOf(session.idt);
290
+ const iat = issuedAtOf(session.idt);
230
291
  const valid = exp !== null && exp > now - 60;
231
- const due = exp === null || exp - options().refreshWindow <= now;
292
+ const stale = iat !== null && now - iat >= options().freshness;
293
+ const due = exp === null || exp - options().refreshWindow <= now || stale;
232
294
  if (due && refresh && session.rt) {
233
295
  try {
234
296
  const tokens = await oidc().refresh(session.rt);
@@ -239,9 +301,17 @@ export function createAuth(input) {
239
301
  return { user: boundUser(tokens.claims), cookies: [cookie] };
240
302
  }
241
303
  catch (error) {
242
- // A failed refresh never clears the cookie: a valid token carries on, and a dead one is replaced by the next sign-in.
243
304
  if (error instanceof AuthError)
244
305
  return { user: null, cookies: [clearSession(options())] };
306
+ // Under the issuer's own rotation nobody else can have spent this
307
+ // token, so a refusal is final -- the account is disabled or the
308
+ // grant is gone, and it stays denied on every request from here.
309
+ // The cookie is left alone though: a losing request in a genuine
310
+ // race must not blank out whatever cookie the winner's response is
311
+ // setting for the browser to pick up next.
312
+ if (isOAuthRefusal(error))
313
+ return { user: null, cookies: [] };
314
+ // A network error or a 5xx: the current token carries on, never past its own expiry.
245
315
  if (!valid)
246
316
  return { user: null, cookies: [] };
247
317
  }
@@ -299,6 +369,19 @@ export function createAuth(input) {
299
369
  throw new Error('@wtfalch/auth: revokeSessions needs namespace sessions');
300
370
  return sessions.revokeSubject(userId);
301
371
  };
372
+ const listSessions = async (request) => {
373
+ if (!options().namespace)
374
+ throw new Error('@wtfalch/auth: sessions needs namespace sessions');
375
+ const user = await currentUser(request);
376
+ return sessions.listSessions(user.id);
377
+ };
378
+ const endSession = async (request, key) => {
379
+ if (!options().namespace)
380
+ throw new Error('@wtfalch/auth: endSession needs namespace sessions');
381
+ const user = await currentUser(request);
382
+ if (!(await sessions.endSession(key, user.id)))
383
+ throw new AuthError('request', 'session does not belong to the signed-in person');
384
+ };
302
385
  const authRequest = (id) => broker().authRequest(id);
303
386
  /**
304
387
  * An OIDC flow this app starts and finishes itself, for a person arriving
@@ -323,7 +406,7 @@ export function createAuth(input) {
323
406
  return { id, state, nonce, verifier };
324
407
  };
325
408
  /** Exchanges a callback the service issued, and seals the cookie. */
326
- const finish = async (callbackUrl, flow, next) => {
409
+ const finish = async (callbackUrl, flow, next, requestHeaders) => {
327
410
  if (!validCallback(callbackUrl))
328
411
  throw new AuthError('request', 'callback does not match namespace binding');
329
412
  const tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
@@ -331,7 +414,7 @@ export function createAuth(input) {
331
414
  nonce: flow.nonce,
332
415
  codeVerifier: flow.verifier,
333
416
  });
334
- const cookie = await sessionCookie(tokens);
417
+ const cookie = await sessionCookie(tokens, requestHeaders);
335
418
  return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
336
419
  };
337
420
  const hostedUrl = (id) => {
@@ -342,9 +425,9 @@ export function createAuth(input) {
342
425
  const needsHostedLogin = (error) => error instanceof BrokerError && error.error === 'hosted_login_required';
343
426
  // Email/reset flows started on the server must transfer their PKCE transaction
344
427
  // to the browser before handing off to the issuer's full authentication UI.
345
- const finishOrHandoff = async (action, flow, next) => {
428
+ const finishOrHandoff = async (action, flow, next, requestHeaders) => {
346
429
  try {
347
- return await finish(await action(), flow, next);
430
+ return await finish(await action(), flow, next, requestHeaders);
348
431
  }
349
432
  catch (error) {
350
433
  if (!needsHostedLogin(error))
@@ -367,10 +450,10 @@ export function createAuth(input) {
367
450
  const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
368
451
  const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
369
452
  const invite = (input) => broker().invite({ ...input, next: safeNextPath(input.next, '') });
370
- const resetPassword = async ({ userId, code, password, next, }) => {
453
+ const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
371
454
  try {
372
455
  const flow = await serverFlow();
373
- const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin));
456
+ const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
374
457
  return { ok: true, ...completed };
375
458
  }
376
459
  catch (error) {
@@ -383,10 +466,10 @@ export function createAuth(input) {
383
466
  return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
384
467
  }
385
468
  };
386
- const followLink = async ({ sessionId, code, next, }) => {
469
+ const followLink = async ({ sessionId, code, next }, requestHeaders) => {
387
470
  try {
388
471
  const flow = await serverFlow();
389
- const completed = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(next, options().afterLogin));
472
+ const completed = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
390
473
  return { ok: true, ...completed };
391
474
  }
392
475
  catch (error) {
@@ -406,7 +489,7 @@ export function createAuth(input) {
406
489
  sessionId,
407
490
  code,
408
491
  next: params.get('next') ?? undefined,
409
- });
492
+ }, request.headers);
410
493
  return result.ok ? redirect(result.location, result.cookies) : failed('link', []);
411
494
  };
412
495
  const signIn = async ({ authRequestId, email, password, }) => {
@@ -480,6 +563,62 @@ export function createAuth(input) {
480
563
  throw error;
481
564
  }
482
565
  };
566
+ const startEmailChange = async (request, email) => {
567
+ const user = await currentUser(request);
568
+ await broker().startEmailChange(user.id, email);
569
+ };
570
+ const completeEmailChange = async (request, code) => {
571
+ const user = await currentUser(request);
572
+ try {
573
+ await broker().completeEmailChange(user.id, code);
574
+ return { ok: true };
575
+ }
576
+ catch (error) {
577
+ if (error instanceof BrokerError && error.error === 'email_taken') {
578
+ return { ok: false, error: 'email_taken' };
579
+ }
580
+ if (error instanceof BrokerError && error.error === 'too_many') {
581
+ return { ok: false, error: 'too_many' };
582
+ }
583
+ if (error instanceof BrokerError && error.status < 500) {
584
+ return { ok: false, error: 'invalid' };
585
+ }
586
+ return { ok: false, error: 'unavailable' };
587
+ }
588
+ };
589
+ const changePassword = async (request, input) => {
590
+ const user = await currentUser(request);
591
+ try {
592
+ await broker().changePassword({ userId: user.id, ...input });
593
+ return { ok: true };
594
+ }
595
+ catch (error) {
596
+ if (error instanceof BrokerError && error.error === 'invalid_current_password') {
597
+ return { ok: false, error: 'invalid_current_password' };
598
+ }
599
+ if (error instanceof BrokerError && error.status < 500) {
600
+ return { ok: false, error: 'invalid_password' };
601
+ }
602
+ return { ok: false, error: 'unavailable' };
603
+ }
604
+ };
605
+ const passkeys = async (request) => {
606
+ const user = await currentUser(request);
607
+ const result = await broker().passkeys(user.id);
608
+ return result.passkeys;
609
+ };
610
+ const deletePasskey = async (request, id) => {
611
+ const user = await currentUser(request);
612
+ try {
613
+ await broker().deletePasskey(user.id, id);
614
+ return { ok: true };
615
+ }
616
+ catch (error) {
617
+ if (error instanceof BrokerError && error.status < 500)
618
+ return { ok: false, error: 'invalid' };
619
+ throw error;
620
+ }
621
+ };
483
622
  const startPasskeySignIn = async ({ authRequestId, email, }) => {
484
623
  try {
485
624
  const { sessionId, options } = await broker().startPasskeySignIn({ authRequestId, email });
@@ -548,7 +687,7 @@ export function createAuth(input) {
548
687
  return { status: 'unavailable', cookies: [cleared] };
549
688
  let session;
550
689
  try {
551
- session = await sessionCookie(outcome.tokens);
690
+ session = await sessionCookie(outcome.tokens, request.headers);
552
691
  }
553
692
  catch (error) {
554
693
  return { status: 'failed', reason: reasonOf(error), cookies: [cleared] };
@@ -588,15 +727,23 @@ export function createAuth(input) {
588
727
  readCookie,
589
728
  gate,
590
729
  startUrl,
730
+ fresh,
591
731
  errorUrl,
592
732
  people,
593
733
  setPersonActive,
594
734
  revokeSessions,
735
+ sessions: listSessions,
736
+ endSession,
595
737
  authRequest,
596
738
  signIn,
597
739
  signUp,
598
740
  startPasskeyRegistration,
599
741
  finishPasskeyRegistration,
742
+ startEmailChange,
743
+ completeEmailChange,
744
+ changePassword,
745
+ passkeys,
746
+ deletePasskey,
600
747
  startPasskeySignIn,
601
748
  finishPasskeySignIn,
602
749
  startQrSignIn,
@@ -632,6 +779,55 @@ function expiryOf(idToken) {
632
779
  return null;
633
780
  }
634
781
  }
782
+ // Age, not time left: a disabled or revoked account must not ride out a
783
+ // long-lived token just because it still has plenty of time on the clock.
784
+ function issuedAtOf(idToken) {
785
+ try {
786
+ const [, payload] = idToken.split('.');
787
+ const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
788
+ return typeof json.iat === 'number' ? json.iat : null;
789
+ }
790
+ catch {
791
+ return null;
792
+ }
793
+ }
794
+ const PROMPTS = ['select_account', 'login'];
795
+ function promptFrom(raw) {
796
+ return PROMPTS.includes(raw ?? '') ? raw : undefined;
797
+ }
798
+ // A year. Past that the bound is doing nothing, and an absurd value is a
799
+ // caller getting it wrong rather than a request to honour.
800
+ const MAX_AGE_CEILING = 31_536_000;
801
+ /**
802
+ * The same sixty seconds `Oidc.verify` allows a token's own claims. `auth_time`
803
+ * comes off the issuer's clock and is compared against this server's, so a
804
+ * bound checked to the exact second would flip on skew alone.
805
+ *
806
+ * It also makes `maxAge: 0` terminate. Zero asks the issuer to authenticate
807
+ * the person again; they then spend a second or two coming back, and a check
808
+ * with no slack would find them already stale and send them round again,
809
+ * forever. The tolerance is why the round trip can ever satisfy the bound.
810
+ */
811
+ const CLOCK_TOLERANCE = 60;
812
+ function maxAgeFrom(raw) {
813
+ if (raw === null || raw.trim() === '')
814
+ return undefined;
815
+ const value = Number(raw);
816
+ if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING)
817
+ return undefined;
818
+ return value;
819
+ }
820
+ /**
821
+ * A bad `maxAge` from app code is a mistake in the app, and the loud failure is
822
+ * the cheap one. Left alone it is silent and expensive: `startUrl` would drop
823
+ * the value, the issuer would be asked for nothing, the person would come back
824
+ * exactly as stale, and the gate would send them again.
825
+ */
826
+ function assertMaxAge(value) {
827
+ if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING) {
828
+ throw new AuthError('request', `maxAge must be whole seconds from 0 to ${MAX_AGE_CEILING}`);
829
+ }
830
+ }
635
831
  function userFrom(claims) {
636
832
  const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
637
833
  return {
@@ -643,6 +839,7 @@ function userFrom(claims) {
643
839
  familyName: str('family_name'),
644
840
  preferredUsername: str('preferred_username'),
645
841
  organizationId: String(claims[ORG_CLAIM]),
842
+ authTime: typeof claims.auth_time === 'number' ? claims.auth_time : null,
646
843
  claims,
647
844
  };
648
845
  }
package/dist/broker.d.ts CHANGED
@@ -123,6 +123,22 @@ export declare class Broker {
123
123
  revoked?: string[];
124
124
  }>;
125
125
  verifyEmail(userId: string, code: string): Promise<unknown>;
126
+ /** Mails a code to `email`, never to the address it would replace -- see `Auth['startEmailChange']`. */
127
+ startEmailChange(userId: string, email: string): Promise<unknown>;
128
+ completeEmailChange(userId: string, code: string): Promise<unknown>;
129
+ changePassword(input: {
130
+ userId: string;
131
+ currentPassword: string;
132
+ newPassword: string;
133
+ }): Promise<unknown>;
134
+ passkeys(userId: string): Promise<{
135
+ passkeys: {
136
+ id: string;
137
+ name: string;
138
+ state: string;
139
+ }[];
140
+ }>;
141
+ deletePasskey(userId: string, passkeyId: string): Promise<unknown>;
126
142
  /** A public client revoking its own token: no key, no service. */
127
143
  revoke(refreshToken: string): Promise<void>;
128
144
  }
package/dist/broker.js CHANGED
@@ -123,6 +123,22 @@ export class Broker {
123
123
  verifyEmail(userId, code) {
124
124
  return this.call('POST', '/verify', { userId, code });
125
125
  }
126
+ /** Mails a code to `email`, never to the address it would replace -- see `Auth['startEmailChange']`. */
127
+ startEmailChange(userId, email) {
128
+ return this.call('POST', '/email/change/send', { userId, email });
129
+ }
130
+ completeEmailChange(userId, code) {
131
+ return this.call('POST', '/email/change/complete', { userId, code });
132
+ }
133
+ changePassword(input) {
134
+ return this.call('POST', '/password/change', input);
135
+ }
136
+ passkeys(userId) {
137
+ return this.call('GET', '/passkeys', { userId });
138
+ }
139
+ deletePasskey(userId, passkeyId) {
140
+ return this.call('POST', '/passkeys/delete', { userId, passkeyId });
141
+ }
126
142
  /** A public client revoking its own token: no key, no service. */
127
143
  async revoke(refreshToken) {
128
144
  await this.options.fetch(`${this.options.issuer}/oauth/v2/revoke`, {
package/dist/config.d.ts CHANGED
@@ -43,6 +43,12 @@ export interface AuthOptions {
43
43
  sessionMaxAge?: number;
44
44
  /** Refresh when the id token has fewer seconds left than this. Default 300. */
45
45
  refreshWindow?: number;
46
+ /**
47
+ * Refresh when the id token is older than this, however far it is from
48
+ * expiry -- the propagation bound for a disabled or revoked account.
49
+ * Default 300. Both modes.
50
+ */
51
+ freshness?: number;
46
52
  fetch?: typeof fetch;
47
53
  }
48
54
  export interface ResolvedOptions {
@@ -74,6 +80,7 @@ export interface ResolvedOptions {
74
80
  brokerUrl: string;
75
81
  sessionMaxAge: number;
76
82
  refreshWindow: number;
83
+ freshness: number;
77
84
  secure: boolean;
78
85
  fetch: typeof fetch;
79
86
  }