chore(release): v1.32.11 — native web-handoff login for self-hosted iOS

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 02:28:51 +02:00
1 parent d2d79b70a2
commit 0b3280c604
28 files changed
+1976 -197

No files matched your search

+106 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.9
version: 1.32.11
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -1601,6 +1601,79 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/auth/native/login:
get:
tags:
- Auth
summary: Start the first-party web-handoff login (browser navigation)
description: "First leg of the native web-handoff login (iOS #65). On a self-hosted domain the iOS app opens this URL
inside an `ASWebAuthenticationSession` with its PKCE `code_challenge`, so the login runs in the instance's real
web origin (fixing password autofill + passkeys). The endpoint validates the challenge, sets a short-lived
encrypted state cookie carrying the challenge + a DB-clock start time, and 302-redirects to
`/auth/login?flow=native`. It writes no database rows. Every error 302-redirects to
`healthlog://login-callback?error=<reason>` (a closed set: `invalid_request`, `rate_limited`) — an error carries
no code or session. Anonymous; rate-limited 10 / 15 min."
parameters:
- in: query
name: code_challenge
schema:
type: string
minLength: 43
maxLength: 128
description: The app's PKCE S256 challenge (RFC 7636). `plain` is unsupported.
required: true
description: The app's PKCE S256 challenge (RFC 7636). `plain` is unsupported.
responses:
"302":
description: Redirect to `/auth/login?flow=native` (state cookie set) on success, or to
`healthlog://login-callback?error=<reason>` on failure.
/api/auth/native/complete:
get:
tags:
- Auth
summary: Complete the web-handoff login and mint the code (browser navigation)
description: "Second leg of the native web-handoff login (iOS #65). After an interactive login on
`/auth/login?flow=native`, the page navigates the browser here as a top-level GET. The endpoint validates the
web session against the database, enforces the freshness binding (`session.createdAt >= startedAt`, both
DB-clock — a pre-existing session is refused), mints a single-use PKCE-locked handoff code, deletes the state
cookie, destroys the scaffold web session, and 302-redirects to `healthlog://login-callback?code=hlh_…`. The
token pair never rides the URL; only the opaque code does. The app never calls this directly. Every failure
302-redirects to `healthlog://login-callback?error=<reason>` (`invalid_state`, `no_session`, `stale_session`,
`rate_limited`). Rate-limited 20 / 15 min."
responses:
"302":
description: Redirect to `healthlog://login-callback?code=hlh_…` on success, or `…?error=<reason>` on any failure.
/api/auth/native/token:
post:
tags:
- Auth
summary: Exchange a web-handoff code for the token bundle
description: >-
Third leg of the native web-handoff login (iOS #65). The app exchanges the one-time handoff code from
`healthlog://login-callback?code=hlh_…` plus its PKCE `codeVerifier` for the SAME native bundle password login
issues.
Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and
expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. The exchange is
flow-gated: a code minted by the OIDC native leg is not redeemable here (and vice versa). A single generic 401
covers every invalid / expired / used / PKCE-mismatch / cross-flow case.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NativeHandoffTokenRequest"
responses:
"200":
description: Handoff accepted — native access + refresh bundle.
content:
application/json:
schema:
$ref: "#/components/schemas/NativeHandoffTokenResponse"
"401": *a1
"422": *a3
"429": *a4
/api/auth/step-up/options:
post:
tags:
@@ -11557,6 +11630,21 @@ components:
- code
- codeVerifier
description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.
NativeHandoffTokenRequest:
type: object
properties:
code:
type: string
pattern: ^hlh_[A-Za-z0-9_-]{43}$
description: One-time handoff code from the healthlog://login-callback?code= redirect.
codeVerifier:
type: string
pattern: ^[A-Za-z0-9\-._~]{43,128}$
description: The app's PKCE code_verifier (RFC 7636).
required:
- code
- codeVerifier
description: Exchange a one-time web-handoff code (+ its PKCE verifier) for the standard native token bundle.
StepUpOptionsRequest:
type: object
properties:
@@ -15600,6 +15688,23 @@ components:
- data
- error
additionalProperties: false
NativeHandoffTokenResponse:
type: object
properties:
data:
$ref: "#/components/schemas/AccessRefreshBundle"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
StepUpOptionsEnvelope:
type: object
properties: