mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
46 lines
1.7 KiB
TypeScript
46 lines
1.7 KiB
TypeScript
/**
|
||
* 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
|
||
>;
|