Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/rules/admin-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ The web UI is styled with Tailwind v4 + FlyonUI (daisyUI/Preline based). There i

- **Build**: `npm run build:css` compiles `static/css/src/app.tailwind.css` (brand themes, safelists, custom components) into the committed artifact `static/css/flyonui.css`. `static/js/flyonui.js` is vendored from node_modules. Both are regenerated by `copy-static-files.sh` and bundled by pkg. tailwindcss/flyonui/iconify are exact-pinned devDependencies only. After editing templates or `app.tailwind.css`, re-run `npm run build:css` and commit the artifact.
- **Brand**: primary indigo `#4C5CC5`, secondary/accent coral `#F8485E` (from the EmailEngine logo sheet), font PT Sans (self-hosted under `static/fonts/pt-sans/`). Light + dark themes with a topbar toggle (`eeTheme` in localStorage, applied pre-paint by `views/partials/theme_init.hbs`).
- **Component library**: reusable parameterized partials in `views/partials/ui/` (btn, badge, modal, form-field, checkbox, tooltip, tooltip-rich, stat-card, page-header, setting-ref, details, breadcrumb, pagination, menu-*). Build missing primitives there first; never hand-roll per page. Block-form partials must neutralize their own hash params when invoking `{{> @partial-block}}` (and any nested `ui/*` call) - Handlebars hash params fall through into nested partials otherwise.
- **Component library**: reusable parameterized partials in `views/partials/ui/` (btn, badge, modal, form-field, checkbox, tooltip, tooltip-rich, stat-card, page-header, setting-ref, details, breadcrumb, pagination, empty-state, menu-*). Build missing primitives there first; never hand-roll per page. Block-form partials must neutralize their own hash params when invoking `{{> @partial-block}}` (and any nested `ui/*` call) - Handlebars hash params fall through into nested partials otherwise.
- **Partial-block pitfall**: a `{{#> ui/*}}` block whose content is nothing but a `{{#if}}`/`{{#unless}}` wrapper can silently lose its entire content (Handlebars 4.7.9 program-association bug; the inner condition ends up wired across the partial boundary). Hoist the condition outside the partial call (block form in the truthy arm, inline form in the else arm) or make the block's first content token a static element. Bit three pages before being diagnosed: the suppression-list row actions and the account-scoped notes on the templates and tokens empty states.
- **Safelist trap**: class names built in JS strings (`badge-${type}`) or emitted from `lib/`/`workers/` (server-driven icons) are invisible to the Tailwind scanner - add them to the `@source inline(...)` safelists in `app.tailwind.css`.
- **Icons**: Tabler via Iconify classes (`icon-[tabler--...]` on a span). Do not add Font Awesome back. Do not add apexcharts (restrictive license, breaks `npm run licenses`).
- **No jQuery**: the admin UI is jQuery-free. The SSE-updated state badges render through `ui/state-badge` (FlyonUI tooltip carries the error text; `static/js/app.js` repaints badge class/text and toggles the tooltip body), and the TLS cert labels use `ui/tooltip` repainted by `paintCertData()` in `static/js/ui.js`. Do not reintroduce jQuery or `data-toggle` attributes.
- **Slow actions show a busy button**: every POST form on the admin surface latches on its first submit - the pressed button is disabled, its icon becomes a spinner, and any later submit is cancelled (`static/js/ui.js`; the framework-free public pages get the same treatment from `static/js/public.js`). It is automatic, so a new form opts into nothing - a page that owns its own submission (`e.preventDefault()`) is skipped, as is a form aimed at another `target`. A submit button carrying a `name`/`value` (the MCP consent decision) keeps posting it: the value moves into a hidden input before the button is disabled, because the form data set is built after the submit event and skips disabled controls. Action buttons that post with `fetch()` instead of navigating get the same state from `uiBusyAction(elm, run)` - busy for the duration, reset however the action ends, and the busy element is its own re-entrancy guard; it works on buttons and on dropdown `<a>` items. Reach for the lower-level `uiButtonBusy(elm, busy)` only when the busy window is not one promise (the account resume button reloads the page instead of resetting). The busy reset leaves a `dropdown-disabled` item alone: an action that switches its own entry off for good (clearing an account's stored logs empties the download and clear entries) keeps the `aria-disabled` it just set, so such an action still goes through `uiBusyAction`.
- **Escape inside a dialog that contains tabs**: FlyonUI resolves the component handling a keypress from the event target, and a tab strip reports itself as opened while defining no Escape handler - so it swallows the key rather than letting it reach the dialog around it, and a modal with tabs stops closing on Escape as soon as the reader switches tab. `static/js/ui.js` re-dispatches the Escape from the modal element in the capture phase so HSOverlay still drives its own close (closing an overlay from outside strands it: HSOverlay defers parts of open and close to timers and transitionend). The account security page's SSO dialog is the case that found it.
- **Do not run prettier on .hbs files** (it mangles inline scripts), and never use HTML entities inside Handlebars partial hash strings (they double-escape; entities in plain markup are fine).
- **Public pages are framework-free**: everything rendered with `views/layout/public.hbs` (hosted auth form, unsubscribe, redirect, OAuth scope error, generic error pages, service preview) deliberately does NOT use Tailwind/FlyonUI or the `ui/*` partials. They are plain commented HTML with stable `ee-*` classes, styled only by the hand-written `static/css/public.css` (design tokens as CSS custom properties, self-contained Tabler icon masks) with minimal vanilla JS in `static/js/public.js`; modals are native `<dialog>` elements and the split-button dropdown is `<details>/<summary>`. This keeps the pages easy for operators to restyle through the `templateHtmlHead`/`templateHeader` injection settings, so keep them dependency-free - no build step, no utility classes, no shared admin scripts. Flash messages use `views/partials/public_alerts.hbs` (not `alerts.hbs`), and the pre-paint theme script is `public_theme_init.hbs` (not `theme_init.hbs`) - it additionally honors a `?theme=light|dark` query argument (session-sticky) so operators can match the pages to their own application's theme.
46 changes: 39 additions & 7 deletions lib/passkeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ const { REDIS_PREFIX, WEBAUTHN_CHALLENGE_TTL } = require('./consts');

const KEY_PREFIX = `${REDIS_PREFIX}webauthn:`;

// A WebAuthn challenge is a bare nonce, and nothing in the signed response the authenticator
// returns says which ceremony the server minted it for - that binding is the server's job.
// Registration is gated behind a password confirmation and sign-in deliberately is not, so while
// both ceremonies drew from one keyspace a nonce minted by the unauthenticated sign-in endpoint
// could be spent completing a registration, skipping the gate entirely. The purpose is part of the
// key, so each ceremony can only consume what it minted.
const CHALLENGE_REGISTER = 'register';
const CHALLENGE_AUTH = 'auth';
const CHALLENGE_PURPOSES = new Set([CHALLENGE_REGISTER, CHALLENGE_AUTH]);

function assertPurpose(purpose) {
if (!CHALLENGE_PURPOSES.has(purpose)) {
// Every caller passes one of the exported constants, so this is a programming error and
// must not degrade into an unnamespaced key that would restore the bypass
throw new Error(`Unknown WebAuthn challenge purpose: ${purpose}`);
}
}

function challengeKey(challengeId, purpose) {
assertPurpose(purpose);
return `${KEY_PREFIX}challenge:${purpose}:${challengeId}`;
}

// Passkey data does not require encryption at rest. Unlike TOTP seeds or OAuth
// client secrets (which are shared secrets), passkeys use public-key cryptography.
// Only the public key is stored here -- the private key never leaves the
Expand Down Expand Up @@ -90,6 +113,9 @@ function credentialKeys(id, user) {
}

module.exports = {
CHALLENGE_REGISTER,
CHALLENGE_AUTH,

async getRpConfig() {
let serviceUrl = await settings.get('serviceUrl');
if (!serviceUrl) {
Expand All @@ -99,18 +125,20 @@ module.exports = {
return { rpId: url.hostname, origin: url.origin };
},

async storeChallenge(challenge) {
async storeChallenge(challenge, purpose) {
let challengeId = crypto.randomBytes(32).toString('hex');
await redis.set(`${KEY_PREFIX}challenge:${challengeId}`, challenge, 'EX', WEBAUTHN_CHALLENGE_TTL);
await redis.set(challengeKey(challengeId, purpose), challenge, 'EX', WEBAUTHN_CHALLENGE_TTL);
return challengeId;
},

async consumeChallenge(challengeId) {
async consumeChallenge(challengeId, purpose) {
// Before the id check, so a caller that forgot the purpose trips the guardrail whatever
// it passed as an id rather than getting a plausible null back
assertPurpose(purpose);
if (!challengeId || typeof challengeId !== 'string') {
return null;
}
let key = `${KEY_PREFIX}challenge:${challengeId}`;
let challenge = await redis.getdel(key);
let challenge = await redis.getdel(challengeKey(challengeId, purpose));
return challenge || null;
},

Expand Down Expand Up @@ -142,8 +170,12 @@ module.exports = {
return hydrateCredential(data);
},

async updateCounter(credentialId, newCounter) {
await redis.hset(`${KEY_PREFIX}cred:${credentialId}`, 'counter', String(newCounter));
// Called after a verified assertion, so it stamps the sign-in time alongside the
// signature counter. The stamp is what the security page shows as "Last used";
// credentials registered before it existed carry no field and render as unknown
// rather than as never used.
async recordAuthentication(credentialId, newCounter) {
await redis.hset(`${KEY_PREFIX}cred:${credentialId}`, 'counter', String(newCounter), 'lastUsedAt', new Date().toISOString());
},

async listCredentials(user) {
Expand Down
Loading