Self-hosters can sign in to the iOS app through their own domain, so the
password manager files credentials under the right site and passkeys run in
the correct origin. The app opens the instance's own web login inside an
ASWebAuthenticationSession; after an interactive login the server mints a
single-use, PKCE-locked handoff code on a compiled-in custom scheme, which the
app exchanges for the standard native token bundle. The token pair never rides
a URL — only the opaque code does.
Three endpoints mirror the OIDC native handoff and share its mint/consume core,
now extracted to native-handoff.ts (the OIDC leg keeps working unchanged, as
the default flow):
- GET /api/auth/native/login validates the PKCE challenge, sets a
short-lived encrypted state cookie, and
redirects to /auth/login?flow=native.
- GET /api/auth/native/complete validates the web session, enforces the
freshness binding, mints the code, and
redirects to healthlog://login-callback.
- POST /api/auth/native/token exchanges the code under the cookie-less
native transport gate for the native bundle.
The freshness binding refuses any session older than the flow start, so a
pre-existing browser session can never silently complete a handoff. The start
time is stamped from the database clock (SELECT now()) to stay comparable to
Session.createdAt with no app/DB skew, and createdAt is read through a
dedicated projection so the comparison cannot be silently dropped. A flow
discriminator on the handoff row (migration 0268) makes an OIDC code
non-redeemable at the web-login route and vice versa.
The callback scheme is a compile-time constant with no redirect_uri parameter,
requireAdmin and requireFreshMfa stay cookie-only, and the exchange mints no
wider scope than password login. The state cookie is AES-256-GCM with a fresh
IV. Adds the flow=native login-page banner in all six locales and the three
routes to the OpenAPI contract.
Merge the eight reviewed audit fixes that were cut a week ago and never
landed. Main has moved on since, so each fix was re-checked against the
current tree and each of the five conflicts resolved by understanding
both sides.
Kept, still open on main:
- Proxy early exits now carry the transport security header subset via a
shared helper. Reconciled with main's /api/health worker-only carve-out.
- Batch measurement ingest bounds the entry instant so a future-dated row
can no longer win "latest reading" or poison the rollup buckets.
- Web Push dials through the pinned safeFetch dispatcher with
requirePublicHost, closing the last user-controlled-host egress.
- Mood bulk upsert and its tag/factor links run in one transaction, so a
rejected rating rolls the mood row back instead of leaving a tagless row.
- Full backup carries NutrientIntakeDay with its unit and source. Folded
into main's portable-export / disaster-recovery split.
- Live daily-series fallback restores the min/max spread band the tiered
read already emitted.
- Coach stream aborts its in-flight reader on unmount and resets send state
on a conversation switch.
- Manual water writes share the day-bound helper, nutrient add clamps after
the increment, and the thiamin cap drops to a real supplement ceiling.
- Compliance point window widens symmetrically instead of wrapping overnight.
- Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx
body as a handled transient error.
- WebAuthn relying-party fallback to localhost is gated to development.
- Chart-token strip list is derived from the allowlist and pinned by a test.
- Regression tests pin the snapshot-cache tenant key, the GlitchTip
path-secret redaction and the Withings credentials delete.
Superseded by main, tests re-pointed at the current shape:
- The intake CSV import was rewritten on main to run through a background
job with a single-active-job admission gate. The future-date bound is
re-applied at request validation and the per-user limiter is kept for
parity with the bulk twin; the old synchronous-write test is replaced.
A browser SSO sign-in landed on /auth/login?error=oidc_failed even though it
had succeeded. Two layers conspired:
- The service worker enables navigation preload, but its fetch handler returned
early for /api/ paths without calling event.respondWith(). For the OIDC
login/callback navigations that left the preload unconsumed, so the browser
also ran its own default fetch and the callback was requested twice. The
first request redeemed the single-use authorization code and set the session;
the second tried to redeem the now-consumed code, failed at the IdP token
endpoint, and that 307 to the error page is what the user saw.
- Even with the double request gone, a legitimate retry (proxy, double
navigation) hit the same wall on the server.
Fix both layers:
- The worker settles /api/auth/* navigations itself, consuming the preload
response (or fetching once when there is none), so exactly one request
reaches the server. These responses are never cached.
- The callback is idempotent. When the single-use state cookie is already
consumed but the request carries a database-validated, unexpired session, the
callback treats it as a duplicate of a completed login and redirects into the
app instead of surfacing an error. The authorization code is never exchanged
on this branch, and a consumed/forged/expired state from a caller with no
valid session still fails closed with the unchanged oidc_state error.
Refs #600
Renumber the elevation migration from 0260 to 0267 — main claimed 0260
for an unrelated table while this branch sat unmerged, and 0266 is now
the highest committed number.
Two loose ends the merge itself did not touch: the schema comment on
StepUpElevation.method still called it "audit/telemetry only, never an
authorisation input" — the exact claim the fresh-factor fix (a prior
commit on this branch) made false, since the method is now precisely
what decides whether an elevation reaches the destructive MFA routes.
Reworded to match. And pruneExpiredStepUpElevations in step-up.ts was
dead code: the actual sweep runs in cleanup-handlers.ts against the
worker Prisma client for reasons that function could never satisfy
against the request-scoped one. Removed rather than left to drift.
A step-up elevation minted from the account password satisfied the fresh-factor
routes. That is a second-factor bypass: a stolen token plus a known password
could rotate the recovery codes — which take no factor in the body — and then
spend one of them to disable the second factor outright. The web has never
allowed that chain, because a password login does not stamp mfaVerifiedAt.
The method is now an authorisation input. Only totp, webauthn, and passkey
satisfy a fresh-factor gate, which is exactly the set of ceremonies the web
stamps a session for; password reaches what a plain cookie session reaches and
stops. The mint gains TOTP and security-key arms so a TOTP-only account can
still manage its factor natively, and the response says which reach it bought.
The gate also returns a discriminated context. session.id meant a session row on
one arm and a token row on the other, and the disable route passed the token id
to a session-scoped query — excluding nothing, so it deleted every browser
session and revoked the caller's own device login. The transport is now in the
type and the compiler refuses the confusion.
Redemption splits into validate-then-commit so a 429, a 422, or a wrong code no
longer burns a proof against a five-per-fifteen-minute mint ceiling. The claim
is UPDATE ... RETURNING, so the statement that decides is the one that reports.
The mint is transactional behind a partial unique index. Sign-out-everywhere and
both password-reset paths clear elevations. The status read drops to plain
requireAuth — it carries no credential material — and every mutation stays gated.
The water quick-add validated calendar realism but never the upper day bound the
batch route applies, so a far-future day was accepted and — every read filters
`day >= since` with no ceiling — became the permanent latest day on the settings
card and the dashboard tile. Hoist the bound into one shared helper both routes
use.
`mode: "add"` bounded a single request to the catalog's plausible daily max but
nothing across requests, so repeated quick-adds drove the stored total
arbitrarily high while the batch route enforced the same cap strictly. Clamp
after the increment so the write stays atomic.
Thiamin's cap sat at exactly 1000 mg while its PRI is exactly 1.0 mg, so a
microgram-read-as-milligram sample landed on precisely the cap and the `>`
comparison let it through. 500 still clears any real supplement dose.
NutrientIntakeDay was in no export path, which contradicted the schema's own
reason for denormalising the unit column. Carry it in the full backup with its
unit and source — source is part of the composite key, so without it a restore
cannot tell a manual entry from a synced day total.
The proxy's seven early exits — worker-only 503, two legacy 301s, the demo-mode
403, and the login / onboarding / MFA redirects — returned before the security
header block, so they carried no HSTS, no framing refusal and no nosniff. Factor
the transport subset into a helper and apply it on every exit. The per-route CSP
stays where it is; it is nonce-bound and only meaningful for a response with a
body.
The WebAuthn relying-party candidate list always carried http://localhost:3000,
including in production. The signed rpIdHash still pinned the real domain, so
this was never a reachable bypass, but a configured production origin has no
reason to carry it. Gate the fallback on development, keeping it when nothing is
configured so getRpId() still resolves.
ORPHAN_ENUMS was a hand-maintained literal that drifted every time
ALLOWED_CHART_TOKENS grew — eighteen allowlisted metrics had no strip entry, so
a model writing 'Your PHQ9_SCORE is elevated' leaked the raw enum into rendered
prose. Derive it from the allowlist and pin the coupling with a test.
The three unit suites that stubbed `requireCookieAuth` / `requireFreshMfa`
directly now stub `requireMfaManagementAuth`, and the password-change suite
stubs the elevation revocation the route gained. Adds the unit checks that need
no database: the elevation TTL is pinned to the cookie path's step-up window so
the two cannot drift apart, the minted value is a prefixed CSPRNG secret whose
plaintext never reaches the row, and a value carrying another credential's
prefix is refused without costing a query.
Second-factor management has been cookie-only since v1.23. The gate is right:
a Bearer token is a credential whose provenance the server cannot see, and
tearing down a second factor must not be reachable from the weaker of an
interactive login and a value pasted into a script. What was missing was never
Bearer access to those routes — it was a way to say, over a transport with no
session row, that the human holding this device just re-proved a factor.
An elevation is that proof. It is minted only against a re-proved account
password or primary passkey, bound to the exact token that re-proved, single-use
via a conditional UPDATE, five-minute-lived to match the cookie path's step-up
window, and stored hashed. A stolen token mints nothing; a second token cannot
redeem another's elevation; a password change drops every one the account holds.
requireMfaManagementAuth is the one gate the management routes share. On a
cookie session it delegates to the original helpers unchanged, so the web flow
runs the code it always did. requireAdmin stays cookie-only and out of reach.
Three surfaces where the words no longer matched the code, plus the missing
web control for a preference the server already honours.
Delete confirmations. Four dialogs promised permanence on surfaces that
render an Undo: the measurements and mood bulk bodies, the lab-result body,
and the illness-episode body (which said "cannot be undone" sixty lines
below the code that fires the Undo toast). All four now use the same
sentence the single-row measurement and mood dialogs already used. A sweep
of every other confirmation in the tree found the rest accurate — the
biomarker cascade, the mood-tag purge, and the account purge keep their
warning because those deletes really are unrecoverable.
Sleep debt. v1.19.0 replaced the summed shortfall with a running balance
that credits catch-up sleep; the labels still described the old sum, so the
word contradicted the number for anyone who slept in after a short night.
Corrected in all six locales, plus the places the old semantics had
propagated: the Coach system prompt, the OpenAPI field descriptions the
native client reads, and four module docblocks.
Glucose reference band. `hasDiabetes` selects the tighter reference band and
grounds the Coach, but no web surface set it — a web-only account was pinned
to the non-diabetic scale with no way out. The control goes on Targets,
above the per-metric editor, because it answers the same question that page
answers and the two are ordered: it picks the default band, an override
below beats it. The copy says what it is — a reference band, not a
diagnosis, never inferred from a reading, no clinical action taken on it.
The passkey list was reported as always reading "never used". It does not
reproduce: the column is selected by the list route and stamped on every
verified assertion, both since v1.23.0. Pinned with tests on both halves so
the contract cannot quietly regress.
Also closes the device list's exposure of the same value, refuses a
cross-device refresh rotation, replaces a vacuous expiry test with one
that fails when the check is removed, and asserts the Nightscout host
guard at save time rather than relying on the probe's placement.
The cookie was the Session row's cuid. cuid is built for collision-
resistant identity across distributed writers, not for unguessability —
it encodes a millisecond timestamp and a process-local counter and never
touches a CSPRNG. A session cookie is a bearer credential and has to be
unguessable, so it now carries 32 random bytes and only the HMAC of that
value is stored, matching how api_tokens and refresh_tokens already hold
their secrets.
Compatibility is passive, and deliberately so. A backfill cannot help:
the cookie already sitting in a browser is the cuid, and nothing we
generate server-side can be pushed into it — backfilling would sign
everyone out at deploy. Upgrading a row in place on first use loses to
the parallel requests a single page load fires: each mints its own
secret, one write wins, and the losers cannot re-emit the winner's
secret because only a hash is stored, so the browser can end up holding
a value the row does not honour. Instead, rows minted before the column
keep a NULL token_hash and stay resolvable by id, and are denied the
sliding-expiry extension so they drain within the 30-day session
lifetime. Every login from this release forward is on a secret at once,
and an id stops authenticating the moment its row holds one.
Note for operators: API_TOKEN_HMAC_KEY now also keys session cookies, so
rotating it signs browser sessions out as well as invalidating tokens.
It is already Core-required, so no deployment gains a new prerequisite.
Rotation refused a replayed token under a spoofed device id (M-4) but
still rotated a *live* one presented from a different device, and then
inherited the caller's id onto the new row — putting the resulting family
outside the victim device's containment scope. Refuse the mismatch
outright when both ids are present; an absent id on either side stays
unattributable and rotates as before, so a client that sends no
X-Device-Id is not locked out.
The expiry refusal had no coverage at all: the standing test presented a
token that was never issued, so it passed on not_found and stayed green
with the expiry branch deleted. Replace it with the real secret plus a
just-inside-the-window companion, and cover the device binding.
resolveBearerToken took an optional permission, and an unset one admitted
any valid token. 324 of the 330 route files pass no permission, so a token
minted with a narrow scope reached the full-backup export, the labs and
measurement surfaces, the coach, and the bulk deletes — the settings copy
tells users to hand exactly that token to third-party automations.
Invert the default. The scope argument becomes a required ScopeRequirement
union: a route that names no scope now accepts cookie sessions and
cookie-equivalent ["*"] tokens only, and refuses a narrow token with 403 and
an undeclared_scope audit row. A route that means to accept a narrow token
names it, as the FHIR routes already do.
No route file changes. The absence of an argument becomes a positive
statement rather than an omission, so a newly added route denies a newly
minted narrow scope by default — the safe outcome is the one you get by
doing nothing.
/mcp keeps its own posture through an explicit any-valid-token requirement:
it authenticates here and authorises downstream on audience and write scope.
That is the single deliberate fail-open call site in the tree, and making the
parameter required is what keeps it greppable.
An MCP-audience token loses its REST read leg as a result, narrowing it to
/mcp only. The isMcpAudienceToken guard stays as defence in depth.
Adversarial review found the transport gate (isCookielessNativeCaller) was
broader than finishLogin's bearer predicate: a blank-UA caller with no
X-Client-Type could pass it and fall into the cookie branch, receiving a
Set-Cookie session instead of the token bundle (inert, not exploitable, but
the endpoint's own invariant was false). Also require isNativeClientRequest
so the cookie path is structurally unreachable here, and report the actual
revoke outcome on a replay so the wide event never overclaims.
Unit-test the handoff library (mint stores only the hash + binds the
challenge + 90s TTL; consume covers not-found / replay-with-revoke /
expiry-burn / PKCE-burn / atomic consume / lost-race), the
token-exchange route (rate limit, transport gate, one identical generic
401 across every invalid-code class, success with issued-hash stamp +
audit, and OIDC_ONLY does not block it), the callback native branches
(handoff mint + scheme redirect with no session, MFA ticket on the
scheme with no session/cookie, post-identity + IdP errors to the
scheme, CSRF web-fallback), the login native-param handling, and the
by-hash refresh-token revoke used for replay containment.
The web OIDC flow ends in a session cookie the Bearer-transport native
client cannot consume, so on an OIDC_ONLY instance the iOS app had no
sign-in. Add a native leg: the app opens the login URL with
client=native and its own PKCE S256 challenge inside an in-app browser;
the callback resolves identity exactly as before, then mints a one-time
handoff code and 302s to the fixed compiled-in scheme
healthlog://oidc-callback (or hands back the existing MFA ticket when
the account has a second factor). The app exchanges the code plus its
PKCE verifier at POST /api/auth/oidc/native/token for the standard
native token bundle.
The return address is a compile-time constant — no redirect_uri
parameter and no env allowlist, so the open-redirect / scheme-hijack
class is removed by construction. The native flag and app challenge
travel only inside the AES-256-GCM state blob, so a network attacker or
the IdP cannot flip a web login into a native one. The handoff code is
256-bit CSPRNG, HMAC-hashed at rest, 90-second TTL, single-use via a
guarded consume; a failed PKCE check burns it. A replay of a consumed
code revokes exactly the pair the first exchange issued. The token pair
never rides a URL; only the opaque code or the MFA ticket do. The SSO
login can never satisfy requireFreshMfa step-up — the transport is
Bearer and step-up is cookie-only.
Adds the OidcNativeHandoff model (migration 0252), the token-exchange
route, a daily cleanup sweep, and the OpenAPI entry.
Every browser-facing redirect in the OIDC login/callback routes was built
from req.url. Behind a reverse proxy that doesn't forward the original
host, that reflects the address the app process was addressed as inside
the container rather than the public domain, sending users to an
unreachable internal URL after login. Route every redirect through the
trusted, operator-set NEXT_PUBLIC_APP_URL instead, matching the
convention the Withings integration already uses.
Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.
Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.
State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.
Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
Two second-factor gaps in the OIDC callback:
The session was minted with mfaVerifiedAt stamped to the login instant,
letting a bare SSO login satisfy requireFreshMfa step-up (MFA disable,
encryption-key rotation, encrypted export, account deletion). OIDC is a
delegated factor — whatever the IdP checked, this app did not see a
second factor — so the stamp is now null, the same treatment a
trusted-device login gets.
An account with a confirmed TOTP secret or a registered security key
was logged straight in, silently downgrading its native 2FA to
IdP-only. The callback now mints the same single-use MFA ticket
password login uses and hands it to the login page through a
short-lived, login-page-scoped cookie (the callback ends on a browser
redirect, so the ticket cannot ride an envelope meta like the password
flow). The login page picks it up once, clears it, and renders the
same challenge step; the login completes at /api/auth/mfa/verify with
identical continuation semantics. The cookie is deliberately not
httpOnly — the login page script must read the ticket, exactly the
exposure the password flow already has — and the ticket alone carries
no session authority (single-use, hashed at rest, 5-minute TTL,
attempt-capped).
Direct (factor-less) OIDC logins now also sync the admin-enforced-MFA
enrollment hint cookie, matching password login.
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.
Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
The most common self-host login loop is a Secure session cookie issued
over plain HTTP: the browser drops it and bounces the user back to the
sign-in page with no error. Detect it at cookie-mint time
(detectInsecureCookieTransport) and emit a clear warn-log plus a
session_cookie_secure_transport_mismatch wide-event, pointing at
SESSION_COOKIE_SECURE=false / X-Forwarded-Proto. Non-fatal; login still
completes.
The first-paint ladder consulted only the healthlog-locale cookie and
Accept-Language — never User.locale. The cookie is written client-side
via document.cookie, which Safari ITP caps at 7 days, so a chosen
language silently reverted to the browser language once a week.
Three seams close it:
- resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts)
now reads User.locale on a cookie miss via a lightweight session read
(no sliding-expiry write, no cookie mutation, fail-soft) before the
Accept-Language fallback.
- PUT /api/auth/me/locale mirrors the accepted locale into the cookie
via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via
shouldEmitSecureCookie) — server-set cookies are exempt from the ITP
cap, and the mount-time backfill hitting the route doubles as the
weekly refresh.
- getSession re-emits the cookie when the user has a persisted locale
but the cookie is absent (absent-only, so a mid-switch cookie is
never reverted before the PUT lands).
Users who never chose a language stay on Accept-Language.
A transient database fault during logout must still clear the session
cookie; record the delete failure on the wide event instead of throwing
before the cookie is cleared, so a user can never stay authenticated
after logging out. Remove the batch measurement future/past instant
refine: cumulative HealthKit rows carry an end-of-day endDate that is
legitimately hours ahead for the current day, and a whole-array parse
would 422 the entire sync. Note the workout batch's deferred Withings
path must write its source directly.
The incident forwarder stripped the query string but not path-segment
secrets from the reported URL; route it through the same redaction the
message and stack already use. Several delete-and-swallow paths caught
every error as the expected unique/absent-row case, masking real
failures on security-notification, logout, and integration-disconnect
paths and the Telegram reply-consume guard; narrow each to the specific
Prisma code and let real failures surface.
The network operator (carrier) used to resolve only from the optional
offline GeoLite2-ASN database. On a host without it the carrier stayed
empty even though the location resolved fine online.
Mine the operator from the same online provider response the location
lookup already fetches — ip-api's top-level `isp`/`org`/`as`, ipwho.is's
nested `connection.{isp,org,asn}` — so the carrier is populated out of the
box. The offline ASN database stays authoritative when configured.
A single `lookupIpGeo` now returns location + ASN + carrier in one pass;
the audit write and the backfill both go through it. The backfill also
revisits rows that already had a location but no carrier, so the column
fills in across existing history, not just for new sign-ins.
In the admin sign-in overview the carrier moves out of a chip stacked
under the login method into its own column, next to the location.
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read
tools + deep-value reads + a confirmed, audience-bound write surface)
with the v1.23 account-security line (MFA, known/trusted devices,
free-text note encryption).
- schema: keep every v1.23 User relation (MFA, known/trusted devices)
alongside the McpOAuthConnection relation; the MeasurementSource enum
carries the MCP value.
- api-handler: the MCP audience guard (a token scoped to
health:read/health:write is bound to /mcp and refused a REST write)
and the step-up MFA helpers coexist.
- maintenance queues: register the MCP token-cleanup sweep next to the
note-encryption backfill and key-rotation queues; shift it to 04:00
so the two daily discovery ticks do not collide.
- migrations: renumber the MCP migrations to 0206/0207 so they follow
the v1.23 chain (0199-0205).
- MCP mood writes route the note through the v1.23 at-rest cipher.
Mirror the TOTP login path: both the security-key verify and verify-options
routes now reject any challenge whose kind is not "login" with the same generic
401, so a future step-up or non-login challenge can never mint a login session
through these endpoints. Set the registration and authentication ceremonies to
userVerification:"required" to match the server's UV enforcement — a key that
passes the browser but skips UV would otherwise fail server-side.
Wire the previously-unused mfaEnforced flag into a real policy. An admin can
require a second factor instance-wide (an AppSettings toggle in the General
section) or per user (a toggle in user management); the effective requirement is
the OR of the two. When required and the account has no active factor — no
confirmed TOTP, no registered security key, and no primary passkey — the user is
walled behind a forced-enrollment interstitial after sign-in, with only the
security settings and sign-out reachable until a factor is enrolled.
The gate is surfaced like the onboarding gate: a hint cookie the proxy reads
without a DB round-trip, recomputed from the database on every sign-in and on
/api/auth/me so a tampered cookie self-corrects. Nobody is locked out — the
enrollment surface stays reachable, and toggling the policy off (or the
disable-mfa CLI) is the operator's recovery path.
Add an opt-in "remember this device" at the second-factor step. On opt-in
a random token is minted, set in an httpOnly+Secure+SameSite=Strict cookie,
and only its HMAC hash is stored in a new TrustedDevice table. A later sign-in
from the same browser still requires the password (factor 1); a matching,
unexpired trusted device lets the login skip factor 2 only.
The token is never accepted for step-up — a trusted-device login does not stamp
mfaVerifiedAt — so a stolen device cookie cannot reach a destructive action.
Trusted devices are listed and revocable from the security surface, are revoked
wholesale when the second factor is removed or the user signs out everywhere,
and cascade away on account deletion. A recovery-code login never trusts the
browser. Migration 0204 adds the table (and the instance-wide mfa_required
policy column that the enforcement work reads).
Record the very first device a user is ever seen on silently. The
new-device sign-in alert only carries meaning once there is an
established device to contrast against, so an empty device ledger
establishes the baseline without firing a notification; alerts begin
with the second distinct device. Registration's silent first-device
behaviour is unchanged.
Also extend the destructive-action step-up to treat a registered
security key as an active second factor, matching requireFreshMfa, so a
key-only account cannot delete or reset without a fresh factor.
Stamp each passkey's last-used time on a successful assertion and return
it from the list, add a rename endpoint, and add a dismissible
add-a-passkey nudge for accounts that have none. Share the relying-party
configuration with the second-factor path.
Introduce a dedicated /settings/security section that gathers every
account-protection control in one place: TOTP enrolment (QR plus manual
secret, confirm, recovery codes shown once with copy and download),
WebAuthn security keys as a second factor (register, list with last-used,
rename, step-up-gated removal), recovery-code count and regeneration, and
primary passkey management.
The second-factor WebAuthn path keeps its own credential store, separate
from primary passkeys, and uses non-resident keys so an account can hold
many. Relying-party resolution is shared with the passkey path. A status
endpoint backs the hub; the step-up gate now recognises a registered
security key as an active second factor so a WebAuthn-only account can
still reach the gated actions.
Add the user-facing account-security surface in Settings:
- Active sessions (closes#64): GET /api/auth/me/sessions lists each browser
login with a coarse device label, masked IP, resolved location, the sliding
last-active stamp, and the current-session marker. DELETE the collection
signs out everywhere else (keeps the current session, revokes other sessions
and native refresh tokens, leaves API tokens alone); DELETE one revokes a
single session, scoped to the owner so a foreign id 404s.
- A sliding last-active stamp on the session, written throttled and
fire-and-forget off the request-resolution path.
- Security activity: GET /api/auth/me/security-activity, the shared read over
the audit log filtered to sign-ins, second-factor changes, password changes,
session revocations, exports, and deletions, with masked IPs.
OpenAPI regenerated for the new endpoints.
On a completed sign-in (password, second-factor, or passkey) from a device or
location not seen before, send one notification through the dispatcher cascade,
honouring the user's per-channel preferences via a new SECURITY_ALERT event
type. Dedupe on a one-way, per-user fingerprint: a SHA-256 over the userId, the
coarse User-Agent family, and a country/ASN-level geo signal, stored in the new
user_known_devices ledger (migration 0203). The first sighting of a fingerprint
alerts and records; later sightings only bump last-seen and stay silent. The
raw User-Agent and IP are never persisted — only the salted hash and a coarse
label. Registration records its device silently so a fresh account does not
alert on its own first session. The whole path is fire-and-forget and never
blocks or fails a sign-in.
Check a newly chosen password against HaveIBeenPwned through the k-anonymity
range API: only the first five hex characters of the SHA-1 digest leave the
host, the suffix is matched locally, and Add-Padding hides the bucket size.
A confirmed match is rejected so a fresh credential is never a known-breached
one; the check fails open, so an unreachable HIBP never blocks the operation.
Covers registration, password change, and admin reset. Existing passwords are
never retroactively blocked at login.
Also lands the six-locale strings for the password-breach notice and the
account-security surfaces that follow.
Reject any challenge that is not a login challenge before minting a session,
cap the disable and confirm request bodies through safeJson, and correct the
recovery-code scan comment to match its early-return-on-match behaviour.
When a password is accepted for an account with a confirmed TOTP factor, the
login route no longer mints a session or token. It creates a single-use,
~5-minute MfaChallenge (the opaque ticket is stored only as a hash) and
returns HTTP 200 with data:null, error:null and meta.mfaRequired + mfaTicket +
methods. The client completes the login at POST /api/auth/mfa/verify with the
ticket + a TOTP or recovery code, receiving the exact same token bundle the
password path issues, with the session stamped second-factor-verified.
- The ticket is claimed once, atomically, only after the factor verifies, so
two concurrent submissions can mint at most one session; no session ever
exists before factor two.
- Wrong codes increment the ticket attempt counter and burn it at the cap
(throttle, not an account lock); the anonymous verify surface is also
rate-limited per IP.
- meta.mfaRequired is returned only after a valid password and the
invalid-credentials response is identical regardless of MFA state, so an
attacker without the password cannot enumerate MFA.
- The session/token tail is extracted to login-response.ts and shared by both
the password path and the verify path, keeping the transport contract
identical. OpenAPI registers the verify endpoint, the MFA management
endpoints, and the mfaRequired login variant.
Add the TOTP enroll/confirm/disable surface and Argon2id-hashed recovery
codes as the v1.23 second factor.
- totp.ts wraps otpauth (zero-dep) pinned to the interop floor (SHA-1, 6
digits, 30 s) with a +/-1 drift window. Replay protection persists the last
accepted time-step per account (User.totpLastStep, migration
0202_v123_totp_laststep) and rejects any step that is not strictly newer,
so a captured code dies the instant it is accepted once.
- recovery-codes.ts issues ten single-use, high-entropy codes, Argon2id-
hashed at rest, shown once; regeneration replaces the entire prior set in a
transaction and verification burns the matched code with a guarded update.
- The TOTP secret is stored AES-256-GCM encrypted (already registered in the
encrypted-columns registry + rotation script); decrypt is fail-closed.
- Routes: setup (pending secret), confirm (activate + issue recovery codes),
disable (step-up + current factor), recovery-codes/regenerate (step-up).
Setup/confirm are cookie-only; disable/regenerate add requireFreshMfa.
- scripts/disable-mfa.ts is the operator escape hatch for a locked-out
self-hoster, audited, run via pnpm dlx tsx.
Add requireFreshMfa(maxAgeSeconds) and requireCookieAuth alongside
requireAdmin. Both resolve through getSession() only, so a Bearer token can
never satisfy them — the same structural boundary requireAdmin relies on, not
a softenable runtime check. requireFreshMfa passes only for a cookie session
whose Session.mfaVerifiedAt is within the window and whose user has an active
second factor; otherwise it throws StepUpRequiredError (401 +
meta.errorCode "auth.stepup.required") so the client can launch a
re-verification flow.
createSession now accepts an optional mfaVerifiedAt stamp so a session minted
behind a completed second factor records its freshness.
Harden the remote MCP endpoint and the in-repo OAuth bridge against the
adversarial review of the milestone branch. The two ship-blockers made the
"read-only, revocable" promise false; the rest tightened the surface.
Audience binding at the resource server: a token whose only grant is the new
`health:read` scope is the MCP read credential. The REST edge now accepts it on
safe (read) methods and refuses it on writes/deletes with 403, so a connector
token can read over `/mcp` and GET routes but can never POST/DELETE the record
despite a "read-only" consent. The `/mcp` resolver is unaffected.
Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a
persistent connection row. Revoking a connection stops every future refresh and
kills its access tokens; a replayed (already-rotated) refresh is treated as
theft and revokes the whole connection. Connections surface in settings as the
unit a user can disconnect. The old access token is revoked on rotation.
Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is
configured and mark the discovery documents non-cacheable; honour the global API
kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and
registration body reads while streaming instead of after; show the verified
client origin on consent; validate CIMD redirect-URI schemes; assert same-origin
on the consent POST; and add a daily prune for expired connector tokens.
New migration 0195 adds the connection anchor and links access tokens to it.
Let a remote MCP client (Claude.ai, ChatGPT) self-register and connect to
the `/mcp` endpoint over OAuth 2.1 + PKCE, bridged onto the existing `hlk_`
Bearer model — no parallel identity store.
- Discovery: RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
Server Metadata at `/.well-known/oauth-protected-resource` and
`/.well-known/oauth-authorization-server` (added to the proxy allowlist).
- `/api/mcp/oauth/authorize` — consent gate backed by the existing session;
a code is only issued on an authenticated POST decision, never on a GET.
- `/api/mcp/oauth/token` — authorization-code and refresh-token grants with
mandatory PKCE S256, single-use codes, and rotating refresh tokens. The
access token is an ordinary short-lived `hlk_` token scoped `health:read`.
- `/api/mcp/oauth/register` — RFC 7591 DCR fallback; CIMD (SEP-991) is the
preferred path and resolves client metadata through `safeFetch`.
- Audience binding (RFC 8707): every token is bound to the canonical `/mcp`
resource URI; a mismatched `resource` is rejected at authorize and token.
- The grant artifacts (code, refresh, DCR client id) are HMAC-signed,
self-describing, and domain-separated; the central log redactors scrub
them plus the PKCE verifier.
Move the canonical Bearer-token validation out of the api-handler request
wrapper into src/lib/auth/bearer.ts so it can run outside a Next.js request
scope without dragging next/server, next/headers, or the Wide-Event machinery
into the import graph. requireAuth's Bearer path now delegates to it and keeps
the existing auth.bearer.* audit trail and Wide-Event annotation, so the
revoked / expired / scope / lastUsedAt semantics live in one place and cannot
drift between callers.
Covered by a focused unit test pinning the hash-only lookup and the
revoked / expired / insufficient-permission rejections; the existing
require-auth Bearer suite stays green.