@ultimat3/auth 20.2.0 → 21.0.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/CLAUDE.md CHANGED
@@ -514,6 +514,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
514
514
  | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `createFlightGate` with auth's own refusal injected |
515
515
  | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
516
516
  | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
517
+ | `sign-out.ts` | `signOutHeaders({ session? })` — what a sign-out RESPONSE appends: the framework session cookie expired (when `session` is given) and `Clear-Site-Data: "cache", "storage"` (`SIGN_OUT_CLEAR_SITE_DATA`), so the browser drops the previous principal's IndexedDB (the page store's `ultimate-client`), local storage, service worker and Cache Storage. Never `"cookies"`: the response sets the signed-out cookie itself. THE TRADE-OFF, decided 2026-09-22: a PWA's offline cache does not survive a sign-out — the worker is unregistered and the next load reinstalls and re-precaches it — because a cached private page IS the previous principal's data. The page boot's rescope wipe stays as the second line, for a sign-out with no navigation. Browsers honour the header only in a secure context (HTTPS, or localhost) |
518
+ | `client-scope.ts` | `clientScopeOf(actor)` — the OPAQUE per-principal id a private document hands the page's client store (`<meta name="ultimate-scope">`, plan 101). Keyed SHA-256 (`SESSION_SECRET`) over kind, id and the impersonator, 32 hex; `''` for anonymous. Never the raw id: the HTML is readable by every script on the page. `SESSION_SECRET` is optional in the env schema, so its absence falls back to a random PER-PROCESS key with one `auth.client_scope.process_key` warning — the id then changes across replicas and restarts, which rescopes (clears) a page's store and never leaks one |
517
519
 
518
520
  ```bash
519
521
  bun test packages/auth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "20.2.0",
3
+ "version": "21.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,8 +31,8 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "20.2.0",
35
- "@ultimat3/db": "20.2.0",
36
- "@ultimat3/schema": "20.2.0"
34
+ "@ultimat3/core": "21.0.0",
35
+ "@ultimat3/db": "21.0.0",
36
+ "@ultimat3/schema": "21.0.0"
37
37
  }
38
38
  }
@@ -0,0 +1,68 @@
1
+ // The browser page's client scope id: an OPAQUE name for the principal a per-request document was
2
+ // rendered for, carried in `<meta name="ultimate-scope">` so the page's one client store fences
3
+ // itself per principal (plan 101, decision 8). Keyed SHA-256 over the principal, never the id:
4
+ // the HTML is cacheable by a browser and readable by every script on the page.
5
+
6
+ import type { Actor } from '@ultimat3/core';
7
+ import { logger } from '@ultimat3/core';
8
+ import { clientScopeSecretShort } from './errors';
9
+
10
+ /** 128 bits: a scope only has to differ between principals, never to authenticate anyone. */
11
+ const SCOPE_HEX_CHARS = 32;
12
+
13
+ /** `wiki/Configuration.md`'s floor for `SESSION_SECRET`, the same one the oauth handshake reads. */
14
+ const MIN_SECRET_LENGTH = 32;
15
+
16
+ export interface ClientScopeOptions {
17
+ /** Defaults to `SESSION_SECRET`. */
18
+ readonly secret?: string | undefined;
19
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
20
+ }
21
+
22
+ /** Minted on first need when no `SESSION_SECRET` exists: stable per process, never per request. */
23
+ let processKey: string | undefined;
24
+
25
+ /**
26
+ * `''` for the anonymous page — the empty scope core reads as "nobody". Otherwise the principal
27
+ * AND who is really acting, so an admin impersonating a user is not the user's own scope: their
28
+ * stores must never share records, on the page or on disk.
29
+ */
30
+ export function clientScopeOf(actor: Actor, options: ClientScopeOptions = {}): string {
31
+ if (actor.kind === 'anonymous') return '';
32
+ const material = JSON.stringify([
33
+ actor.kind,
34
+ actor.id,
35
+ actor.onBehalfOf?.actorKind ?? null,
36
+ actor.onBehalfOf?.actorId ?? null,
37
+ ]);
38
+ return new Bun.CryptoHasher('sha256', scopeKey(options))
39
+ .update(material)
40
+ .digest('hex')
41
+ .slice(0, SCOPE_HEX_CHARS);
42
+ }
43
+
44
+ function scopeKey(options: ClientScopeOptions): string {
45
+ if (options.secret !== undefined) {
46
+ // The same floor as the env path below: an explicit key is a caller's value, so a short one is
47
+ // refused rather than silently swapped for the process key.
48
+ if (options.secret.length < MIN_SECRET_LENGTH) {
49
+ throw clientScopeSecretShort(options.secret.length, MIN_SECRET_LENGTH);
50
+ }
51
+ return options.secret;
52
+ }
53
+ const configured = (options.env ?? Bun.env)['SESSION_SECRET']?.trim() ?? '';
54
+ if (configured.length >= MIN_SECRET_LENGTH) return configured;
55
+ if (processKey === undefined) {
56
+ // `SESSION_SECRET` is optional in the env schema, so its absence cannot refuse a render. A
57
+ // random per-process key keeps the id opaque; what it costs is stability across replicas and
58
+ // restarts — a principal's page then rescopes, which clears its store, never leaks it.
59
+ processKey = Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
60
+ byte.toString(16).padStart(2, '0'),
61
+ ).join('');
62
+ logger.warn('auth.client_scope.process_key', {
63
+ reason: 'SESSION_SECRET unset or shorter than 32 chars',
64
+ fix: 'export SESSION_SECRET="$(openssl rand -hex 32)"',
65
+ });
66
+ }
67
+ return processKey;
68
+ }
package/src/errors.ts CHANGED
@@ -241,6 +241,19 @@ export const authPolicyNumberInvalid = (
241
241
  meta: { option: key, value: String(value) },
242
242
  });
243
243
 
244
+ /**
245
+ * An explicit key for `clientScopeOf` under the floor `SESSION_SECRET` is held to. The env path
246
+ * already refused a short secret; the option skipped the screen, so `{ secret: 'x' }` keyed every
247
+ * page's scope id with one character. `X_CONFIG_INVALID`, borrowed like the two above.
248
+ */
249
+ export const clientScopeSecretShort = (length: number, minLength: number): AuthError =>
250
+ new AuthError({
251
+ code: 'X_CONFIG_INVALID',
252
+ cause: `clientScopeOf({ secret }) was passed a key of ${String(length)} characters and at least ${String(minLength)} are required, so the scope id would be keyed by a guessable secret`,
253
+ fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
254
+ meta: { option: 'secret', minLength },
255
+ });
256
+
244
257
  export const passwordWeak = (reasons: readonly string[]): AuthError =>
245
258
  new AuthError({
246
259
  code: 'X_PASSWORD_WEAK',
package/src/index.ts CHANGED
@@ -52,6 +52,9 @@ export {
52
52
  } from './auth';
53
53
 
54
54
  export { BuiltinAdapter } from './builtin-adapter';
55
+ /** The opaque per-principal id a per-request document hands the page's client store. */
56
+ export type { ClientScopeOptions } from './client-scope';
57
+ export { clientScopeOf } from './client-scope';
55
58
  export type { AuthUserSummary } from './directory';
56
59
  export { describeUser, findUserByExternalId, listOrgUsers } from './directory';
57
60
  // The one normalisation an address gets before it is an identity key. Public because an app
@@ -316,6 +319,9 @@ export {
316
319
  sessionExpiry,
317
320
  verifySession,
318
321
  } from './session';
322
+ /** A sign-out response's headers: the session cookie expired, and `Clear-Site-Data`. */
323
+ export type { SignOutHeadersOptions } from './sign-out';
324
+ export { SIGN_OUT_CLEAR_SITE_DATA, signOutHeaders } from './sign-out';
319
325
 
320
326
  export {
321
327
  AUTH_TABLE_NAMES,
@@ -0,0 +1,44 @@
1
+ // What a sign-out RESPONSE carries, beside ending the session row (`logout`): the session cookie
2
+ // expired, and `Clear-Site-Data`, so the browser drops everything the previous principal left on
3
+ // the origin — the page store's IndexedDB, local storage, the service worker and its cached pages.
4
+
5
+ import type { SessionPolicy } from './session';
6
+ import { clearSessionCookie } from './session';
7
+
8
+ /**
9
+ * `"cache", "storage"` and never `"cookies"`: the response itself sets the cookie that marks the
10
+ * browser signed out, and `"cookies"` would clear that too. `"storage"` unregisters the service
11
+ * worker and empties Cache Storage — which is the point, since a cached private page is the previous
12
+ * principal's data — so the next load reinstalls the worker and re-precaches. That cost is paid once
13
+ * per sign-out, and it is the trade the framework makes: a PWA's offline cache does not outlive the
14
+ * person it was cached for.
15
+ */
16
+ export const SIGN_OUT_CLEAR_SITE_DATA = '"cache", "storage"';
17
+
18
+ export interface SignOutHeadersOptions {
19
+ /**
20
+ * The framework session's policy, when the app signs out of `@ultimat3/auth`'s own session: the
21
+ * cookie is then expired here. Absent for an app with its own cookie (it sets that itself).
22
+ */
23
+ readonly session?: SessionPolicy | undefined;
24
+ readonly cookieName?: string | undefined;
25
+ }
26
+
27
+ /**
28
+ * The headers a sign-out response appends, in order: `for (const [name, value] of
29
+ * signOutHeaders()) ctx.headers.append(name, value)`. The browser acts on them only for a secure
30
+ * context (HTTPS, or localhost) — which is every deployed app and every `x dev`.
31
+ */
32
+ export function signOutHeaders(options: SignOutHeadersOptions = {}): readonly [string, string][] {
33
+ return [
34
+ ...(options.session === undefined
35
+ ? []
36
+ : [
37
+ ['set-cookie', clearSessionCookie(options.session, options.cookieName)] as [
38
+ string,
39
+ string,
40
+ ],
41
+ ]),
42
+ ['clear-site-data', SIGN_OUT_CLEAR_SITE_DATA],
43
+ ];
44
+ }