Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

Mobile sign-in, device enrolment and local unlock

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-10-31 · Related: ADR-0015 (care roles on Better Auth), ADR-0028 (clinical privilege matrix), ADR-0002 (leaf modules), ADR-0009 (RLS)

Enforcement: packages/auth/src/session-assurance.test.ts (ASSURANCE: …, P1-A: …, P1-B: …), packages/db/src/sync-trigger.test.ts (PRIVILEGES: session_assurance … refuse tenant INSERT), packages/email/src/templates.test.ts (EMAIL: the OTP email renders in de, en and pt and contains no clinical content).

Spec Gate finding 2 called out that we had no way to assert "the current session was proven with a factor at least this strong". A clinical boundary that gates by user.twoFactorEnabled is lying: the flag only records that TOTP exists for the user — not that this session verified it. The same hole lets a WebAuthn plugin accept an assertion without User Verification (UV = 0), i.e. presence alone. This ADR records how enrolment, sign-in and local unlock close that hole.

Decision

Server-written session assurance

Every Session carries a session_assurance row the server writes at the moment the Session is established (packages/db/src/schema/care.ts). The level is one of:

  • strong — a passkey ceremony with User Verification (the patched @better-auth/passkey plugin, ticket 02 — requireUserVerification: true at both call sites), or password plus TOTP, or password plus a backup code.
  • device — a basic session lifted by devices.attest from an active device of this User (ticket 18 adds the write path).
  • basic — password alone, email OTP, or the invitation email verification. (The classifier labels /verify-email with method email_otp: both ceremonies prove address control and the clinical boundary at ticket 17 reads only level. If a later ticket needs to distinguish link-click from code entry, split the method enum there.)

Classification is by Better Auth endpoint path only (classifyAssurancePath in packages/auth/src/session-assurance.ts); user.twoFactorEnabled and a stored passkey never count. Only the server writes the row, under the auth.assurance system reason: app_tenant has SELECT and no INSERT, pinned by the RLS matrix. Unknown session-creating paths (admin impersonation, oauth flows) write no row; the boundary (ticket 17) must treat absence as less than strong.

Enrolment refusal under impersonation (ticket 14b)

Credential enrolment (/passkey/generate-register-options, /passkey/verify-registration, /two-factor/enable) is refused when the current session is impersonated (session.session.impersonatedBy set). An admin impersonating a target would otherwise register their own authenticator against the target and sign in later as the target without impersonatedBy — a persistent takeover with no audit trail of impersonation. The refusal fires in hooks.before (packages/auth/src/index.ts), so no credential is ever persisted and no new target session is ever created.

Defence-in-depth: /passkey/verify-registration with createSession: true only yields a strong session when the registering session was itself strong and not impersonated. If the before-hook refusal is ever bypassed, the new session still never claims strong assurance. First-time passkey enrolment from a basic (password or email-OTP) session therefore leaves the enrolling session unchanged; the user signs in via /passkey/verify-authentication to obtain a strong session with their new passkey.

Email-OTP routes we do not expose

The Better Auth emailOTP plugin ships eight HTTP routes, several of which increment a per-code attempt counter on failure. Ticket 14's keepsFailureWrites preserves the counter past the auth transaction only on /sign-in/email-otp and /email-otp/verify-email. Any other verify-style route would roll its counter back on 4xx, letting a distributed attacker evade the 3-attempt budget. The product uses only sign-in-OTP, verify-email, and send-verification-otp; the rest are disabled in hooks.before with HTTP 404 (packages/auth/src/index.ts, DISABLED_EMAIL_OTP_PATHS):

  • /email-otp/check-verification-otp
  • /email-otp/request-password-reset
  • /email-otp/reset-password
  • /email-otp/request-email-change
  • /email-otp/change-email
  • /forget-password/email-otp

Disabling at the door is simpler and tighter than extending keepsFailureWrites: the plugin never runs, no counter is ever written, no code is ever fed to its verifier.

Enrolment

An invitation link → email OTP proves the invited address (basic session). Then a passkey is created with User Verification (step up to strong). Then devices.register activates the device (keys delivered). If no platform authenticator is available the device registers pending_activation, receives no keys, and is activated by a 6-digit code the PDL issues (D-Team screen). Later sessions on an activated device step up from basic to device by key attestation (ticket 18 lifts this; this ADR only records the shape).

Local unlock

PIN on the device unlocks the local key; biometrics (Face ID / Fingerprint on native) is an unlock accelerator, not a factor on its own. Five wrong PIN attempts lock the device for 15 minutes; ten destroy the local key. A PDL-issued unlock code recovers from a hard lock. The server does not see the PIN; its role is to deliver a new device_code on PDL request.

Email OTP template

Email OTP uses the Better Auth emailOTP plugin with the account email transport. One template per language (de/en/pt), DE formal "Sie" (clinical context), no clinical content ever — no wound, patient, dressing or any care terminology appears in subject, body or preheader. Codes are stored hashed (storeOTP: "hashed"), expire after 5 minutes, and burn after 3 bad attempts (allowedAttempts: 3). The per-IP rate limit is max: 3 / 60 s on send and max: 5 / 60 s on verify.

Consequences

  • The clinical authorization boundary (ticket 17) has one authoritative read: getSessionAssurance(db, sessionId). A missing row is weaker than any explicit level.
  • The passkey patch is load-bearing. If a later pnpm install loses the patch, the UV refusal test in session-assurance.test.ts fails; ticket 02 also pins the source check (MANIFEST: the installed @better-auth/passkey source contains no "requireUserVerification: false").
  • keepsFailureWrites covers only the two retained email-OTP verify paths (/sign-in/email-otp, /email-otp/verify-email): a wrong code must commit its attempt counter past the auth transaction rollback, or a brute-force walk through the one-million code space is free. Every other email-OTP verify path is 404'd by the hooks.before guard above (ticket 14b), so no counter is written on those paths at all.
  • Impersonated enrolment is a 403 with code IMPERSONATED_SESSION_CANNOT_ENROL; the mobile and web clients surface a translated "stop impersonating before enrolling" message.
  • Session assurance is the mechanism; the boundary's requireStrongAuth policy (ticket 17) is the thing that reads it. Policy may evolve without changing this ADR.

Alternatives rejected

  • Treat user.twoFactorEnabled as the strong-auth check. It records existence of TOTP, not that this session verified it — a user with 2FA enabled who signs in via a passkey without UV still passes. Rejected: fails Spec Gate finding 2 head-on.
  • Compute assurance on the client. The client can lie; the server has to be the one writing. The clinical boundary runs server-side anyway.
  • Keep the attempt counter in a sidecar table. The email-otp plugin encodes it in the stored OTP value and would need forking to use a sidecar. Extending keepsFailureWrites is one line and keeps us on the upstream plugin.
  • Overloaded "device" level before ticket 18. The device enum value exists today but the writer is deferred to ticket 18; a half-wired level would blur the boundary's contract. The classifier returns only basic or strong until then.
Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.