Commit Graph
17 Commits
Author SHA1 Message Date
Marc-André Bombeck 1dde3034c6 fix(auth): say a refused sign-in in the sign-in page's own words
The sign-in refusals carry only their status's generic code, so once the
sign-in page read codes, a wrong password, a wrong second-factor code
and a refused security key all said "Your session has ended", and a
lockout said "too many requests just now".

localizedSignInError reads a generic 401 as the step's own failure and a
generic 429 as "Too many attempts. Wait a few minutes and try again.";
a code the step names itself keeps its sentence. The password, passkey,
code and security-key steps use it. The rate-limit sentence moves from
auth.claim to auth.signInRateLimited, since the claim page and the
sign-in page now share it, and the Korean one takes the polite form the
rest of the catalogue uses.
2026-11-12 13:54:34 +01:00
Marc-André Bombeck 367ba65bf6 fix(auth): take the push subscription off on every sign-out button
Only the menu's sign-out removed this browser's push subscription. The
sign-out buttons on the registration page and the profile take-over page,
which exist so someone else can use the browser next, left it behind, and
the next person kept seeing the last account's reminders. All three now
go through one signOutRequest.

The browser's own subscription is now removed only once the server has
confirmed the sign-out; the server row still goes first, while the
session can name it. A refused sign-out stores the row again, so the
person who is still signed in keeps push. The logout test pins the new
order and the refusal.

The helper file is renamed web-push-subscription.ts, since it now also
saves a subscription for the settings card.
2026-11-12 13:51:44 +01:00
Marc-André Bombeck ecb219f346 fix(auth): say a refused sign-in in the reader's language
The sign-in page (password and passkey), the second-factor step and
registration printed the server's English error text, so a German or
French page said "Invalid credentials" or "Invalid code". They now
resolve the error code through localizedApiError like the other forms
and fall back to the page's own sentence. Registration still shows a
422 as sent: that is the password feedback, which the server already
words in the reader's language.

A 429 carries the generic `http.rate_limited` code, which had no
sentence, so a person locked out by the limiter would have read only
"Login failed". The catalog now has one, reusing the wording of the
profile take-over's rate-limit message.
2026-11-11 21:21:20 +01:00
Marc-André Bombeck a4766824ef fix(auth): keep the page when a sign-out from an auth page is refused
The sign-out buttons on the registration and profile take-over pages
took every HTTP answer as success. A refused sign-out (the demo's 403,
a 429, a 5xx) wiped the client caches and reloaded the page while the
session stayed valid; on the take-over page a network failure was an
unhandled rejection with no message at all.

Both go through apiFetch now, which throws on a non-2xx answer, and say
that the person is still signed in: inline on the registration page, as
a toast on the take-over page. The message is the one the account
menu's sign-out uses.
2026-11-11 21:18:46 +01:00
Marc-André Bombeck 8fdc238981 fix(auth): replace the sign-in page in the history after signing in
Back after signing in, registering or taking over a profile showed the
form that had just been submitted, because each pushed the next page
on top of it. Password, passkey and second-factor sign-in, registration
and the password take-over now replace the page instead.
2026-11-11 21:15:05 +01:00
Marc-André Bombeck 75b879279f fix(auth): leave nothing of the last account behind on any sign-in
Only the password and passkey success path cleared the offline copies
on this device. A session that ended by expiry reached the sign-in page
without any wipe, and single sign-on and both profile take-over paths
started the next account on top of the previous one's IndexedDB
snapshot and worker caches.

The shell now clears those copies on every /auth/* page once
/api/auth/me answers 401 or 403: once per signed-out stretch, never on
an answer without a verdict. Single sign-on and the take-over by single
sign-on clear them before leaving for the identity provider, and
registration and the password take-over start from an empty query cache
like the sign-in page does.
2026-11-11 21:10:38 +01:00
Marc-André Bombeck bbef4eea99 feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
2026-10-29 08:56:51 +01:00
Marc-André Bombeck 9b93247ae0 test: prime only the EN catalog in the unit setup
The setup file runs once per test file and imported all eight message
catalogs, several hundred kilobytes each, which made setup the largest
share of a component-suite run. It now primes EN, the fallback floor of
the t() chain. A test that renders another locale primes it at module
scope through primeLocales(), which loads the catalog via the production
loader in a file-level beforeAll.
2026-10-20 08:38:08 +02:00
Marc-André Bombeck 0ebb61343e feat(claim): offer the identity provider on the claim page and name the way in to guardians
The claim page shows the password form, a continue-with-provider button, or
both, as the preview names them, and explains a refused single-sign-on claim
when the callback sends the browser back. The guardian's handover panel says
how the person will sign in.
2026-10-15 23:34:38 +02:00
Marc-André Bombeck 055c48a3fe fix(handover): show the link holder display names only
The claim preview is anonymous: whoever holds the link has proved
nothing yet. It listed each guardian's grant id and fell back to their
login name. It now carries the display name alone, null when there is
none, and the page says "a guardian" for that.

The notifications to the guardians fell back to the new owner's login
name when the record had no display name. They now use sentences about
the person the guardian looked after instead.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck 75a0f00be9 Tidy the environment, invite and claim surfaces
Environment settings show the home date, travel ranges and latest air
quality day as formatted dates instead of ISO keys, the backfill card
ends in its action row with the status lines above it, and the
attribution list sits on the page's reading edge. The sharing invite card
keeps a one-sentence description and says once, under the field, that
the person needs an account here. An expired claim link offers the
sign-in page instead of a dead end.
2026-10-08 16:21:12 +02:00
Marc-André Bombeck 8617f809f0 feat(sharing): hand a managed profile over to the person it describes
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.

The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.

The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.

Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
2026-10-08 11:18:14 +02:00
Marc-André Bombeck b100c73a62 fix(auth): refuse a sign-in over plain http before it spends a factor (#1097)
A server that issues Secure session cookies cannot sign anyone in on a
plain http:// page: the browser drops the cookie. The login request was
still verified, the MFA ticket minted and then claimed, and the next
attempt answered "Invalid or expired challenge".

The web client now sends an optional clientTransport on the browser
sign-in requests (password login, passkey start, both MFA completions).
When the server issues Secure cookies and the page is plain http on a
non-loopback host, the request is refused with 409
auth.session.insecure_transport before the password is checked, a
ticket is minted or claimed, a code is spent, or a passkey challenge is
issued. A request without the field is never refused. The SSO start
takes client_proto/client_host and sends such a page back to the login
page instead of to the provider.

GET /api/auth/registration-status publishes sessionCookieSecure, and the
login page shows the reason with both fixes and disables the sign-in
controls before anything is sent; on localhost it shows a note only.
SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line
stays.
2026-10-03 18:21:19 +02:00
Marc-André Bombeck 01fe007f35 test(e2e): take the TOTP parameters and the field hooks from the source
The spec re-declared the server's algorithm, digit count and period as
literals, so a change to any of them would leave the spec generating codes the
server no longer verifies and blaming the product. It imports the three
exported constants instead; the recovery-batch size stays a literal, because
its module pulls in the database client and would drag the generated client
into the Playwright process.

The two credential fields on the login form are addressed by test id now, like
the submit button beside them, and the recovery toggle at the challenge is
renamed to the house's noun-verb order.
2026-09-10 04:52:53 +02:00
Marc-André Bombeck 50a65b6f80 test(e2e): cover the second-factor journey end to end
Enrol TOTP from the settings surface, sign out, sign back in through the
challenge, and step up for a recovery-code rotation — in a browser, against
a real server, on a throwaway account the journey seeds and drops itself.

Four refusals ride alongside the four positives: a wrong code at the
challenge (401, no session cookie, and the ticket's attempt counter moved),
the enrolment code replayed (401, refused by the monotonic totpLastStep
floor), the rotation once the session's mfaVerifiedAt has aged past the
five-minute window (401 with meta.errorCode auth.stepup.required), and a
recovery code presented a second time (401). Each was proved to fail by
removing the control it stands on and watching that one assertion break.

Codes are computed from the enrolment secret with the otpauth dependency the
server verifies against, at explicit timestamps, so nothing waits on a step
boundary: the sign-in code is generated one step ahead of now, inside the
±1 drift window and necessarily above the replay floor.

The fixture reaches Postgres for the two things that have no endpoint by
design — ageing the step-up stamp, and emptying the per-IP auth buckets
this journey would otherwise exhaust. The account carries the Playwright
project name so the two browser projects never drive one factor state.

Adds data-testid hooks on the TOTP card, the login step, and the password
form so the spec asserts on stable attributes instead of rendered text.
2026-09-10 03:53:11 +02:00
Marc-André Bombeck 135bb04f0d feat(auth): allow trusting a device to skip the second factor
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).
2026-06-27 21:49:39 +02:00
Marc-André Bombeck 86ac1e1d12 feat(auth): complete login with a security-key second factor
When a password is accepted for an account with a second factor, the
login response carries a single-use ticket instead of a session. The
login page now renders a second step: a TOTP or recovery code, or a
WebAuthn security-key assertion scoped to the account's registered keys.
A WebAuthn-only account (no TOTP) is recognised at the password step and
offered the security-key path.

The security-key verify endpoints validate the open ticket, verify the
assertion against the user's second-factor credentials, and only then
mint the session through the shared login tail, so no session exists
before the factor passes. The login page also arms passkey conditional-UI
autofill on the username field.
2026-06-27 20:40:45 +02:00