feat(auth): native OIDC SSO session handoff for the iOS client

The web OIDC flow ends in a session cookie the Bearer-transport native
client cannot consume, so on an OIDC_ONLY instance the iOS app had no
sign-in. Add a native leg: the app opens the login URL with
client=native and its own PKCE S256 challenge inside an in-app browser;
the callback resolves identity exactly as before, then mints a one-time
handoff code and 302s to the fixed compiled-in scheme
healthlog://oidc-callback (or hands back the existing MFA ticket when
the account has a second factor). The app exchanges the code plus its
PKCE verifier at POST /api/auth/oidc/native/token for the standard
native token bundle.

The return address is a compile-time constant — no redirect_uri
parameter and no env allowlist, so the open-redirect / scheme-hijack
class is removed by construction. The native flag and app challenge
travel only inside the AES-256-GCM state blob, so a network attacker or
the IdP cannot flip a web login into a native one. The handoff code is
256-bit CSPRNG, HMAC-hashed at rest, 90-second TTL, single-use via a
guarded consume; a failed PKCE check burns it. A replay of a consumed
code revokes exactly the pair the first exchange issued. The token pair
never rides a URL; only the opaque code or the MFA ticket do. The SSO
login can never satisfy requireFreshMfa step-up — the transport is
Bearer and step-up is cookie-only.

Adds the OidcNativeHandoff model (migration 0252), the token-exchange
route, a daily cleanup sweep, and the OpenAPI entry.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 16:50:56 +02:00
1 parent 74891e95f4
commit e6d9cc229a
16 files changed
+900 -70

No files matched your search

+64
View File
@@ -1562,6 +1562,38 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/auth/oidc/native/token:
post:
tags:
- Auth
summary: Exchange a native OIDC handoff code for the token bundle
description: >-
The cookie-less native leg of the OIDC SSO flow. The iOS app opens `GET
/api/auth/oidc/login?client=native&code_challenge=<S256>` inside an `ASWebAuthenticationSession`; the callback
returns a one-time handoff code on `healthlog://oidc-callback?code=hlh_…` (or an `mfa_ticket` when the account
has a second factor, completed at /api/auth/mfa/verify). This endpoint exchanges the code + 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. A single generic
401 covers every invalid/expired/used/PKCE-mismatch case.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/OidcNativeTokenRequest"
responses:
"200":
description: Handoff accepted — native access + refresh bundle.
content:
application/json:
schema:
$ref: "#/components/schemas/OidcNativeTokenResponse"
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/mfa/totp/setup:
post:
tags:
@@ -11015,6 +11047,21 @@ components:
- mfaTicket
- code
description: Complete a second-factor login challenge and receive the same token bundle the password path issues.
OidcNativeTokenRequest:
type: object
properties:
code:
type: string
pattern: ^hlh_[A-Za-z0-9_-]{43}$
description: One-time handoff code from the healthlog://oidc-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 native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.
MfaDisableRequest:
type: object
properties:
@@ -14874,6 +14921,23 @@ components:
- data
- error
additionalProperties: false
OidcNativeTokenResponse:
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
TotpSetupEnvelope:
type: object
properties: