Files
HealthLog/src/lib/validations/oidc-native.ts
T
Marc-André Bombeck e6d9cc229a 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.
2026-07-18 16:50:56 +02:00

43 lines
1.5 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 native OIDC token exchange
* (`POST /api/auth/oidc/native/token`). Single-sourced here so the runtime
* `safeParse` and the OpenAPI registry (`src/lib/openapi/routes/auth.ts`)
* describe the identical wire contract.
*/
import { z } from "zod/v4";
/**
* `{ code, codeVerifier }`.
*
* - `code` is the opaque handoff code from the `healthlog://oidc-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 oidcNativeTokenSchema = z
.object({
code: z
.string()
.regex(/^hlh_[A-Za-z0-9_-]{43}$/, "Malformed handoff code.")
.describe(
"One-time handoff code from the healthlog://oidc-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: "OidcNativeTokenRequest",
description:
"Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.",
});
export type OidcNativeTokenRequest = z.infer<typeof oidcNativeTokenSchema>;