mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
d2d79b70a2
commit
0b3280c604
28 files changed
+1976
-197
No files matched your search
+106
-1
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user