@wtfalch/auth 0.30.0 → 0.31.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
@@ -41,6 +41,14 @@ middleware for the paths that need a session, and read the person with
41
41
  `createAuth` from `@wtfalch/auth` is the same thing without the framework, for
42
42
  a CLI or a worker that has no router to import.
43
43
 
44
+ **Sign-in entry form.** `/auth/start` answers a GET (query) and a same-origin
45
+ form POST (`application/x-www-form-urlencoded`, at most 4096 bytes, length
46
+ declared). Post the form to keep an email `login_hint` out of URLs, access logs
47
+ and `Referer`: `<form method="post" action="/auth/start">` with `next`,
48
+ `intent`, `prompt`, `max_age` and `login_hint` as fields. A POST reads only its
49
+ body, refuses another origin (403), a non-form type (415) and a large or
50
+ undeclared body (413), and a `login_hint` still blocks a silent pass.
51
+
44
52
  ## One-time passwords
45
53
 
46
54
  An account created with `changeRequired: true` holds a one-time password.
@@ -103,6 +111,14 @@ already has a query string keeps it. `expired` and `request` carry no `next`:
103
111
  there is no transaction to read it from. Use it for the retry link
104
112
  (`/auth/start?next=<path>`); a page that ignores it behaves as before.
105
113
 
114
+ **`next` on the error page is attacker-controllable.** The SDK only puts a safe
115
+ path there, but anyone can open `<onError>?next=//evil.test` themselves. The
116
+ error page must run `next` through `safeNextPath` (exported from
117
+ `@wtfalch/auth`) before building a retry link, and never use it raw in a link,
118
+ redirect or `Location`. `next` can also
119
+ carry an invite token: send no `Referer` from the error page
120
+ (`Referrer-Policy: no-referrer`) and keep its query string out of your logs.
121
+
106
122
  ## In a browser app
107
123
 
108
124
  `@wtfalch/auth/browser` is the other half: a public PKCE client for an app that
@@ -142,6 +158,21 @@ again. `forget()` sets the same mark, so a sign-out does not undo itself.
142
158
  `completeSignIn()` at the callback returns `signed-in`, `silent-refused` — the
143
159
  answer to a silent attempt, not a failure — or `error`.
144
160
 
161
+ **Signing out at the issuer.** `forget()` only clears this tab. To end the
162
+ person's session at the issuer too, read the URL first, then forget, then go:
163
+
164
+ ```ts
165
+ const url = auth.endSessionUrl('https://app.example.com/signed-out');
166
+ await auth.forget();
167
+ if (url) window.location.assign(url);
168
+ ```
169
+
170
+ The SDK keeps the ID token it received at sign-in inside its stored session and
171
+ builds `id_token_hint` itself; it is never on `currentSession()` or any
172
+ outcome. The URL is the issuer's `/oidc/v1/end_session`, and the return address
173
+ must be registered for the client. It is `null` when no ID token is held, and
174
+ always on a namespace client.
175
+
145
176
  ### Multi-session seams: a non-default opener, storage, refresh and scopes
146
177
 
147
178
  Four options on `BrowserAuthOptions` exist for a client that is not a
@@ -250,6 +281,42 @@ wiring one in — `createBrowserAuth` throws rather than silently ignoring it.
250
281
  always reports `no-refresh-token`: the namespace exchange never retains a
251
282
  refresh token to begin with.
252
283
 
284
+ ## Native sign-in for a desktop or mobile shell
285
+
286
+ `@wtfalch/auth/desktop` mounts the handoff under `/auth/desktop/*`:
287
+ `createDesktopHandoff({ auth, appUrl, secret, appId, scheme, store, admit })`.
288
+ The shell opens `/auth/desktop/start` in the system browser, the person signs
289
+ in and confirms, and the shell trades its verifier for the session cookie once
290
+ at `/auth/desktop/redeem`.
291
+
292
+ - **Set `admit`.** The default, `alwaysAdmit`, hands the shell a session for
293
+ whoever the issuer signed in, with no further check. It fails open: an app
294
+ that needs an organisation, second-factor or linked-principal check and leaves
295
+ `admit` out gets no warning. Pass the check explicitly.
296
+ - **The store must lock.** `lock(tx, id)` is `SELECT ... FOR UPDATE` in a real
297
+ store. The memory store locks per row; a store that only reads lets two
298
+ concurrent redeems both get the cookie.
299
+ - **Rate-limit `/auth/desktop/start` at the edge too.** The handler caps open
300
+ sign-ins per caller (`maxStartsPerCaller`, default 8, keyed on the address in
301
+ `clientAddressHeader`, default `cf-connecting-ip`) under the store's total
302
+ capacity. Callers with no such header share one bucket.
303
+ - **Log unexpected failures.** The browser sees only `unavailable`; pass
304
+ `onError` to receive `{ step, reason, errorName }` (fixed text, never a
305
+ message, cookie or verifier) and alert on it.
306
+
307
+ ## An MCP resource with a path
308
+
309
+ `@wtfalch/auth/mcp` serves several apps on one host, each at its own resource
310
+ URL (`https://host/a/mcp`, `https://host/b/mcp`). Serve each app's metadata at
311
+ `protectedResourceMetadataPath({ issuer, audience, resource })`, which is the
312
+ RFC 9728 path-suffixed form (`/.well-known/oauth-protected-resource/a/mcp`; a
313
+ resource at the root keeps `/.well-known/oauth-protected-resource`), with
314
+ `protectedResourceMetadata(...)` as the body. The path is exact: `/a/mcp` and
315
+ `/a/mcp2` never share one, and a trailing slash is part of it. `mcpChallenge`
316
+ names the same URL. Each app has its own `audience`, and a token issued for one
317
+ is refused (`invalid`) at the other. A stage copy passes its own host's
318
+ `resource`, never the live one.
319
+
253
320
  ## Configuration
254
321
 
255
322
  | | |
@@ -309,6 +376,51 @@ Not available in namespace mode. `extraScopes` on `createAuth`'s options
309
376
  adds to the scope every sign-in and QR device grant asks for, for a
310
377
  resource server's own audience scope. See `docs/adopting.md` for both.
311
378
 
379
+ ## Verifying a person's access token
380
+
381
+ `createPersonTokenVerifier` (`@wtfalch/auth/person-token`) is for a server that
382
+ accepts a signed-in person's bearer token. `verify(token)` returns
383
+ `{ sub, organizationId? }` or `null`; `null` covers a bad token and an issuer
384
+ that could not be asked alike. A JWT is checked against the issuer's keys only;
385
+ an opaque token is introspected (RFC 7662).
386
+
387
+ `verifyOutcome(token)` is for a caller that must tell those apart. It also
388
+ introspects a JWT after the signature checks, so a revoked JWT is refused
389
+ before its `exp`. It returns `{ ok: true, identity }` or `{ ok: false, reason }`
390
+ with `reason` one of `invalid`, `expired`, `revoked` or `unavailable`.
391
+ `unavailable` means the issuer could not be asked (network error, non-2xx,
392
+ unreadable answer): answer with a retryable error and do not remember it as a
393
+ refusal. It needs `introspectionClientId` and `introspectionClientSecret`;
394
+ without them every token is `invalid`. A positive introspection answer is
395
+ cached for up to 60 seconds; a refusal or an `unavailable` never is.
396
+
397
+ `organizationId` is present only when the token's organisation claim is a
398
+ non-empty string. An empty or non-string claim is treated as missing and the
399
+ token still verifies.
400
+
401
+ ## Back-channel logout
402
+
403
+ In namespace mode, `auth.backchannelLogout` (mounted by `handle` at
404
+ `{basePath}/backchannel-logout`) receives the issuer's own POST of a
405
+ `logout_token` and ends every session of the subject it names. Register that
406
+ URL as the client's back-channel logout URI at the issuer. It takes no
407
+ same-origin check, because the issuer calls it, not a browser.
408
+
409
+ A token is refused with 400 unless it is signed by the issuer's published keys
410
+ for this client, carries the back-channel-logout event, no `nonce`, an `iat`
411
+ no older than two minutes (plus 60 seconds of clock tolerance) and an `exp`.
412
+ There is no `jti` store, so that window is all that limits a replay, and a
413
+ replay only ends sessions again. A body over 16 KiB is refused with 413. Outside
414
+ namespace mode the route answers 404.
415
+
416
+ A token that names only a `sid` (the issuer's own session id) is answered 200
417
+ and ends nothing: the session store is keyed by subject. Configure the issuer
418
+ to send `sub`, or sessions stay alive after logout.
419
+
420
+ Sessions are also re-checked against the issuer at least every 60 seconds
421
+ whatever `freshness` is set to, so a revoked or deactivated person is signed
422
+ out within a minute even if the logout call never arrives.
423
+
312
424
  ## Passkeys and QR sign-in
313
425
 
314
426
  Passkeys live on each app's own domain: the relying-party ID is the app's
package/dist/auth.d.ts CHANGED
@@ -208,6 +208,14 @@ export interface Auth {
208
208
  complete(callbackUrl: string, cookieHeader: string | null, requestHeaders?: Headers): Promise<Completed>;
209
209
  /** POST {basePath}/logout */
210
210
  logout(request: Request): Promise<Response>;
211
+ /**
212
+ * POST {basePath}/backchannel-logout: the issuer's own call, not the
213
+ * browser's. Needs namespace mode -- a plain cookie session holds no
214
+ * server-side record this can end. Throws if namespace mode is not
215
+ * configured; use the OIDC back-channel logout registration to point only
216
+ * namespace-mode deployments at it.
217
+ */
218
+ backchannelLogout(request: Request): Promise<Response>;
211
219
  /** GET {basePath}/verify?userId=&code= */
212
220
  verify(request: Request): Promise<Response>;
213
221
  /** GET {basePath}/link?sessionId=&code= : the sign-in link from an email, on any device. */
@@ -308,6 +316,25 @@ export interface Auth {
308
316
  }): Promise<{
309
317
  userId: string;
310
318
  }>;
319
+ /**
320
+ * Replaces the developer's own session with one for a test user, `userId`
321
+ * -- act-as, refused everywhere but a stage or laptop entry (see
322
+ * `App.actAs` in the broker). Runs its own OIDC transaction, exactly as
323
+ * `resetPassword` does, so there is no `authRequestId` to receive.
324
+ *
325
+ * `actorId` is the developer's own id -- not resolved from a request here,
326
+ * because the caller already knows who is signed in -- and travels into
327
+ * the `act_as.started` event `onActAs` writes once the broker has made the
328
+ * session and finished the auth request. That write is required and
329
+ * awaited: unlike `onAuthEvent`, a throw or a rejection from `onActAs`
330
+ * fails this call, because an admin override nobody's audit trail caught
331
+ * is worse than one refused outright.
332
+ */
333
+ actAs(input: {
334
+ actorId: string;
335
+ userId: string;
336
+ next?: string;
337
+ }, requestHeaders?: Headers): Promise<Completed>;
311
338
  handle(request: Request): Promise<Response>;
312
339
  /**
313
340
  * `refresh: false` for callers that cannot set cookies; they get a user only while the token is valid.
@@ -336,7 +363,8 @@ export interface Auth {
336
363
  /**
337
364
  * Where to send somebody when a sign-in step failed for a reason they
338
365
  * cannot fix by retrying it automatically: `onError` with `?auth_error=`,
339
- * which is the same shape the callback's own failures use. Exposed because
366
+ * which is the shape the callback's own failures use. Unlike a callback
367
+ * failure that had a transaction, it carries no `next`. Exposed because
340
368
  * the Next adapter has to reach it from a server component, where a
341
369
  * `Response` is no use.
342
370
  */
package/dist/auth.js CHANGED
@@ -6,7 +6,7 @@ import { namespaceSessions } from './namespace-session.js';
6
6
  import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, rejected, scopeFor, } from './oidc.js';
7
7
  import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
8
8
  import { safeLoginHint, safeNextPath } from './redirect.js';
9
- import { expiryOf, issuedAtOf, timeoutSignal } from './util.js';
9
+ import { expiryOf, issuedAtOf, recheckSeconds, timeoutSignal } from './util.js';
10
10
  export function createAuth(input) {
11
11
  // Resolved on first use so `next build`, which imports every route module, needs none of the values set.
12
12
  let resolved = null;
@@ -128,12 +128,36 @@ export function createAuth(input) {
128
128
  return false;
129
129
  return Date.now() / 1000 - user.authTime <= maxAge + CLOCK_TOLERANCE;
130
130
  };
131
+ /**
132
+ * `/start` takes its parameters from the query on a GET and from a form-encoded
133
+ * body on a POST (the entry form posts, so an email hint stays out of URLs, logs
134
+ * and `Referer`). A POST ignores the query; it must come from this app's own
135
+ * origin, declare a small length and be a form body.
136
+ */
137
+ const startParams = async (request) => {
138
+ if (request.method !== 'POST')
139
+ return new URL(request.url).searchParams;
140
+ if (!isSameOrigin(request, options().appUrl.origin))
141
+ return new Response('Forbidden', { status: 403 });
142
+ const type = request.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
143
+ if (type !== 'application/x-www-form-urlencoded')
144
+ return new Response('Unsupported Media Type', { status: 415 });
145
+ const length = request.headers.get('content-length');
146
+ if (length === null || !/^\d{1,9}$/.test(length) || Number(length) > START_BODY_MAX)
147
+ return new Response('Payload Too Large', { status: 413 });
148
+ const text = await request.text();
149
+ if (text.length > START_BODY_MAX)
150
+ return new Response('Payload Too Large', { status: 413 });
151
+ return new URLSearchParams(text);
152
+ };
131
153
  const start = async (request) => {
132
154
  if (!isNavigation(request))
133
155
  return new Response('Unauthorized', { status: 401 });
134
156
  if (options().namespace && !(await sessions.bindingActive()))
135
157
  return unavailable();
136
- const params = new URL(request.url).searchParams;
158
+ const params = await startParams(request);
159
+ if (params instanceof Response)
160
+ return params;
137
161
  const next = safeNextPath(params.get('next'), options().afterLogin);
138
162
  // Only the gate can ask for a silent pass: its sealed marker is the sole way
139
163
  // in, and the query's own `prompt` never reaches `none`. An explicit choice
@@ -326,7 +350,7 @@ export function createAuth(input) {
326
350
  const route = pathname.startsWith(`${options().basePath}/`)
327
351
  ? pathname.slice(options().basePath.length + 1)
328
352
  : null;
329
- if (route === 'start' && request.method === 'GET')
353
+ if (route === 'start' && (request.method === 'GET' || request.method === 'POST'))
330
354
  return start(request);
331
355
  if (route === 'callback' && request.method === 'GET')
332
356
  return callback(request);
@@ -336,6 +360,8 @@ export function createAuth(input) {
336
360
  return link(request);
337
361
  if (route === 'logout' && request.method === 'POST')
338
362
  return logout(request);
363
+ if (route === 'backchannel-logout' && request.method === 'POST')
364
+ return backchannelLogout(request);
339
365
  return new Response('Not found', { status: 404 });
340
366
  };
341
367
  const readCookie = async (value, { refresh = true } = {}) => {
@@ -354,7 +380,7 @@ export function createAuth(input) {
354
380
  const exp = expiryOf(session.idt);
355
381
  const iat = issuedAtOf(session.idt);
356
382
  const valid = exp !== null && exp > now - 60;
357
- const stale = iat !== null && now - iat >= options().freshness;
383
+ const stale = iat !== null && now - iat >= recheckSeconds(options().freshness);
358
384
  const due = exp === null || exp - options().refreshWindow <= now || stale;
359
385
  if (due && refresh && session.rt) {
360
386
  try {
@@ -461,6 +487,47 @@ export function createAuth(input) {
461
487
  throw new Error('@wtfalch/auth: revokeSessions needs namespace sessions');
462
488
  return sessions.revokeSubject(userId);
463
489
  };
490
+ /**
491
+ * POST {basePath}/backchannel-logout: the issuer's own back-channel call,
492
+ * never the browser's -- so, unlike `logout`, this takes no same-origin
493
+ * check. A logout token names a session by `sub` and/or `sid`; this store
494
+ * keys a record by subject, not by the issuer's session id, so a `sid`-only
495
+ * token is accepted (200, per the spec: a token this app cannot act on is
496
+ * not a token it may reject) but ends nothing. Every failure path answers
497
+ * with no cookie and nothing about which case it was, since the caller is
498
+ * the issuer, not the person the token names.
499
+ */
500
+ const backchannelLogout = async (request) => {
501
+ if (!options().namespace)
502
+ return new Response('Not found', { status: 404, headers: NO_STORE });
503
+ if (request.method !== 'POST')
504
+ return new Response('Method not allowed', { status: 405, headers: NO_STORE });
505
+ let token;
506
+ try {
507
+ const body = await readBounded(request, BACKCHANNEL_LOGOUT_MAX_BODY_BYTES);
508
+ if (body === null) {
509
+ return backchannelLogoutError('invalid_request', 'the request body is too large', 413);
510
+ }
511
+ token = new URLSearchParams(body).get('logout_token');
512
+ }
513
+ catch {
514
+ return backchannelLogoutError('invalid_request', 'the request body could not be read');
515
+ }
516
+ if (!token)
517
+ return backchannelLogoutError('invalid_request', 'missing logout_token');
518
+ let claims;
519
+ try {
520
+ claims = await oidc().verifyLogoutToken(token);
521
+ }
522
+ catch (error) {
523
+ if (rejected(error))
524
+ return backchannelLogoutError('invalid_token', 'the logout token was refused');
525
+ throw error;
526
+ }
527
+ if (claims.sub)
528
+ await sessions.revokeSubject(claims.sub);
529
+ return new Response(null, { status: 200, headers: NO_STORE });
530
+ };
464
531
  const listSessions = async (request) => {
465
532
  if (!options().namespace)
466
533
  throw new Error('@wtfalch/auth: sessions needs namespace sessions');
@@ -564,6 +631,30 @@ export function createAuth(input) {
564
631
  emit({ type: 'createTestUser', email: input.email, userId: result.userId });
565
632
  return result;
566
633
  };
634
+ /**
635
+ * Runs its own transaction, as `resetPassword` does. No hosted-login
636
+ * handoff: the broker's `/act-as` either finishes the auth request
637
+ * directly or refuses outright, so there is nothing for `finishOrHandoff`
638
+ * to catch here.
639
+ */
640
+ const actAs = async ({ actorId, userId, next }, requestHeaders) => {
641
+ const onActAs = options().onActAs;
642
+ if (!onActAs)
643
+ throw new Error('@wtfalch/auth: onActAs is required to use actAs');
644
+ const flow = await serverFlow();
645
+ const callbackUrl = await broker().actAs({ authRequestId: flow.id, actorId, userId });
646
+ // Audited only once `finish` has made the session: a failed `finish` leaves no
647
+ // `act_as.started` for an attempt the caller sees as failed, and a retry logs once.
648
+ const completed = await finish(callbackUrl, flow, safeNextPath(next, options().afterLogin), requestHeaders);
649
+ await onActAs({
650
+ type: 'act_as.started',
651
+ actor: actorId,
652
+ target: userId,
653
+ client: options().clientId,
654
+ time: Date.now(),
655
+ });
656
+ return completed;
657
+ };
567
658
  /**
568
659
  * An admin's reset for somebody in this app's own organisation whom
569
660
  * `requestPasswordReset` cannot otherwise get back in -- nobody at the
@@ -945,6 +1036,7 @@ export function createAuth(input) {
945
1036
  callback,
946
1037
  complete,
947
1038
  logout,
1039
+ backchannelLogout,
948
1040
  verify,
949
1041
  link,
950
1042
  followLink,
@@ -954,6 +1046,7 @@ export function createAuth(input) {
954
1046
  sendLink,
955
1047
  invite,
956
1048
  createTestUser,
1049
+ actAs,
957
1050
  handle,
958
1051
  read,
959
1052
  readCookie,
@@ -994,6 +1087,45 @@ function redirect(location, cookies, status = 302) {
994
1087
  headers.append('Set-Cookie', cookie.header);
995
1088
  return new Response(null, { status, headers });
996
1089
  }
1090
+ const NO_STORE = { 'Cache-Control': 'no-store' };
1091
+ /** A logout token is a few hundred bytes; this is generous and still small. */
1092
+ const BACKCHANNEL_LOGOUT_MAX_BODY_BYTES = 16 * 1024;
1093
+ /** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
1094
+ async function readBounded(request, max) {
1095
+ const declared = Number(request.headers.get('content-length'));
1096
+ if (Number.isFinite(declared) && declared > max)
1097
+ return null;
1098
+ if (!request.body)
1099
+ return '';
1100
+ const reader = request.body.getReader();
1101
+ const chunks = [];
1102
+ let size = 0;
1103
+ for (;;) {
1104
+ const { done, value } = await reader.read();
1105
+ if (done)
1106
+ break;
1107
+ size += value.byteLength;
1108
+ if (size > max) {
1109
+ await reader.cancel();
1110
+ return null;
1111
+ }
1112
+ chunks.push(value);
1113
+ }
1114
+ const bytes = new Uint8Array(size);
1115
+ let offset = 0;
1116
+ for (const chunk of chunks) {
1117
+ bytes.set(chunk, offset);
1118
+ offset += chunk.byteLength;
1119
+ }
1120
+ return new TextDecoder().decode(bytes);
1121
+ }
1122
+ /** A back-channel logout endpoint's 400 error body, per OpenID Connect Back-Channel Logout 1.0 §2.6. */
1123
+ function backchannelLogoutError(error, description, status = 400) {
1124
+ return new Response(JSON.stringify({ error, error_description: description }), {
1125
+ status,
1126
+ headers: { 'content-type': 'application/json', ...NO_STORE },
1127
+ });
1128
+ }
997
1129
  function matches(rule, pathname) {
998
1130
  if (!rule)
999
1131
  return false;
@@ -1070,6 +1202,8 @@ function userFrom(claims) {
1070
1202
  };
1071
1203
  }
1072
1204
  // The token itself is bad, as opposed to the keys being unreachable or rotating.
1205
+ /** The most a `/start` form body may hold: a path, a hint and three short fields. */
1206
+ const START_BODY_MAX = 4096;
1073
1207
  function isNavigation(request) {
1074
1208
  const mode = request.headers.get('sec-fetch-mode');
1075
1209
  if (mode && mode !== 'navigate')
package/dist/broker.d.ts CHANGED
@@ -146,6 +146,12 @@ export declare class Broker {
146
146
  }): Promise<{
147
147
  userId: string;
148
148
  }>;
149
+ /** The service half of `Auth['actAs']`: a session for the target, finishing `authRequestId`. */
150
+ actAs(input: {
151
+ authRequestId: string;
152
+ actorId: string;
153
+ userId: string;
154
+ }): Promise<string>;
149
155
  sendReset(email: string, next: string): Promise<unknown>;
150
156
  sendVerification(email: string, next: string): Promise<unknown>;
151
157
  /**
package/dist/broker.js CHANGED
@@ -137,6 +137,11 @@ export class Broker {
137
137
  createTestUser(input) {
138
138
  return this.call('POST', '/test-users', input);
139
139
  }
140
+ /** The service half of `Auth['actAs']`: a session for the target, finishing `authRequestId`. */
141
+ async actAs(input) {
142
+ const { callbackUrl } = await this.call('POST', '/act-as', input);
143
+ return callbackUrl;
144
+ }
140
145
  sendReset(email, next) {
141
146
  return this.call('POST', '/reset/send', { email, next });
142
147
  }
package/dist/browser.d.ts CHANGED
@@ -72,7 +72,7 @@ export interface BrowserAuthOptions {
72
72
  * origin `/auth` belongs to Stalwart and never reaches the app.
73
73
  */
74
74
  callbackPath?: string;
75
- /** Scopes to ask for. `openid` is required; the rest is the app's business. */
75
+ /** Scopes to ask for. `openid` is required (`createBrowserAuth` throws without it); the rest is the app's business. */
76
76
  scope?: string;
77
77
  /**
78
78
  * Extra scopes appended to `scope` at `/authorize`, de-duplicated with it —
@@ -176,6 +176,16 @@ export interface BrowserAuth {
176
176
  * to the other's now-spent refresh token.
177
177
  */
178
178
  refresh(): Promise<RefreshOutcome>;
179
+ /**
180
+ * The issuer's sign-out URL carrying `id_token_hint` and
181
+ * `post_logout_redirect_uri`, built from the ID token this tab received at
182
+ * sign-in, or null when none is held (a session from before this existed, a
183
+ * namespace client, or after `forget()`). The token itself never leaves the
184
+ * SDK. Call it before `forget()`, which drops it; then send the browser there.
185
+ * The URL is the issuer's fixed `/oidc/v1/end_session`; `postLogoutRedirectUri`
186
+ * must be registered for the client at the issuer.
187
+ */
188
+ endSessionUrl(postLogoutRedirectUri: string): string | null;
179
189
  /** Drop the session, and do not silently take another in this tab. Awaits
180
190
  * the store's writes and invalidates pending sign-in/refresh completions.
181
191
  * After it resolves, an older write cannot restore the forgotten session. */
package/dist/browser.js CHANGED
@@ -49,13 +49,17 @@ function verifier() {
49
49
  return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
50
50
  }
51
51
  function publicSession(session) {
52
- const { refreshToken, ...rest } = session;
52
+ const { refreshToken, idToken, ...rest } = session;
53
53
  return rest;
54
54
  }
55
55
  // Instances using the same store and context share cancellation and refreshes.
56
56
  // The network is outside this queue: forget must not wait for a token response.
57
57
  const coordinators = new WeakMap();
58
58
  export function createBrowserAuth(options) {
59
+ // The nonce check reads the ID token, and the issuer sends one only for `openid`.
60
+ // Without it every sign-in would be refused after the code exchange, so refuse here.
61
+ if (!(options.scope ?? 'openid').split(/\s+/).includes('openid'))
62
+ throw new Error('createBrowserAuth: scope must include openid');
59
63
  if (options.namespace) {
60
64
  // The namespace path coordinates a shared session across tabs and
61
65
  // clients through fixed `sessionStorage`/`localStorage` keys (see
@@ -232,6 +236,8 @@ export function createBrowserAuth(options) {
232
236
  accessToken: token.access_token,
233
237
  expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
234
238
  refreshToken: token.refresh_token,
239
+ // A refresh response may carry a new ID token; otherwise the one from sign-in stays.
240
+ idToken: token.id_token ?? session.idToken,
235
241
  };
236
242
  await store.set(tokenKey, JSON.stringify(next));
237
243
  if (!current(generation))
@@ -242,6 +248,15 @@ export function createBrowserAuth(options) {
242
248
  return {
243
249
  accountChanged: () => false,
244
250
  currentSession,
251
+ endSessionUrl(postLogoutRedirectUri) {
252
+ const idToken = readStored()?.idToken;
253
+ if (!idToken)
254
+ return null;
255
+ const url = new URL(`${options.issuer.replace(/\/$/, '')}/oidc/v1/end_session`);
256
+ url.searchParams.set('id_token_hint', idToken);
257
+ url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
258
+ return url.toString();
259
+ },
245
260
  silentSignInAvailable,
246
261
  async trySilentSignIn(returnTo = window.location.pathname) {
247
262
  if (!silentSignInAvailable())
@@ -344,6 +359,7 @@ export function createBrowserAuth(options) {
344
359
  accessToken: token.access_token,
345
360
  expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
346
361
  refreshToken: token.refresh_token,
362
+ idToken: token.id_token,
347
363
  };
348
364
  await store.set(tokenKey, JSON.stringify(session));
349
365
  if (!current(generation))
package/dist/config.d.ts CHANGED
@@ -32,6 +32,20 @@ export type AuthEvent =
32
32
  email: string;
33
33
  userId: string;
34
34
  };
35
+ /**
36
+ * `actAs`'s own audit record: who asked (`actor`), who they viewed the app
37
+ * as (`target`), which client the session was opened for, and when. Unlike
38
+ * `AuthEvent`, this has exactly one variant and one writer (`onActAs`),
39
+ * because act-as is the one call here that must never go unrecorded -- see
40
+ * `onActAs`'s own doc comment for what that changes.
41
+ */
42
+ export interface ActAsEvent {
43
+ type: 'act_as.started';
44
+ actor: string;
45
+ target: string;
46
+ client: string;
47
+ time: number;
48
+ }
35
49
  /**
36
50
  * Why `accessToken` refused: no session (no cookie in cookie mode, no live
37
51
  * record behind the namespace handle in namespace mode), a session with no
@@ -70,11 +84,11 @@ export interface AuthOptions {
70
84
  * `appKey`; unused without it. Every replica of the app must share one.
71
85
  */
72
86
  sessionStore?: SessionStore;
73
- /** This app's origin, e.g. https://portal.valet.wtfalch.dev. Every URL the SDK builds starts here, never from the request's Host. */
87
+ /** This app's origin, e.g. https://portal.example.test. Every URL the SDK builds starts here, never from the request's Host. */
74
88
  appUrl: string | undefined;
75
- /** From scripts/provisioned.json. */
89
+ /** From the stack's provisioned.json. */
76
90
  clientId: string | undefined;
77
- /** From scripts/provisioned.json. Sent as a scope and checked on every token. */
91
+ /** From the stack's provisioned.json. Sent as a scope and checked on every token. */
78
92
  organizationId: string | undefined;
79
93
  /**
80
94
  * Extra scopes appended to `scopeFor`'s fixed set, on both the
@@ -111,7 +125,12 @@ export interface AuthOptions {
111
125
  afterLogin?: string;
112
126
  /** Default /. Must be a registered post-logout URI. */
113
127
  afterLogout?: string;
114
- /** Where a failed callback lands, with ?auth_error=<reason>. Default /. */
128
+ /**
129
+ * Where a failed callback lands, with `?auth_error=<reason>`, and `&next=<path>`
130
+ * when the failed sign-in had a transaction. `next` is a same-origin path, but anyone
131
+ * can open this page with a `next` of their own: run it through `safeNextPath` before
132
+ * building a link. Default /.
133
+ */
115
134
  onError?: string;
116
135
  /** Where a verified email link lands. Default /. */
117
136
  afterVerify?: string;
@@ -137,6 +156,16 @@ export interface AuthOptions {
137
156
  * the call it rode in on.
138
157
  */
139
158
  onAuthEvent?: (event: AuthEvent) => void;
159
+ /**
160
+ * Required to call `actAs`. Unlike `onAuthEvent`, this IS awaited, and a
161
+ * throw or a rejection fails the `actAs` call -- the whole point of
162
+ * act-as is that it never happens off the record, so a session nobody's
163
+ * audit trail caught must not go live silently. The broker has already
164
+ * made the session and finished the auth request by the time this runs;
165
+ * a failure here cannot undo that, only stop the SDK from handing the
166
+ * browser the result.
167
+ */
168
+ onActAs?: (event: ActAsEvent) => Promise<void>;
140
169
  }
141
170
  export interface ResolvedOptions {
142
171
  namespace: ReturnType<typeof resolveNamespaceContext> | null;
@@ -174,6 +203,7 @@ export interface ResolvedOptions {
174
203
  secure: boolean;
175
204
  fetch: typeof fetch;
176
205
  onAuthEvent: ((event: AuthEvent) => void) | null;
206
+ onActAs: ((event: ActAsEvent) => Promise<void>) | null;
177
207
  }
178
208
  export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
179
209
  /**
package/dist/config.js CHANGED
@@ -61,6 +61,7 @@ export function resolveOptions(options) {
61
61
  secure: appUrl.protocol === 'https:',
62
62
  fetch: options.fetch ?? globalThis.fetch,
63
63
  onAuthEvent: options.onAuthEvent ?? null,
64
+ onActAs: options.onActAs ?? null,
64
65
  };
65
66
  }
66
67
  function required(value, name) {