Prima Get an API key

Module kit / Identity and auth

Aurum login and enrolment

identity-auth/ui/aurum-login-and-enrolmentlive

Kind frontend
Surface server-HTML
Maturity prod
Reusable yes
Design system alchemy-ui
Stack fastify-5, server-html, simplewebauthn-server-13
Depends on _shared/design-system-and-tokens
Renders identity-auth/fastify-passkey-auth-ssh-enrolment
Entry aurum/src/web/auth.ts
Reference implementation
  • aurum/src/web/auth.ts
  • aurum/src/web/webauthn.ts
  • aurum/src/cli/enroll.ts
Measured on aurum week2-3-confirm-gate a17fce3 2026-09-12
Version 1

Purpose

Two server-rendered pages and two bodies around them: the login page with a passkey button first and the scrypt password folded away as break-glass, the one-time enrolment page a mailed-nowhere link opens, the uniform "Nicht gefunden." for a token that is unknown, expired or already used, and the JSON 401 every other path answers without a credential. The invariant that makes them trustworthy is that nothing on the public surface can create a credential: an enrolment link exists only because someone with a shell on the host minted it, the page that consumes it cannot tell the caller which way a bad token is bad, and every failure the browser sees is one sentence.

It makes B22 visible: registerAuth's default-deny hook, loginHtml, the /login password path and its AURUM_PASSWORD_LOGIN policy (src/web/auth.ts), enrollHtml, enrollNotFound and the two ceremonies (src/web/webauthn.ts), and the operator half in src/cli/enroll.ts.

When to use

Trigger phrases: "the Aurum login", "Mit Passkey anmelden", "break-glass password", "Passkey einrichten", "the enrolment link", "Nicht gefunden on the enrol link", "401 unauthorized in the browser", "why is there no register button". Surface: server-HTML, a Fastify 5.10.0 app (aurum/package-lock.json at a17fce3) that returns HTML strings with inline styles and one inline script per page (auth.ts 286 to 331, webauthn.ts 343 to 388); no framework, no stylesheet, no build for the pages. It is server HTML because the tool has one user and one job, and a login page that is a string in the auth module is one fewer thing that can drift from the routes it serves.

Do not use it for the Labs SPA's passkey screens (identity-auth/ui/profile-and-passkeys, F2 territory: multi-RP, many users, React), for a Prima surface (F1, F6, F7 are magic-link first), or for a page a customer sees. Copy it for another founder-only or operator-only tool where SSH is already the trust anchor.

Stack assumptions

S1: none of the source's own; the pages carry one inline style on the column (system-ui, max-width: 22rem or 24rem, centred with 4rem above) and element-level inline declarations and nothing else: six on the login page (the button, p#msg, the details, the form, the input, the form's button, auth.ts 290 to 295) and two on the enrolment page (the button and p#msg, webauthn.ts 349 to 350). The demo is built with @prima-modules/alchemy-ui and @prima-modules/tokens, the manifest default (D1b), which is what a customer's primacode builds this surface with; the rebuild is therefore a design decision the source never made, and the Frontend section says which atoms stand in for which inline rule. Component library at the source: none. Browser-side WebAuthn: hand-written, navigator.credentials.get and .create with two base64url helpers shared as the B64URL_JS string (auth.ts 334 to 337, exported and reused by webauthn.ts 353); the inventory row's simplewebauthn-browser-13 is not a dependency (package.json at a17fce3 lists @simplewebauthn/server 13.3.2 and no browser package), so this recipe's stack names the server package. Data-fetch style: inline fetch with content-type: application/json, same-origin, cookies by default. Copy: de-CH, the four sentences the two scripts write, no ß.

Env read by the pages' server (B22 Stack assumptions): AURUM_PASSWORD_LOGIN (off removes the break-glass block, but only once two passkeys exist, auth.ts 190 to 198), AURUM_PUBLIC_ORIGIN (the RP ID and the expected origin, never the request host, 72 and 278 to 280), AURUM_DASH_PASSWORD_HASH, AURUM_SESSION_SECRET, AURUM_API_TOKEN (boot refuses production without them, 57 to 60).

Data model

Client state: two element texts per page and nothing else. #msg on both pages (in the DOM from first paint with min-height: 1.2em, auth.ts 291, webauthn.ts 350; emptied on click, written on failure); on the login page the <details> open flag the browser keeps. No store, no module state beyond the TOKEN constant the enrolment page embeds (webauthn.ts 354: the path token, filtered to [A-Za-z0-9_-], as a JSON string in the script).

Props of the demo (demo.tsx): { screen: 'login' | 'enrol' | 'not-found' | 'unauthorized' | 'operator'; passwordEnabled?: boolean; breakGlassOpen?: boolean; message?: string; transcript?: string }.

Browser storage: none written by page code. What the server sets: the aurum_session cookie, HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200 (auth.ts 200 to 204), an HMAC-signed {sub, iat, exp} (101 to 106), the same cookie for a password login and a passkey login (AuthContext.issueSession, 165 to 178; webauthn.ts 246 and 326). JavaScript cannot read it. What the browser never holds: the password after the form posts, the enrolment token after the page unloads, a credential's private key (it stays in the authenticator), any session value it could copy.

Lifetimes that shape the pages: the session 12 h (SESSION_TTL_MS, 20); an enrolment token ten minutes and single-use (ENROLLMENT_TOKEN_TTL_MS in the store, enroll.ts 123); a challenge single-use, consumed before verification (webauthn.ts 196 to 202, 277 to 279).

API surface

Contracts the two pages consume, all same-origin (the routes are B22's):

RouteSent bySuccessRefusal and how the page renders it
GET /loginthe browser200 text/html, loginHtml(await passwordLoginEnabled()) (auth.ts 247 to 249)none; public (allow-list 26)
POST /api/webauthn/authenticate/optionspasskeyLogin (304 to 306), body {}200 {challenge, rpId, timeout, userVerification, allowCredentials: []} (webauthn.ts 253 to 263)403 {"error":"bad origin"} (254), 429 {"error":"slow down"} (255): any non-ok is throw new Error('nicht verfuegbar'), caught, rendered as "Anmeldung fehlgeschlagen." (305, 327)
POST /api/webauthn/authenticate/verifythe assertion as JSON (314 to 324)200 {ok: true} and the session cookie (326 to 327); the page sets location.href = '/'401 {"error":"invalid credentials"} for malformed, unknown credential, consumed challenge, counter regression, failed verification (268 to 322); 403, 429 as above; any non-ok is throw new Error('abgelehnt') and the same sentence (325, 327). The browser's own cancel of credentials.get lands in the same catch
POST /loginthe break-glass <form> (292 to 296)200 {ok: true} and the cookie (267 to 269); the form's own navigation shows that JSON401 {"error":"invalid credentials"} for a wrong password, a locked-out address (5 failures per 15 min, 133 to 134) and a disabled password path alike (251 to 266, uniform401); rendered by the browser as the raw body, since it is a form post and not a fetch. See Gotchas for the 415 the form gets at a17fce3
GET /enroll/:tokenthe browser, from the printed link200 text/html, enrollHtml(token) after a 40 ms floor (webauthn.ts 122 to 139)404 text/html <p>Nicht gefunden.</p> for a rate-limited address, an unknown, expired or used token alike (78 to 85, 127 to 136)
POST /enroll/:token/optionsthe enrol script, body {} (359)200 the registration options: rp, user, challenge, pubKeyCredParams, attestation: 'none', authenticatorSelection with resident key and user verification required, excludeCredentials (156 to 169)403 bad origin; 404 {"error":"not found"} for the token cases: throw new Error('link'), rendered "Fehlgeschlagen. Neuen Link per SSH erzeugen." (360, 385)
POST /enroll/:token/verifythe attestation as JSON (372 to 381)200 {ok: true, label} and the cookie; "Passkey registriert. Weiter …" then location.href = '/' (383 to 384)403 bad origin; 404 {"error":"not found"} for malformed, challenge mismatch, attestation failure, unverified, redeem failure (188 to 241): throw new Error('verify'), the same sentence
anything else without a credentiala browser typing /401 application/json {"error":"unauthorized"} (auth.ts 244); no redirect to /login, no pagethe browser shows the body as text

No WebSocket, no postMessage. Error codes rendered as copy: exactly two sentences, "Anmeldung fehlgeschlagen." and "Fehlgeschlagen. Neuen Link per SSH erzeugen.", whatever the cause; the page never distinguishes a network error from a refused assertion from a cancelled prompt, on purpose (B22 Invariants: unknown, expired and used are indistinguishable at every stage, test/passkey.test.ts 260 to 291).

Application logic

Login, GET /login:

  1. The hook lets the request through (allow-list, auth.ts 24 to 37); the handler asks passwordLoginEnabled() and renders the page with or without the break-glass block (247 to 249, 286 to 297).
  2. Click "Mit Passkey anmelden": #msg cleared; POST .../authenticate/options; navigator.credentials.get with the server's challenge, rpId, timeout and user verification, allowCredentials: [] so the authenticator offers its resident keys (301 to 313).
  3. The assertion is serialised with the base64url helpers and posted to .../authenticate/verify; on ok, location.href = '/' (314 to 326).
  4. Any throw, including the user cancelling the prompt, writes "Anmeldung fehlgeschlagen." (327).
  5. Break-glass: open the <details>, type the password, submit; the server answers {ok: true} with the cookie or a uniform 401 (251 to 270). The demo renders the form as the page ships it; Gotchas record what happens to that post at a17fce3.

Enrolment, GET /enroll/:token:

  1. The operator mints the link over SSH: node dist/cli/enroll.js --label "<device>" prints ${AURUM_PUBLIC_ORIGIN}/enroll/<token> once to stdout and stores only the sha256 with the label, the minting shell's identity and a ten-minute expiry, plus an audit row that carries the fact and never the token (enroll.ts 105 to 127). There is no HTTP route that does this (webauthn.ts 8 to 13; test/passkey.test.ts 346).
  2. The browser opens the link on the device to enrol; the server audits the attempt, checks the enrolment limiter, resolves the token to a live row (length 16 to 256, hash lookup, timing-safe compare, not used, not expired, liveToken 108 to 118), pads to the 40 ms floor and renders the page with the token embedded as a script constant (122 to 139). Every failure is the same 404 page.
  3. Click "Passkey erstellen": POST /enroll/<token>/options; navigator.credentials.create with the returned rp, user, challenge, params, attestation: 'none', resident key required, excludeCredentials for already-enrolled ids (356 to 371).
  4. The attestation is posted to /enroll/<token>/verify; the server consumes the challenge bound to this token, verifies against the canonical origin and RP ID with user verification required, then burns the token and inserts the credential in one transaction (redeemEnrollmentToken, 226 to 236), audits passkey_enrolled, issues the session cookie and answers {ok, label}. The page writes "Passkey registriert. Weiter …" and goes to / (383 to 384).
  5. Any failure after the page loaded writes "Fehlgeschlagen. Neuen Link per SSH erzeugen." (385): the token is single-use and may already be burnt, so the way forward is a new link, never a retry.

Invariants

  • No public route mints an enrolment token; the only writer is the CLI over a shell on the host (webauthn.ts 8 to 13, enroll.ts 10 to 12; asserted by test/passkey.test.ts 346 to 365).
  • The enrolment page renders only for a live token, and the three ways a token is dead produce one status, one body, one content type and one latency floor (enrollNotFound 77 to 85; test/passkey.test.ts 260 to 291).
  • Each page's failure copy is one sentence for every cause; the browser learns nothing from it (auth.ts 327, webauthn.ts 385).
  • The break-glass block exists in the HTML only when passwordLoginEnabled() is true, and that is true whenever fewer than two passkeys are enrolled, whatever AURUM_PASSWORD_LOGIN says (auth.ts 190 to 198, 292 to 296; test/passkey.test.ts 301).
  • A passkey login and a password login mint the same cookie through the same function (issueSession, auth.ts 200 to 204; webauthn.ts 246, 326).
  • The RP ID and the expected origin come from AURUM_PUBLIC_ORIGIN, never from Host (auth.ts 278 to 280; webauthn.ts 23 to 24, 209 to 210, 300 to 301).
  • The 401 for a missing credential is JSON, not a page, and there is no redirect to /login (auth.ts 244); a browser at / sees the body.
  • The token never leaves the page except in the two POST paths it is meant for; it is not logged, not in the audit rows, not in the session (enroll.ts 117 to 121; webauthn.ts 342).

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

GET /login: passkey first, the break-glass password folded away login server rendered, hydrated in view

Aurum

Passwort (Break-glass)
GET /login: the details opened, the scrypt password form login-break-glass server rendered, hydrated in view

Aurum

Passwort (Break-glass)
After a refused or cancelled ceremony: one message for every cause login-failed server rendered, hydrated in view

Aurum

Anmeldung fehlgeschlagen.

Passwort (Break-glass)
AURUM_PASSWORD_LOGIN=off with two or more passkeys: no password block at all login-passkey-only server rendered, hydrated in view

Aurum

GET /enroll/:token with a live token: the one-time enrolment page enrol server rendered, hydrated in view

Passkey einrichten

Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab.

Registration verified, the session cookie set, about to leave for / enrol-done server rendered, hydrated in view

Passkey einrichten

Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab.

Passkey registriert. Weiter …

Any failure after the page loaded: the same sentence, the next link is minted over SSH enrol-failed server rendered, hydrated in view

Passkey einrichten

Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab.

Fehlgeschlagen. Neuen Link per SSH erzeugen.

GET /enroll/:token for an unknown, used or expired token: the uniform 404 page not-found server rendered, hydrated in view

Nicht gefunden.

Any other path without a session: the default-deny hook answers JSON, not a page unauthorized server rendered, hydrated in view
{"error":"unauthorized"}
The step before the enrolment page: the link minted over SSH, printed once to stdout and stored only as its sha256 operator-mint server rendered, hydrated in view
$ docker exec -it aurum node dist/cli/enroll.js --label "laptop-beispiel"

Enrolment link (single-use, expires 2026-09-12T10:10:00.000Z, ~10 min):

  https://aurum.example.com/enroll/BEISPIEL-nicht-echt-0000000000000000000000000

Open it in the browser on the device you want to enrol. It is shown once.
recovery: only 1 passkey(s) enrolled - the scrypt password stays ENABLED.
enrol a second authenticator before setting AURUM_PASSWORD_LOGIN=off (it will be refused otherwise).
identity-auth/ui/aurum-login-and-enrolment/demo.tsx
/**
 * Live demo for identity-auth/ui/aurum-login-and-enrolment.
 *
 * Aurum's two auth pages are Fastify-rendered HTML strings with inline
 * styles and one inline script each (`loginHtml` in src/web/auth.ts lines
 * 286 to 331 and `enrollHtml` in src/web/webauthn.ts lines 343 to 388, both
 * at a17fce3 on week2-3-confirm-gate), plus the two bodies a browser meets
 * around them: the uniform 404 page for an unknown, used or expired
 * enrolment token (`enrollNotFound`, webauthn.ts 78 to 85) and the JSON 401
 * the default-deny hook answers on every other path (auth.ts 244). This file
 * rebuilds those four screens from the alchemy-ui atoms (design system
 * `alchemy-ui`, the manifest default: the source is neither a Prima surface
 * nor an Astro site, D1b) and adds the operator step the pages depend on,
 * the enrolment link minted over SSH by src/cli/enroll.ts, rendered as the
 * transcript that command prints (RENDERING.md section 2.2, CLI surfaces).
 *
 * What the browser does on the real pages happens in their inline scripts
 * after a click: `navigator.credentials.get` or `.create`, two fetches, then
 * `location.href = '/'`. None of that runs here. A state is the markup with
 * the message line the script would have written, so every outcome is on
 * the docs page at once and nothing reaches the network.
 *
 * Sample data only: the token in the transcript is obviously not one, the
 * origin is a placeholder, the device label is generic.
 */
import React from 'react';
import { defineStates } from '@prima-modules/demo';
import { Button, Heading, Input, Text } from '@prima-modules/alchemy-ui';

/* ---- props ----------------------------------------------------------- */

export type Screen = 'login' | 'enrol' | 'not-found' | 'unauthorized' | 'operator';

export interface Props {
  screen: Screen;
  /** `loginHtml(passwordEnabled)`: the break-glass block exists only when true. */
  passwordEnabled?: boolean;
  /** The `<details>` around the password form, open or closed. */
  breakGlassOpen?: boolean;
  /** The `#msg` line the inline script writes after a ceremony. */
  message?: string;
  /** The transcript of `node dist/cli/enroll.js --label <device>`. */
  transcript?: string;
}

/* ---- sample data ------------------------------------------------------ */

const ORIGIN = 'https://aurum.example.com';
/* Not a token. The real one is 32 random bytes as base64url (43 characters). */
const TOKEN = 'BEISPIEL-nicht-echt-0000000000000000000000000';

const mintTranscript = `$ docker exec -it aurum node dist/cli/enroll.js --label "laptop-beispiel"

Enrolment link (single-use, expires 2026-09-12T10:10:00.000Z, ~10 min):

  ${ORIGIN}/enroll/${TOKEN}

Open it in the browser on the device you want to enrol. It is shown once.
recovery: only 1 passkey(s) enrolled - the scrypt password stays ENABLED.
enrol a second authenticator before setting AURUM_PASSWORD_LOGIN=off (it will be refused otherwise).`;

export const states = defineStates<Props>({
  login: { label: 'GET /login: passkey first, the break-glass password folded away', props: { screen: 'login', passwordEnabled: true }, height: 360 },
  'login-break-glass': { label: 'GET /login: the details opened, the scrypt password form', props: { screen: 'login', passwordEnabled: true, breakGlassOpen: true }, height: 480 },
  'login-failed': { label: 'After a refused or cancelled ceremony: one message for every cause', props: { screen: 'login', passwordEnabled: true, message: 'Anmeldung fehlgeschlagen.' }, height: 360 },
  'login-passkey-only': { label: 'AURUM_PASSWORD_LOGIN=off with two or more passkeys: no password block at all', props: { screen: 'login', passwordEnabled: false }, height: 300 },
  enrol: { label: 'GET /enroll/:token with a live token: the one-time enrolment page', props: { screen: 'enrol' }, height: 360 },
  'enrol-done': { label: 'Registration verified, the session cookie set, about to leave for /', props: { screen: 'enrol', message: 'Passkey registriert. Weiter …' }, height: 360 },
  'enrol-failed': { label: 'Any failure after the page loaded: the same sentence, the next link is minted over SSH', props: { screen: 'enrol', message: 'Fehlgeschlagen. Neuen Link per SSH erzeugen.' }, height: 360 },
  'not-found': { label: 'GET /enroll/:token for an unknown, used or expired token: the uniform 404 page', props: { screen: 'not-found' }, height: 240 },
  unauthorized: { label: 'Any other path without a session: the default-deny hook answers JSON, not a page', props: { screen: 'unauthorized' }, height: 240 },
  'operator-mint': { label: 'The step before the enrolment page: the link minted over SSH, printed once to stdout and stored only as its sha256', props: { screen: 'operator', transcript: mintTranscript }, height: 400 },
});

/* ---- the screens ------------------------------------------------------ */

/* The source wraps each page in a 22rem (login) or 24rem (enrol) column with
   4rem above it (auth.ts 288, webauthn.ts 346). */
function Column({ width, children }: { width: 'narrow' | 'wide'; children: React.ReactNode }) {
  return (
    <div className={`${width === 'wide' ? 'max-w-[24rem]' : 'max-w-[22rem]'} mx-auto py-10 font-sans`}>
      {children}
    </div>
  );
}

function Message({ text, tone }: { text?: string; tone: 'error' | 'neutral' }) {
  /* The source's `p#msg` is in the DOM from first paint with min-height 1.2em,
     so the page does not jump when the script writes it (auth.ts 291,
     webauthn.ts 350). The demo keeps the geometry and drops the id: the docs
     page renders every state in one document, and seven `id="msg"` would be
     invalid HTML. `role="status"` is set only when a sentence is shown, so
     the empty line is not a live region. */
  return (
    <p data-msg className={`min-h-[1.2em] mt-3 text-sm font-body ${tone === 'error' ? 'text-red-700' : 'text-ink'}`} role={text ? 'status' : undefined}>
      {text ?? ''}
    </p>
  );
}

function Login({ passwordEnabled, breakGlassOpen, message }: Props) {
  return (
    <Column width="narrow">
      <Heading level={1}>Aurum</Heading>
      <div className="mt-4">
        <Button variant="primary" fullWidth data-pk>Mit Passkey anmelden</Button>
      </div>
      <Message text={message} tone="error" />
      {passwordEnabled && (
        <details className="mt-4 group" open={breakGlassOpen || undefined}>
          <summary className="cursor-pointer text-sm font-body text-ink-60 hover:text-ink">Passwort (Break-glass)</summary>
          <form method="post" action="/login" className="mt-2 flex flex-col gap-2">
            <Input type="password" name="password" placeholder="Passwort" inputSize="sm" />
            <div>
              <Button variant="outline" size="sm" type="submit">Anmelden</Button>
            </div>
          </form>
        </details>
      )}
    </Column>
  );
}

function Enrol({ message }: Props) {
  return (
    <Column width="wide">
      <Heading level={1}>Passkey einrichten</Heading>
      {/* The source's lede is a plain <p> in the default colour (webauthn.ts
          348), so the Text atom is used without its muted variant. */}
      <Text className="mt-2">Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab.</Text>
      <div className="mt-4">
        <Button variant="primary" fullWidth data-go>Passkey erstellen</Button>
      </div>
      <Message text={message} tone="neutral" />
    </Column>
  );
}

/* The two bodies have no column of their own: `enrollNotFound` sends a bare
   `<p>Nicht gefunden.</p>` (webauthn.ts 82) and the 401 is a JSON body a
   browser shows top-left as preformatted text (auth.ts 244). `p-2` stands in
   for the 8px the browser puts around a bare body; nothing centres them. */
function NotFound() {
  return (
    <div className="font-sans p-2">
      <Text>Nicht gefunden.</Text>
    </div>
  );
}

function Unauthorized() {
  return (
    <div className="font-sans p-2">
      <pre className="font-mono text-sm text-ink whitespace-pre-wrap">{'{"error":"unauthorized"}'}</pre>
    </div>
  );
}

function Operator({ transcript }: Props) {
  /* The dark surface is a plain div, not the Card atom: Card paints its own
     `bg-white` (variant default) and Tailwind emits `.bg-white` after
     `.bg-ink` in the bundle, so a `bg-ink` passed through `className` loses
     whatever the class order (measured 2026-09-12 on the built docs page,
     recipe Gotchas). The frame is the transcript and nothing else; the
     commentary lives in the state label and the recipe. `break-all` on the
     pre: the link is one unbreakable run of origin plus token, and the
     wrapper hides overflow, so without it the token is clipped below about
     26rem (measured 2026-09-12 at a 400px viewport: 50px cut off). */
  return (
    <div className="font-sans">
      <div className="rounded-3xl overflow-hidden bg-ink text-paper" data-transcript>
        <pre className="font-mono text-[13px] leading-relaxed whitespace-pre-wrap break-all p-6 text-paper">{transcript ?? ''}</pre>
      </div>
    </div>
  );
}

export default function Demo(props: Props) {
  switch (props.screen) {
    case 'login':
      return <Login {...props} />;
    case 'enrol':
      return <Enrol {...props} />;
    case 'not-found':
      return <NotFound />;
    case 'unauthorized':
      return <Unauthorized />;
    case 'operator':
      return <Operator {...props} />;
    default:
      return null;
  }
}
Component tree, from the recipe
GET /login                      auth.ts loginHtml(passwordEnabled)         (public route, allow-list line 26)
  div (system-ui, max-width 22rem, centred, 4rem above)
    h1 "Aurum"
    button#pk "Mit Passkey anmelden"                                       click -> POST /api/webauthn/authenticate/options -> navigator.credentials.get -> POST .../verify -> location.href = '/'
    p#msg (min-height 1.2em; "Anmeldung fehlgeschlagen." on any failure)
    details > summary "Passwort (Break-glass)"                               only when passwordEnabled
      form[method=post][action=/login] > input[type=password][name=password], button "Anmelden"
  <script> B64URL_JS + passkeyLogin
GET /enroll/:token              webauthn.ts enrollHtml(token)               (public prefix, allow-list line 34; a live token, else the 404 page)
  div (system-ui, max-width 24rem)
    h1 "Passkey einrichten"
    p "Dieser Link ist einmalig gültig und läuft in wenigen Minuten ab."
    button#go "Passkey erstellen"                                           click -> POST /enroll/:token/options -> navigator.credentials.create -> POST /enroll/:token/verify -> "Passkey registriert. Weiter …" -> location.href = '/'
    p#msg ("Fehlgeschlagen. Neuen Link per SSH erzeugen." on any failure)
  <script> B64URL_JS + TOKEN constant + the ceremony
GET /enroll/:token (unknown | expired | used)   webauthn.ts enrollNotFound: 404 text/html "<p>Nicht gefunden.</p>" after a 40 ms floor
any other path without a credential          auth.ts onRequest hook: 401 application/json {"error":"unauthorized"}; no HTML page
operator step (not a page)                   cli/enroll.ts --label <device>: the link printed once to stdout, ten minutes, single-use
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild aurum-login-and-enrolment/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state (RENDERING.md, section 3). The source pages are strings with inline styles; the demo rebuilds their structure with the atoms (Heading level 1 for the h1, Button primary fullWidth for the one wide button, Input inputSize="sm" with type="password" and Button outline sm for the break-glass form, Text in its default colour for the lede, as the source's plain <p> with no colour of its own (webauthn.ts 348), and for the 404 sentence; for the operator transcript a plain div with bg-ink and text-paper around a pre, not the Card molecule, because every Card variant paints a background of its own that a class passed through className cannot beat, Gotchas 2026-09-12) and keeps the source's geometry in two Tailwind classes (max-w-[22rem] and max-w-[24rem] with mx-auto py-10, the centred 22rem column with 4rem above of auth.ts 288 and the 24rem of webauthn.ts 346). The message line keeps the source's min-h-[1.2em] but not its id (data-msg instead; the source's #pk and #go are data-pk and data-go for the same reason, Gotchas 2026-09-12), and carries role="status", which the source lacks, only in the three states that show a sentence. The break-glass input is as the page ships it (auth.ts 294: type, name, placeholder, no autocomplete attribute). The two bodies, not-found and unauthorized, have no column: the 404 is a bare <p> (webauthn.ts 82) and the 401 is JSON a browser shows top-left, so the demo renders them at the frame's top-left with p-2 for the 8px the browser puts around a bare body. Hydration is visible for every state: the demo has no handlers, portals or measurements, so the server-rendered markup is the state.

States, in order: login (passkey first, the details closed), login-break-glass (the details open: the password input and "Anmelden"), login-failed (the one sentence in #msg, red), login-passkey-only (passwordEnabled: false: no details at all, which is the page once two passkeys exist and the switch is off), enrol (the one-time page for a live token), enrol-done ("Passkey registriert. Weiter …", the instant before location.href), enrol-failed ("Fehlgeschlagen. Neuen Link per SSH erzeugen."), not-found (the 404 page: one sentence), unauthorized (the JSON body of the default-deny hook, as a browser shows it), operator-mint (the CLI transcript with a placeholder origin and an obviously fake token, rendered as a dark block the way the docs site's terminal block renders a transcript, RENDERING.md section 2.2 for CLI surfaces; the frame carries the transcript and nothing else, the fact that only the token's sha256 is stored is in the state label and in Application logic step 6, not printed inside the frame, since enroll.ts 121 to 126 prints only the link block and the recovery note).

Tokens used: bg-brand-purple through Button primary, text-ink, text-ink-60, text-red-700 for the failed-login sentence (the source's inline color:#a00), bg-ink and text-paper on the transcript block (the plain div, measured as rgb(27, 23, 38) on the built docs page, Acceptance check 6), font-sans and font-mono. No viewport: nothing here is shell-wrapped; the source centres a narrow column on a bare page and the frame does the same. Responsive: the columns are max-width only and shrink with the frame; below 22 rem the button and the input are full width already.

Empty, loading, error states: the pages have no loading state (a click either resolves to a navigation or to the sentence) and no empty state; error is the sentence. What the demo deliberately does not render: the inline scripts and the ceremonies (nothing here calls navigator.credentials or fetch), the base64url helpers, the navigation to / after success, and the details' native marker styling. The copy is the pages' own; the transcript's recovery note uses a hyphen where enroll.ts 139 prints an em dash (house rule).

Security & tenancy

What the browser may hold: the HttpOnly session cookie it cannot read, and, on the enrolment page, the token as a script constant and in the address bar for the life of that page. Never a password beyond the form post, never a key: the credential's private half is in the authenticator and the server stores the public key and a counter (webauthn.ts 228 to 235).

Origin and CSRF: the cookie is SameSite=Strict (auth.ts 203); a cookie-authenticated non-GET must carry Origin equal to AURUM_PUBLIC_ORIGIN (237 to 240), and the four unauthenticated ceremony routes check the same header before anything else (originOk, webauthn.ts 87 to 91, 143, 174, 254, 266; test/passkey.test.ts 216). The CSP the server sends on every HTML response is default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none' (auth.ts 213 to 214): the inline script and the inline styles are why the two 'unsafe-inline' are there.

Surface gating: one surface, one user (USER_ID is the literal aurum-founder, webauthn.ts 41), no /me, no tenant. What is hidden versus enforced: the break-glass block is not rendered when the password path is off, and the server refuses the post with the same 401 as a wrong password when it is off, so hiding and enforcing agree (auth.ts 254 to 259). Nothing is hidden by CSS.

Attacker-controlled inputs rendered: the token, filtered to [A-Za-z0-9_-] before it is embedded as a JSON string (webauthn.ts 344, 354); nothing else from a request reaches a page.

Tests

  • aurum at a17fce3: npm test (node --import tsx --test test/*.test.ts) includes test/passkey.test.ts (365 lines) with the cases this surface rests on: enrol then login mints the existing cookie (113), expired token rejected (142), single-use token (149), replayed registration (159), two concurrent redemptions yield one success (176), wrong-origin assertion (202), cross-origin POST refused before the ceremony (216), counter regression (227, 248), unknown, expired and used indistinguishable (260), the scrypt path (293), AURUM_PASSWORD_LOGIN=off refused with fewer than two passkeys (301), the default-deny gate (323), the enrolment limiter (329), no HTTP handler mints a token (346). B22's Tests section records which of these it ran.
  • The demo's colocated test, aurum-login-and-enrolment/demo.test.tsx (eight cases, passed 8 of 8 on 2026-09-12 with npx vitest run --config modules/identity-auth/ui/aurum-login-and-enrolment/vitest.config.mjs from the repo root): every state renders non-empty markup; the password input exists only when passwordEnabled; the details are open only in login-break-glass; a failed login is one sentence and none of the script's internal reasons; the enrolment states and the 404 carry no token in their markup while the transcript does; the 401 is the JSON body; no state's markup carries an element id and role="status" is present in exactly the three states that show a sentence (the case added after the duplicate-id finding in Gotchas); the transcript's pre sits in an element whose only bg-* class is bg-ink, the state's markup carries no bg-white, and the frame's text is the transcript and nothing else (the case added after the paper-on-white rendering in Gotchas; markup cannot prove a computed colour, so the check is that no competing background class is present to win, and the computed colour is Acceptance check 6). The root vitest.config.mjs includes packages/*/test and test/ only, so the test has a config beside it until the root gains the demo glob (a deviation recorded for this wave).
  • The kit's render smoke: node tooling/verify/render-smoke.mjs identity-auth/ui/aurum-login-and-enrolment renders the ten states with renderToString (all hydrate: visible).
  • Playwright against a running Aurum: not part of the source's suite at a17fce3 (the passkey tests drive app.inject with a software authenticator, test/webauthn-authenticator.ts); a real-browser walk-through is Acceptance check 5.

Deploy & ops

Build target: none for the pages; they ship inside the app's image (B22 Deploy & ops: the container on the estate; the process binds 0.0.0.0 on PORT 8787 inside the container, app.listen({ port: PORT, host: '0.0.0.0' }) at server.ts 286, and it is the compose port mapping, deploy/compose.yml 37 to 38 ("127.0.0.1:8787:8787"), that publishes it on 127.0.0.1:8787, where the host's Caddy's reverse_proxy 127.0.0.1:8787 reaches it, deploy/Caddyfile.fragment 48, 55, 60 and 72). Env baked at build: none; everything is read at boot. CSP: the server's own onSend header (Security & tenancy), plus Caddy's Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and -Server on the vhost (deploy/Caddyfile.fragment 33 to 39). CORS: none; the pages are same-origin and no other origin calls these routes. Deploy path: S13's estate container (B200's ship.sh); rollback is the image tag; rotating AURUM_SESSION_SECRET signs everyone out. Argus checks that see these pages: GET /login 200 containing Mit Passkey anmelden, GET / without a credential 401 {"error":"unauthorized"} (B22 Deploy & ops).

Gotchas

  • 2026-09-12 (measured at a17fce3, auth.ts 292 to 296; the defect is B22's first Gotcha, D8): the break-glass block is a plain <form method="post" action="/login">, which posts application/x-www-form-urlencoded; the server registers no parser for that type and Fastify answers 415 before the handler runs, so the form as rendered cannot sign anyone in. The demo's login-break-glass state shows the form as the page ships it. Fix it needs: @fastify/formbody, or a fetch posting JSON from the page, and a test that posts the urlencoded body the page sends.
  • 2026-09-12 (measured at a17fce3, deploy/Caddyfile.fragment 7 to 19 and 63 to 75; D8, recorded, not fixed): the tracked vhost fragment predates app-layer auth. Its header says server.ts "has NO authentication of any kind", and its catch-all handle wraps reverse_proxy in basic_auth, with public handle blocks only for /telegram/webhook*, /u/* and /health (40 to 60). Applied as written, /login, /enroll/* and /api/webauthn/authenticate/* sit behind Caddy's basic-auth prompt, so the passkey page is reachable only after a password. Whether the live vhost still matches the fragment was not measured (the estate is not touched from here). Fix it needs: public handle blocks for the three paths, or dropping basic_auth now that auth.ts is the boundary, and deleting the stale header.
  • 2026-09-12 (measured at a17fce3, auth.ts 244): the default-deny hook answers JSON with no redirect, so a person who opens / in a browser sees {"error":"unauthorized"} and has to know /login exists; nothing links to it. The demo's unauthorized state is that body. A surface with more than one user redirects a browser (Accept: text/html) to /login and keeps the JSON for everything else.
  • 2026-09-12 (measured at a17fce3, auth.ts 327, webauthn.ts 385): the failure sentences absorb the user's own cancel of the WebAuthn prompt, which is not a failure of anything. A rebuild may keep one sentence for server refusals and say "Abgebrochen." for NotAllowedError, which reveals nothing about the server.
  • 2026-09-12 (measured at a17fce3, webauthn.ts 354 and enroll.ts 124): the token is in the URL and in the page's script; a browser extension or a shoulder can read it while the page is open, and the ten-minute, single-use, SSH-minted design is what makes that acceptable. The demo shows a token only in the operator transcript, and its colocated test asserts the enrolment states carry none.
  • 2026-09-12 (rendering trap): the source has no design system, so the rebuild in alchemy-ui is not a pixel match and cannot be; the verifier's mismatch_ratio is not a meaningful tolerance for this row (Acceptance check 6). What the rebuild must match is the structure and the copy, which the colocated test pins.
  • 2026-09-12 (rendering trap, measured on the docs page built from prima.li at 86fe9d1 with this kit at aab1a3f): the operator-mint transcript rendered paper-coloured text on a white surface, invisible, because the demo passed bg-ink text-paper through className to the Card molecule. Card paints bg-white for variant="default" (packages/alchemy-ui/src/molecules/Card.tsx 22 to 26, verbatim from Labs fd8558f), and the bundle emits #alchemy-ui .bg-white after #alchemy-ui .bg-ink at equal specificity (packages/alchemy-ui/dist/alchemy-ui.css), so the atom's own background wins whatever order the classes have in the attribute: Tailwind orders utilities by config order, not by class order, and className on an atom cannot override a bg-*, text-* or outline-* the atom sets itself. Fixed the same day by rendering the dark surface as a plain div (rounded-3xl overflow-hidden bg-ink text-paper) with no atom underneath and a colocated case that fails on a competing bg-* class; the sibling profile-and-passkeys/demo.tsx (333, 338) takes the other way out, the important modifier !bg-green-50, which is what to use when the atom itself must stay.
  • 2026-09-12 (rendering trap, measured on the docs page built from prima.li at 86fe9d1 with this kit at ed56378): the demo had kept the source's element ids (#msg, #pk, #go, auth.ts 290 to 291, webauthn.ts 349 to 350) and put role="status" aria-live="polite" on every message line, so the one docs document carried seven id="msg", four id="pk", three id="go" and seven live regions. A demo renders beside nine sibling frames, never alone: it must not carry an id the source has, and a live region belongs only on a state that shows something. Fixed the same day (data-msg, data-pk, data-go; role="status" only with a sentence) with a colocated case that fails on any id=" in any state.
  • 2026-09-12 (rendering trap, measured on the same build with Playwright at a 400px viewport): the operator-mint transcript clipped the enrolment link, 50px cut off at 400 and 90px at 360, because the wrapper is overflow-hidden and the link is one unbreakable run of origin plus token that whitespace-pre-wrap cannot break. Fixed the same day with break-all on the pre (the class is in the committed bundle); at 768 and above the link fitted before and after.
  • 2026-09-12 (rendering trap): <details open> in React is the boolean open prop; the demo passes open={breakGlassOpen || undefined} so the closed state emits no attribute and the server-rendered markup hydrates without a mismatch.
  • 2026-09-12 (measured at a17fce3): the working tree carries one uncommitted line in auth.ts (POST /api/unsubscribe on the allow-list, line 30), so every auth.ts line number in this recipe is the committed file's (git show a17fce3:src/web/auth.ts), one less than the working copy from line 30 on; the same note is in B22.
  • 2026-07-28 (0203908, enroll.ts 133 to 141; wiki platform/aurum.md): passkeys landed with the rule that the password stays until two passkeys exist; recoveryNote prints the posture after every CLI action, and the demo's transcript shows the one-passkey posture.

Reference implementation

Branch: aurum week2-3-confirm-gate at a17fce3 (2026-09-12). Paths relative to the repo root:

  • src/web/auth.ts (341 lines at a17fce3): the hook's design 1 to 15, SESSION_TTL_MS and the cookie name 20 to 21, PUBLIC_ROUTES 24 to 37, loadConfig 46 to 74, password hashing 76 to 98, cookie signing 100 to 129, brute-force protection 131 to 146, AuthContext 165 to 178, registerAuth 180 to 275 (passwordLoginEnabled 190 to 198, issueSession 200 to 204, security headers and CSP 207 to 217, the hook 220 to 245, GET /login 247 to 249, POST /login 251 to 270), rpIdFrom 278 to 280, loginHtml 282 to 331, B64URL_JS 334 to 337, uniform401 339 to 341.
  • src/web/webauthn.ts (388 lines): the design constraint 1 to 25, constants 40 to 53, enrollNotFound 77 to 85, originOk 87 to 91, registerWebAuthn 100 to 331 (liveToken 108 to 118, GET /enroll/:token 122 to 139, options 141 to 170, verify 172 to 249, authenticate options 253 to 263, authenticate verify 265 to 328), readClientChallenge 333 to 340, enrollHtml 342 to 388.
  • src/cli/enroll.ts (143 lines): why the CLI is the only minter 1 to 17, usage 26 to 33, shellIdentity 35 to 44, list 61 to 81, revoke 83 to 95, burn 97 to 103, mint 105 to 127, recoveryNote 133 to 141.
  • src/web/server.ts: registerAuth 73 to 79, registerWebAuthn 80, listen 286.
  • deploy/Caddyfile.fragment: the vhost, headers 33 to 39, public handles 40 to 60, the catch-all with basic_auth 63 to 75.
  • test/passkey.test.ts (365 lines): the cases listed under Tests; test/webauthn-authenticator.ts, the software authenticator they use.

Deviations of the reference from this recipe, dated 2026-09-12: the urlencoded break-glass post; the stale Caddy fragment; the JSON 401 with no link to /login; no browser-level test; the stack pin simplewebauthn-browser-13 in the inventory row, which is not a dependency.

Acceptance checks

From aurum at a17fce3, or a new tool built to this recipe:

  1. npm test passes, and grep -c "^test(" test/passkey.test.ts prints 15 (or the new tool's count).
  2. grep -n "app\.\(get\|post\|put\|delete\)\(<[^>]*>\)\?(.*enroll" src/web/webauthn.ts prints exactly three lines, 122, 141 and 172 (GET /enroll/:token, POST /enroll/:token/options, POST /enroll/:token/verify; the optional group is the Fastify route generic <{ Params: { token: string } }> that sits between the method and the paren on all three, so a pattern without it prints nothing and proves nothing); the guard that no route mints a token is the second command: grep -rn "createEnrollmentToken" src/ | grep -v "db/" prints src/cli/enroll.ts only (line 115 at a17fce3).
  3. grep -n "'unsafe-inline'" src/web/auth.ts prints one line, 214, carrying the directive twice (style and script), which is the price of inline pages; a rebuild that moves the script to a file drops the second.
  4. grep -c "simplewebauthn/browser" package.json prints 0; grep -n "B64URL_JS" src/web/auth.ts src/web/webauthn.ts prints the export and the two uses.
  5. Browser walk-through against a local Aurum (npm run dev, AURUM_PUBLIC_ORIGIN=http://localhost:8787, an authenticator or a virtual one in DevTools): (a) open /, observe {"error":"unauthorized"} as text; (b) open /login, observe "Mit Passkey anmelden" first and "Passwort (Break-glass)" folded; click the button and cancel the prompt, observe "Anmeldung fehlgeschlagen."; (c) npm run enroll -- --label test-device, open the printed link, observe "Passkey einrichten", click "Passkey erstellen", complete the ceremony, observe "Passkey registriert. Weiter …" and the dashboard at /; (d) open the same link again, observe "Nicht gefunden." with status 404; (e) npm run enroll -- --list, observe the device and the recovery note; (f) AURUM_PASSWORD_LOGIN=off with one passkey, restart, observe the break-glass block still on /login and the refusal in the log; with two passkeys, observe it gone.
  6. Demo gate: npm run check prints no line for this recipe; node tooling/verify/render-smoke.mjs identity-auth/ui/aurum-login-and-enrolment prints ok for the ten states and 0 failures; npx vitest run --config modules/identity-auth/ui/aurum-login-and-enrolment/vitest.config.mjs prints 8 passed; in the prima.li checkout, PRIMA_MODULES_DIR=<this repo> npm run build:index writes dist/docs/modules/identity-auth/ui/aurum-login-and-enrolment/index.html, and opened in Playwright the page has ten figure[data-demo-state] elements, each with a non-empty .demo-stage, pageerror count 0, and in the operator-mint figure the pre's parent has computed background-color rgb(27, 23, 38) (--color-ink) and the pre computed color rgb(251, 250, 252) (--color-paper) (verified 2026-09-12 against prima.li at 86fe9d1 after the Gotcha of that date; before the fix the same probe read rgb(255, 255, 255)).
  7. Verifier: compare.mjs needs an --app that serves Aurum, which the harness does not boot (its two scripts are for Labs and for Astro sites); a run against a local Aurum on http://127.0.0.1:8787 with --state login is possible with rendering.verify set to route: /login, session: anon, selector: body, and its report is read for page_errors: [] on both sides only: the source has no design system, so mismatch_ratio carries no tolerance for this row.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.