Files
HealthLog/src/lib/validations/native-handoff.ts
T
Marc-André Bombeck 0b3280c604 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.
2026-07-24 02:28:51 +02:00

46 lines
1.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Request schema for the first-party web-handoff token exchange
* (`POST /api/auth/native/token`, iOS #65). Single-sourced here so the runtime
* `safeParse` and the OpenAPI registry (`src/lib/openapi/routes/auth.ts`)
* describe the identical wire contract. Same shape as the OIDC native exchange
* — the code prefix and PKCE verifier grammar are shared across both flows.
*/
import { z } from "zod/v4";
/**
* `{ code, codeVerifier }`.
*
* - `code` is the opaque handoff code from the
* `healthlog://login-callback?code=` redirect: the `hlh_` prefix + the 43-char
* base64url body of a 256-bit CSPRNG value. Pinned exactly so only well-formed
* codes reach the hash lookup.
* - `codeVerifier` is the app's PKCE verifier (RFC 7636 §4.1 — 43–128 chars of
* the unreserved set); the server checks `S256(codeVerifier)` against the
* challenge bound at mint (constant-time) before consuming the code.
*/
export const nativeHandoffTokenSchema = z
.object({
code: z
.string()
.regex(/^hlh_[A-Za-z0-9_-]{43}$/, "Malformed handoff code.")
.describe(
"One-time handoff code from the healthlog://login-callback?code= redirect.",
),
codeVerifier: z
.string()
.regex(
/^[A-Za-z0-9\-._~]{43,128}$/,
"The PKCE code_verifier must be 43–128 chars of the RFC 7636 unreserved set.",
)
.describe("The app's PKCE code_verifier (RFC 7636)."),
})
.meta({
id: "NativeHandoffTokenRequest",
description:
"Exchange a one-time web-handoff code (+ its PKCE verifier) for the standard native token bundle.",
});
export type NativeHandoffTokenRequest = z.infer<
typeof nativeHandoffTokenSchema
>;