mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
43 lines
1.5 KiB
TypeScript
43 lines
1.5 KiB
TypeScript
/**
|
||
* 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>;
|