# Mobile sign-in, device enrolment and local unlock

The repository's architectural decision, rationale and consequences.

# Mobile sign-in, device enrolment and local unlock

**Status:** accepted · **Date:** 2026-10-31 · **Related:** [ADR-0015](0015-care-service-roles-on-better-auth-organizations.md) (care roles on Better Auth), [ADR-0028](0028-immutable-clinical-records-and-privilege-matrix.md) (clinical privilege matrix), [ADR-0002](0002-no-barrels-on-server-capable-modules.md) (leaf modules), [ADR-0009](0009-row-level-security.md) (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 [#decision]

### Server-written session assurance [#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) [#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 [#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 [#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 [#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-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 [#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 [#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.

