From e6d9cc229a4b394d1a6e22fc7578dad99167d83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 16:39:04 +0200 Subject: [PATCH 1/4] feat(auth): native OIDC SSO session handoff for the iOS client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.production.example | 10 +- CLAUDE.md | 2 +- docs/api/openapi.yaml | 64 ++++++ .../0252_oidc_native_handoff/migration.sql | 48 ++++ prisma/schema.prisma | 34 +++ src/app/api/auth/oidc/callback/route.ts | 193 ++++++++++++---- src/app/api/auth/oidc/login/route.ts | 60 ++++- src/app/api/auth/oidc/native/token/route.ts | 163 ++++++++++++++ src/lib/auth/oidc-native-handoff.ts | 210 ++++++++++++++++++ src/lib/auth/oidc.ts | 15 +- src/lib/auth/refresh-token.ts | 35 +++ src/lib/jobs/oidc-handoff-cleanup.ts | 24 ++ src/lib/jobs/reminder/cleanup-handlers.ts | 22 ++ .../reminder/register-integration-sync.ts | 20 ++ src/lib/openapi/routes/auth.ts | 28 +++ src/lib/validations/oidc-native.ts | 42 ++++ 16 files changed, 900 insertions(+), 70 deletions(-) create mode 100644 prisma/migrations/0252_oidc_native_handoff/migration.sql create mode 100644 src/app/api/auth/oidc/native/token/route.ts create mode 100644 src/lib/auth/oidc-native-handoff.ts create mode 100644 src/lib/jobs/oidc-handoff-cleanup.ts create mode 100644 src/lib/validations/oidc-native.ts diff --git a/.env.production.example b/.env.production.example index ba8c95224..5a8b78bea 100644 --- a/.env.production.example +++ b/.env.production.example @@ -115,10 +115,12 @@ API_TOKEN_HMAC_KEY="" # hides password/passkey login entirely once set to "true" -- but only takes # effect when the three vars above are also fully set, so a typo can't lock # every user out. Enforced server-side, not just a hidden button. The native -# iOS client authenticates via password/passkey only -- there is no OIDC -# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in -# entirely until a native SSO flow ships. Do not enable it on a deployment -# with active iOS users. +# iOS client signs in through the native OIDC SSO handoff (the app opens the +# login URL in an in-app browser and completes at a custom-scheme callback), so +# OIDC_ONLY="true" is supported for iOS -- but only on the app build that ships +# that flow. An older password/passkey-only iOS build is locked out of sign-in +# by OIDC_ONLY; confirm the app version before enabling it on a deployment with +# active iOS users. # Serve the issuer over HTTPS wherever possible: sign-in material (the # authorization code, the token-endpoint response carrying the ID token) # crosses this connection. Plain HTTP is defensible only on a fully trusted diff --git a/CLAUDE.md b/CLAUDE.md index bb69ca078..0520b6acf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v - The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip. - `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user. - Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift. -- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app. +- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked. ## DO-NOTs diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b7bf5f4eb..47c49f594 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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=` 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: diff --git a/prisma/migrations/0252_oidc_native_handoff/migration.sql b/prisma/migrations/0252_oidc_native_handoff/migration.sql new file mode 100644 index 000000000..6bbb00f9c --- /dev/null +++ b/prisma/migrations/0252_oidc_native_handoff/migration.sql @@ -0,0 +1,48 @@ +-- Native OIDC SSO session handoff (iOS#49). +-- +-- One-time handoff codes bridging the browser-context OIDC callback to the +-- cookie-less native token exchange. The web OIDC flow ends in a session +-- cookie a Bearer-transport native client can never consume; this table lets +-- the callback mint an opaque, PKCE-locked, single-use code that the app +-- exchanges for the standard native token bundle at +-- POST /api/auth/oidc/native/token. +-- +-- The raw code (`hlh_<…>`) rides ONLY the custom-scheme redirect; only its +-- HMAC-SHA256 hash is stored, mirroring the refresh-token / MFA-challenge +-- hash-at-rest posture. `issued_refresh_token_hash` records the pair minted at +-- exchange so a replay (a second presentation after consumption) can revoke +-- exactly that family member — refresh-token reuse-detection reach-back. + +-- CreateTable +CREATE TABLE "oidc_native_handoffs" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "code_hash" TEXT NOT NULL, + "code_challenge" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "issued_refresh_token_hash" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ip_address" TEXT, + "user_agent" TEXT, + CONSTRAINT "oidc_native_handoffs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex — the exchange loads a row by the code's hash; unique so a hash +-- collision can never resolve to two live codes. +CREATE UNIQUE INDEX "oidc_native_handoffs_code_hash_key" + ON "oidc_native_handoffs" ("code_hash"); + +-- CreateIndex — per-user audit scans + the FK-cascade delete path. +CREATE INDEX "oidc_native_handoffs_user_id_idx" + ON "oidc_native_handoffs" ("user_id"); + +-- CreateIndex — the daily cleanup sweep filters on `expires_at`. +CREATE INDEX "oidc_native_handoffs_expires_at_idx" + ON "oidc_native_handoffs" ("expires_at"); + +-- AddForeignKey — deleting a user cascades their handoff rows away. +ALTER TABLE "oidc_native_handoffs" + ADD CONSTRAINT "oidc_native_handoffs_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b328fb331..31162b780 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -667,6 +667,9 @@ model User { // authorization for an MCP client, audience-bound to health:read/health:write. mcpOAuthConnections McpOAuthConnection[] refreshTokens RefreshToken[] + // v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the + // browser-context OIDC callback to the cookie-less native token exchange. + oidcNativeHandoffs OidcNativeHandoff[] auditLogs AuditLog[] withingsConnection WithingsConnection? // v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the @@ -3214,6 +3217,37 @@ model RefreshToken { @@map("refresh_tokens") } +// v1.30.x — one-time native OIDC handoff code. Bridges the browser-context +// OIDC callback to the cookie-less native token exchange. The raw value +// `hlh_<…>` rides ONLY the custom-scheme redirect (healthlog://oidc-callback); +// only its HMAC-SHA256 hash (hashToken, keyed by API_TOKEN_HMAC_KEY) is stored, +// so a database dump reconstructs nothing. Single-use via the guarded consume +// (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes +// the token pair the first exchange issued (refresh-token reuse-detection +// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows. +model OidcNativeHandoff { + id String @id @default(cuid()) + userId String @map("user_id") + codeHash String @unique @map("code_hash") // hashToken(hlh_<…>) + /// The app's S256 PKCE challenge, bound at mint; the exchange verifies + /// s256(code_verifier) against it (constant-time) before consuming. + codeChallenge String @map("code_challenge") + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + /// hashToken of the refresh token issued at exchange — replay containment + /// revokes exactly this family member (+ its paired access token). + issuedRefreshTokenHash String? @map("issued_refresh_token_hash") + createdAt DateTime @default(now()) @map("created_at") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) + @@map("oidc_native_handoffs") +} + // ─── Withings ────────────────────────────────────────────── model WithingsConnection { diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index f772eb858..5832c23a6 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -29,6 +29,10 @@ import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { resolveServerDefaultTimezone } from "@/lib/tz/resolver"; +import { + buildNativeCallbackUrl, + mintNativeHandoff, +} from "@/lib/auth/oidc-native-handoff"; const LOGIN_ERROR_URL = "/auth/login"; @@ -82,17 +86,38 @@ interface StoredOidcState { nonce: string; codeVerifier: string; next: string; + /** + * v1.30.x — set only when the login GET carried `client=native`. When true + * the callback mints a handoff code (or MFA ticket) and redirects to the + * custom scheme instead of minting a session. Both fields live ONLY inside + * the AES-256-GCM blob, so they are tamper-authenticated: a network attacker + * or the IdP cannot flip a web login into a native one or swap the app + * challenge. + */ + native?: boolean; + /** The app's S256 PKCE challenge, bound at login; verified at the exchange. */ + appCodeChallenge?: string; } function isStoredOidcState(value: unknown): value is StoredOidcState { - return ( - typeof value === "object" && - value !== null && - typeof (value as Record).state === "string" && - typeof (value as Record).nonce === "string" && - typeof (value as Record).codeVerifier === "string" && - typeof (value as Record).next === "string" - ); + const v = value as Record; + if ( + typeof value !== "object" || + value === null || + typeof v.state !== "string" || + typeof v.nonce !== "string" || + typeof v.codeVerifier !== "string" || + typeof v.next !== "string" + ) { + return false; + } + // Optional native fields: when present they must be the right shape, and a + // native start MUST carry the challenge the exchange verifies against. + if (v.native !== undefined) { + if (v.native !== true) return false; + if (typeof v.appCodeChallenge !== "string") return false; + } + return true; } /** @@ -106,34 +131,12 @@ function isStoredOidcState(value: unknown): value is StoredOidcState { export const GET = apiHandler(async (req: NextRequest) => { annotate({ action: { name: "auth.oidc.callback" } }); - const config = getOidcConfig(); - if (!config) return errorRedirect("oidc_disabled"); - - const rl = await checkAuthSurfaceRateLimit( - req, - "auth:oidc:callback", - 20, - 15 * 60 * 1000, - ); - const ip = rl.ip ?? "unknown"; - if (!rl.allowed) return errorRedirect("oidc_rate_limited"); - - const { searchParams } = req.nextUrl; - const idpError = searchParams.get("error"); - if (idpError) { - annotate({ - meta: { - reason: "idp_denied", - idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other", - }, - }); - return errorRedirect("oidc_denied"); - } - - const code = searchParams.get("code"); - const state = searchParams.get("state"); - if (!code || !state) return errorRedirect("oidc_state"); - + // Decrypt the single-use state blob FIRST. The `native` flag lives inside the + // AES-256-GCM payload (tamper-authenticated), so decrypting it up front lets + // every post-decrypt error branch redirect to the custom scheme instead of + // the web login page — the app parses `?error=` and never lands on a + // dead web page. A missing/undecryptable blob leaves `stored` null (isNative + // false) and those branches fall back to the web redirect by construction. const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value; let stored: StoredOidcState | null = null; if (rawCookie) { @@ -144,6 +147,54 @@ export const GET = apiHandler(async (req: NextRequest) => { stored = null; } } + const isNative = stored?.native === true; + + // Native-aware error redirect: the custom scheme for a decrypted native flow, + // the web login page otherwise. Deletes the single-use state cookie either + // way. An error redirect carries no code, ticket, or session, so routing it + // to the scheme leaks nothing (spec §1). + const failRedirect = (reason: string): NextResponse => { + if (isNative) { + const response = NextResponse.redirect( + buildNativeCallbackUrl({ error: reason }), + ); + deleteStateCookie(response); + return response; + } + return errorRedirect(reason); + }; + + const config = getOidcConfig(); + if (!config) return failRedirect("oidc_disabled"); + + const rl = await checkAuthSurfaceRateLimit( + req, + "auth:oidc:callback", + 20, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; + if (!rl.allowed) return failRedirect("oidc_rate_limited"); + + const { searchParams } = req.nextUrl; + const idpError = searchParams.get("error"); + if (idpError) { + annotate({ + meta: { + reason: "idp_denied", + idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other", + }, + }); + return failRedirect("oidc_denied"); + } + + const code = searchParams.get("code"); + const state = searchParams.get("state"); + // Broken round-trip / missing-or-unreadable state blob / CSRF mismatch all + // resolve to the web `oidc_state` fallback: the flow is fundamentally broken + // and there is nothing safe to hand the app (spec §1 web-fallback). + if (!code || !state) return errorRedirect("oidc_state"); + if (!stored) return errorRedirect("oidc_state"); // CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback. @@ -166,7 +217,7 @@ export const GET = apiHandler(async (req: NextRequest) => { codeVerifier: stored.codeVerifier, redirectUri, }); - if (!tokens.id_token) return errorRedirect("oidc_failed"); + if (!tokens.id_token) return failRedirect("oidc_failed"); const identity = await verifyIdToken({ metadata, @@ -229,11 +280,11 @@ export const GET = apiHandler(async (req: NextRequest) => { // health record. if (!email) { annotate({ meta: { reason: "no_email" } }); - return errorRedirect("oidc_no_email"); + return failRedirect("oidc_no_email"); } if (emailVerified !== true) { annotate({ meta: { reason: "email_unverified" } }); - return errorRedirect("oidc_email_unverified"); + return failRedirect("oidc_email_unverified"); } const byEmail = await prisma.user.findFirst({ @@ -251,7 +302,7 @@ export const GET = apiHandler(async (req: NextRequest) => { ipAddress: ip, }); annotate({ meta: { reason: "identity_conflict" } }); - return errorRedirect("oidc_identity_conflict"); + return failRedirect("oidc_identity_conflict"); } user = await prisma.user.update({ where: { id: byEmail.id }, @@ -277,7 +328,7 @@ export const GET = apiHandler(async (req: NextRequest) => { } if (!registrationEnabled) { annotate({ meta: { reason: "registration_disabled" } }); - return errorRedirect("oidc_registration_disabled"); + return failRedirect("oidc_registration_disabled"); } const username = await deriveUniqueUsername(email, (candidate) => @@ -337,11 +388,11 @@ export const GET = apiHandler(async (req: NextRequest) => { // second factor. An account with a confirmed TOTP secret or a // registered security key gets the same MFA challenge step password // login gets — no session is minted here; the single-use ticket rides - // the handoff cookie to the login page, which renders the same - // challenge UI and completes at `/api/auth/mfa/verify`. (The - // trusted-device skip cannot apply on this path: its cookie is - // SameSite=Strict, which the browser withholds on an IdP-initiated - // redirect chain.) + // the handoff cookie to the login page (web) or the custom scheme + // (native), which render the same challenge UI and complete at + // `/api/auth/mfa/verify`. (The trusted-device skip cannot apply on this + // path: its cookie is SameSite=Strict, which the browser withholds on an + // IdP-initiated redirect chain.) const hasTotp = Boolean(user.totpConfirmedAt); const webauthnKeyCount = await prisma.webauthnMfaCredential.count({ where: { userId: user.id }, @@ -356,12 +407,31 @@ export const GET = apiHandler(async (req: NextRequest) => { await auditLog("auth.mfa.challenge", { userId: user.id, ipAddress: ip, - details: { source: "login.oidc" }, + details: { source: isNative ? "login.oidc.native" : "login.oidc" }, }); annotate({ action: { name: "auth.mfa.challenge" }, meta: { mfa_required: true }, }); + + // Native: hand the app the SAME single-use ticket via the custom scheme. + // No `OIDC_MFA_COOKIE` (it exists only to ferry the ticket to the web + // login page's script) and no session — the app completes at the + // existing native-capable `/api/auth/mfa/verify{,/webauthn}` endpoints, + // which end in `finishLogin` and return the native bundle directly. The + // ticket alone is inert: single-use, hashed at rest, 5-minute TTL, + // 5-attempt cap, factor-gated. + if (isNative) { + const response = NextResponse.redirect( + buildNativeCallbackUrl({ + mfa_ticket: challenge.ticket, + methods: methods.join(","), + }), + ); + deleteStateCookie(response); + return response; + } + const loginUrl = oidcAppUrl(LOGIN_ERROR_URL); if (stored.next !== "/") { loginUrl.searchParams.set("next", stored.next); @@ -385,6 +455,33 @@ export const GET = apiHandler(async (req: NextRequest) => { const ua = req.headers.get("user-agent"); + // Native, no MFA: mint a one-time handoff code and hand it to the app via + // the custom scheme. NO session and NO cookie are minted here — the app + // exchanges the code at `POST /api/auth/oidc/native/token` for the native + // bundle. `userId` is the resolved identity; the app's S256 challenge + // (bound at login, tamper-authenticated in the state blob) locks the code + // to the app instance that started the flow. The session/device are + // recorded at the exchange via `finishLogin`, not here. + if (isNative) { + const { code: handoffCode } = await mintNativeHandoff({ + userId: user.id, + // Guaranteed present by `isStoredOidcState` when `native === true`. + appCodeChallenge: stored.appCodeChallenge as string, + ipAddress: ip, + userAgent: ua, + }); + await auditLog("auth.oidc.native.handoff_minted", { + userId: user.id, + ipAddress: ip, + }); + annotate({ action: { name: "auth.oidc.native.handoff_minted" } }); + const response = NextResponse.redirect( + buildNativeCallbackUrl({ code: handoffCode }), + ); + deleteStateCookie(response); + return response; + } + // v1.23 parity with password login — the admin-enforced-MFA hint cookie // sends a single-factor account into forced enrollment after sign-in. await syncMfaEnrollCookie(user.id, { @@ -414,6 +511,6 @@ export const GET = apiHandler(async (req: NextRequest) => { } catch (err) { getEvent()?.setError(err); annotate({ meta: { reason: "exchange_or_verify_failed" } }); - return errorRedirect("oidc_failed"); + return failRedirect("oidc_failed"); } }); diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index 6ae2ffe67..3c5c0ff36 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -5,7 +5,8 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { encrypt } from "@/lib/crypto"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; -import { s256Challenge } from "@/lib/mcp/oauth/pkce"; +import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce"; +import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff"; import { buildAuthorizationUrl, discoverOidcMetadata, @@ -29,9 +30,28 @@ import { export const GET = apiHandler(async (req: NextRequest) => { annotate({ action: { name: "auth.oidc.login" } }); + // v1.30.x — native SSO. The iOS app opens this URL inside an + // `ASWebAuthenticationSession` with `client=native` and its own PKCE + // `code_challenge`. Both are query params here only; they are validated and + // folded into the AES-256-GCM state blob so nothing native-specific rides the + // IdP round-trip as a URL parameter. The `client` param is attacker-flippable + // at this route, but routing THIS request's error to the custom scheme leaks + // nothing — an error redirect carries no code, ticket, or session (spec §1). + const isNative = req.nextUrl.searchParams.get("client") === "native"; + const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge"); + + // Native-aware error redirect: the scheme for a native start, the web login + // page otherwise. Machine-readable `error=` in both. + const loginError = (reason: string): NextResponse => { + if (isNative) { + return NextResponse.redirect(buildNativeCallbackUrl({ error: reason })); + } + return NextResponse.redirect(oidcAppUrl(`/auth/login?error=${reason}`)); + }; + const config = getOidcConfig(); if (!config) { - return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_disabled")); + return loginError("oidc_disabled"); } const rl = await checkAuthSurfaceRateLimit( @@ -41,9 +61,14 @@ export const GET = apiHandler(async (req: NextRequest) => { 15 * 60 * 1000, ); if (!rl.allowed) { - return NextResponse.redirect( - oidcAppUrl("/auth/login?error=oidc_rate_limited"), - ); + return loginError("oidc_rate_limited"); + } + + // A native start MUST carry a valid S256 challenge (43–128 chars). `plain` is + // structurally unsupported — the exchange only ever verifies S256. + if (isNative && !isValidChallenge(appCodeChallenge)) { + annotate({ meta: { reason: "invalid_native_request" } }); + return loginError("oidc_invalid_request"); } let metadata; @@ -51,7 +76,7 @@ export const GET = apiHandler(async (req: NextRequest) => { metadata = await discoverOidcMetadata(config); } catch (err) { getEvent()?.setError(err); - return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_failed")); + return loginError("oidc_failed"); } const state = randomBytes(32).toString("base64url"); @@ -59,10 +84,11 @@ export const GET = apiHandler(async (req: NextRequest) => { const codeVerifier = randomBytes(64).toString("base64url"); const codeChallenge = s256Challenge(codeVerifier); - const safeNext = sanitizeOidcNextPath( - req.nextUrl.searchParams.get("next"), - req.url, - ); + // `next` is a post-login WEB path, meaningless to the app — pin it to "/" on + // the native branch (one less input to sanitize). + const safeNext = isNative + ? "/" + : sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url); const redirectUri = getOidcRedirectUri(); const authorizationUrl = buildAuthorizationUrl({ @@ -75,8 +101,20 @@ export const GET = apiHandler(async (req: NextRequest) => { }); const response = NextResponse.redirect(authorizationUrl); + // The server↔IdP PKCE (`codeVerifier`) stays independent of the app↔server + // PKCE (`appCodeChallenge`). The native flag + challenge live ONLY inside the + // AES-256-GCM blob, so the callback branches on tamper-authenticated state a + // network attacker or the IdP cannot flip. const cookiePayload = encrypt( - JSON.stringify({ state, nonce, codeVerifier, next: safeNext }), + JSON.stringify({ + state, + nonce, + codeVerifier, + next: safeNext, + ...(isNative + ? { native: true as const, appCodeChallenge: appCodeChallenge! } + : {}), + }), ); response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, { httpOnly: true, diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts new file mode 100644 index 000000000..24e6ed179 --- /dev/null +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -0,0 +1,163 @@ +/** + * POST /api/auth/oidc/native/token + * + * The cookie-less native leg of the OIDC SSO flow (design spec §4). The app + * presents the one-time handoff code it received on + * `healthlog://oidc-callback?code=` plus its PKCE `codeVerifier`; on success it + * receives the SAME native token bundle password login issues + * (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`). + * + * Security-critical invariants: + * - It IS the credential mint (same class as `/api/auth/mfa/verify`): no + * session or Bearer is required to reach it. + * - The transport gate (`isCookielessNativeCaller`) makes the endpoint + * structurally incapable of minting a web session or handing a 60-day refresh + * token into a browser context — a Mozilla UA or an inbound session cookie is + * rejected before any state is touched. + * - `userId` is derived SOLELY from the server-side handoff row — the request + * carries no identity field. + * - A single generic 401 covers not-found / expired / used / PKCE-mismatch / + * deleted-user so a code-guesser learns nothing; distinct `annotate` names + * keep the wide events diagnosable. + * - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy + * `requireFreshMfa` step-up, so an SSO login can never satisfy step-up. + */ +import { NextRequest } from "next/server"; +import { apiError, safeJson } from "@/lib/api-response"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { isCookielessNativeCaller } from "@/lib/auth/native-client"; +import { finishLogin } from "@/lib/auth/login-response"; +import { + consumeNativeHandoff, + stampIssuedRefreshToken, +} from "@/lib/auth/oidc-native-handoff"; +import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; + +export const dynamic = "force-dynamic"; + +/** The single generic rejection for every invalid-code class (steps 4–9). */ +function invalidCode() { + return apiError("Invalid or expired code", 401); +} + +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "auth.oidc.native.token" } }); + + // (1) Rate limit — byte-for-byte the MFA-verify surface's posture, including + // the trust-violation collapse semantics. + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:oidc:native-token", + 10, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; + if (!rl.allowed) { + return apiError("Too many attempts. Please try again later.", 429, { + headers: rateLimitHeaders(rl), + }); + } + + // (2) Transport gate — this endpoint can only ever hand a bundle to a + // genuine cookie-less native caller. A browser (Mozilla UA or an inbound + // session cookie) is rejected before any state is touched. + if (!isCookielessNativeCaller(request.headers)) { + annotate({ action: { name: "auth.oidc.native.token.wrong_transport" } }); + return invalidCode(); + } + + await ensureDbCompatibility(); + + // (3) Body — bounded, then the exact wire contract. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = oidcNativeTokenSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + const { code, codeVerifier } = parsed.data; + + // (4–8) Lookup + replay/expiry/PKCE + atomic single-use consume. + const result = await consumeNativeHandoff(code, codeVerifier); + if (result.status !== "ok") { + switch (result.status) { + case "not_found": + annotate({ action: { name: "auth.oidc.native.token.not_found" } }); + break; + case "replayed": + // Containment (revoke the issued pair) + the audit row already ran + // inside `consumeNativeHandoff`. + annotate({ + action: { name: "auth.oidc.native.handoff_replay" }, + meta: { revoked_issued_pair: true }, + }); + break; + case "expired": + annotate({ action: { name: "auth.oidc.native.token.expired" } }); + break; + case "pkce_mismatch": + annotate({ action: { name: "auth.oidc.native.pkce_failed" } }); + break; + case "race_lost": + annotate({ action: { name: "auth.oidc.native.token.race_lost" } }); + break; + } + return invalidCode(); + } + + // (9) Load the user (may have been deleted mid-flight). + const user = await prisma.user.findUnique({ + where: { id: result.userId }, + select: { id: true, username: true, onboardingCompletedAt: true }, + }); + if (!user) { + annotate({ action: { name: "auth.oidc.native.token.user_missing" } }); + return invalidCode(); + } + + // (10) Mint the native bundle. Step (2) guarantees the cookie-less-native + // branch of `finishLogin`, i.e. the 24h access + 60d rotating refresh with + // the `auth.token.autoissue.native` audit row. `mfaVerified` is deliberately + // unset — the SSO login can never satisfy step-up. + const ua = request.headers.get("user-agent"); + const response = await finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "login.oidc.native", + }); + + // (11) Stamp the issued refresh token's hash for replay-containment + // reach-back, then audit the completed SSO login. Reading the bundle off a + // clone leaves the client's response stream intact. Best-effort: the code is + // already single-use-consumed, so a stamping hiccup only forgoes the + // replay-revoke for this one row. + try { + const bundle = (await response.clone().json()) as { + data?: { refreshToken?: unknown }; + }; + const refreshToken = bundle.data?.refreshToken; + if (typeof refreshToken === "string") { + await stampIssuedRefreshToken(result.handoffId, refreshToken); + } + } catch { + // Non-fatal — see comment above. + } + + await auditLog("auth.oidc.login", { + userId: user.id, + ipAddress: ip, + details: { transport: "native" }, + }); + + return response; +}); diff --git a/src/lib/auth/oidc-native-handoff.ts b/src/lib/auth/oidc-native-handoff.ts new file mode 100644 index 000000000..bd37a8f64 --- /dev/null +++ b/src/lib/auth/oidc-native-handoff.ts @@ -0,0 +1,210 @@ +/** + * Native OIDC SSO session handoff (iOS#49). + * + * The web OIDC flow (`/api/auth/oidc/login` → IdP → `/api/auth/oidc/callback`) + * ends in a session cookie. The native app is a Bearer-transport client and can + * never consume a cookie. This module bridges the two: the callback mints a + * one-time, PKCE-locked handoff code that rides ONLY the custom-scheme redirect + * back to the app; the app exchanges it at `POST /api/auth/oidc/native/token` + * for the standard native token bundle. + * + * Security posture (design spec §2–§4, §10): + * - The return address is a compiled-in constant — there is no + * `redirect_uri` parameter, so the open-redirect / scheme-hijack class is + * removed by construction, not validated away. + * - The raw code (`hlh_<…>`) is 256-bit CSPRNG, hashed at rest with the same + * `hashToken` (HMAC-SHA256, `API_TOKEN_HMAC_KEY`) used for Bearer/refresh + * tokens — a DB dump reconstructs nothing. + * - Single-use via a guarded update (`WHERE consumedAt IS NULL`); a failed + * PKCE check also burns the code (no verifier oracle, no retry surface). + * - A presentation of an already-consumed code is treated as an interception + * signal and revokes exactly the token pair the first exchange issued + * (refresh-token reuse-detection reach-back). + * - The token pair NEVER rides the redirect URL — only the opaque code (or an + * MFA ticket) does. + */ +import { randomBytes } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; +import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce"; +import { revokeRefreshTokenByHash } from "@/lib/auth/refresh-token"; +import { auditLog } from "@/lib/auth/audit"; + +/** + * The ONLY callback target for the native flow. Compiled-in, not operator- or + * client-supplied — see design spec §2. The `healthlog` scheme is an iOS-app + * dependency the app must register (iOS-coord §9.2). If the shipped app owns a + * different scheme, this constant is corrected once before release; it remains + * a compile-time constant either way. + */ +export const NATIVE_OIDC_REDIRECT_URI = "healthlog://oidc-callback"; + +/** + * 90-second handoff TTL. The gap it must cover is OS-level (scheme redirect → + * app foregrounds → one POST) — sub-second in practice; 90s absorbs a slow + * radio without holding the interception window open. Expiry is enforced at + * read; a daily sweep prunes stale rows. + */ +export const OIDC_NATIVE_HANDOFF_TTL_MS = 90_000; + +/** Distinct prefix — `hlk_` (access) / `hlr_` (refresh) / `hlac_` (MCP) are taken. */ +export const HANDOFF_CODE_PREFIX = "hlh_"; + +/** + * The 43-char base64url body of a 32-byte (256-bit) CSPRNG code. The token + * route's Zod schema pins `^hlh_[A-Za-z0-9_-]{43}$` so only well-formed codes + * ever reach the hash lookup. + */ +function generateHandoffCode(): string { + return `${HANDOFF_CODE_PREFIX}${randomBytes(32).toString("base64url")}`; +} + +/** + * Build the native callback redirect from the compiled-in constant plus + * query-escaped params only — nothing the client sends is ever echoed into a + * `Location` header on the native branch. Assembled by string (not via `new + * URL`) so the target is exactly `healthlog://oidc-callback?…` with no + * scheme-normalisation surprises. + */ +export function buildNativeCallbackUrl(params: Record): string { + const query = new URLSearchParams(params).toString(); + return `${NATIVE_OIDC_REDIRECT_URI}?${query}`; +} + +export interface MintedHandoff { + /** The raw `hlh_<…>` code — placed ONLY on the custom-scheme redirect. */ + code: string; + handoffId: string; +} + +/** + * Mint a fresh single-use handoff code for a resolved identity. `userId` comes + * from the server-side identity resolution, never from client input; the app's + * S256 challenge is bound here so the exchange can prove the caller is the app + * instance that started the flow. IP/UA are audit-only (a mobile radio changes + * address between the browser context and the app's socket, so they are NOT + * validated at exchange). + */ +export async function mintNativeHandoff(input: { + userId: string; + appCodeChallenge: string; + ipAddress?: string | null; + userAgent?: string | null; +}): Promise { + const code = generateHandoffCode(); + const row = await prisma.oidcNativeHandoff.create({ + data: { + userId: input.userId, + codeHash: hashToken(code), + codeChallenge: input.appCodeChallenge, + expiresAt: new Date(Date.now() + OIDC_NATIVE_HANDOFF_TTL_MS), + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + }, + select: { id: true }, + }); + return { code, handoffId: row.id }; +} + +/** + * Closed result union for `consumeNativeHandoff`. Every non-`ok` variant maps + * to the same generic 401 at the route so a code-guesser learns nothing; + * distinct variants keep the wide events diagnosable. + */ +export type ConsumeHandoffResult = + | { status: "ok"; userId: string; handoffId: string } + | { status: "not_found" } + | { status: "replayed"; userId: string } + | { status: "expired" } + | { status: "pkce_mismatch" } + | { status: "race_lost" }; + +/** + * Validate + atomically consume a handoff code (design spec §4 steps 4–8). + * + * Order: hash-lookup → replay containment → expiry burn → constant-time PKCE + * verify (burn on mismatch) → guarded single-use consume. The replay branch + * revokes exactly the pair the first exchange issued (via + * `issuedRefreshTokenHash`) and audits — the accepted DoS-for-containment trade + * (spec §10 T7). A failed PKCE check burns the code so an attacker holding the + * code but not the verifier also denies themselves retries (spec §10 T1). + */ +export async function consumeNativeHandoff( + rawCode: string, + codeVerifier: string, +): Promise { + const codeHash = hashToken(rawCode); + const row = await prisma.oidcNativeHandoff.findUnique({ + where: { codeHash }, + }); + + if (!row) return { status: "not_found" }; + + // Replay: any presentation of an already-consumed row — regardless of + // whether the presented verifier matches — is an interception signal. + // Revoke exactly the pair the first exchange issued and audit; the code + // itself is already spent, so no further consume is needed. + if (row.consumedAt !== null) { + if (row.issuedRefreshTokenHash) { + await revokeRefreshTokenByHash(row.issuedRefreshTokenHash); + } + await auditLog("auth.oidc.native.handoff_replay", { + userId: row.userId, + ipAddress: row.ipAddress, + }); + return { status: "replayed", userId: row.userId }; + } + + // Expiry: enforced at read. Burn the row so a lingering expired code has no + // second life even before the sweep job reaps it. + if (row.expiresAt.getTime() <= Date.now()) { + await burnHandoff(row.id); + return { status: "expired" }; + } + + // PKCE: constant-time S256. A mismatch burns the code (single-presentation + // posture) — no verifier oracle. + if (!verifyPkceS256(codeVerifier, row.codeChallenge)) { + await burnHandoff(row.id); + return { status: "pkce_mismatch" }; + } + + // Single-use consume — the same claim-once shape as `claimChallenge` / + // refresh rotation. Two concurrent exchanges mint at most one bundle. + const consumed = await prisma.oidcNativeHandoff.updateMany({ + where: { id: row.id, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + if (consumed.count === 0) { + // Lost the race — the winner's pair is legitimately in flight; do NOT + // revoke. A genuine later replay is caught by the consumed-at branch above. + return { status: "race_lost" }; + } + + return { status: "ok", userId: row.userId, handoffId: row.id }; +} + +/** Guarded burn: consume a live row without issuing anything. */ +async function burnHandoff(id: string): Promise { + await prisma.oidcNativeHandoff.updateMany({ + where: { id, consumedAt: null }, + data: { consumedAt: new Date() }, + }); +} + +/** + * Stamp the hash of the refresh token minted at exchange onto the consumed + * handoff row, so a later replay can revoke exactly this family member. Called + * after `finishLogin` succeeds. Best-effort: a failure here never fails the + * login (the code is already single-use-consumed), it only forgoes the + * replay-reach-back for this one row. + */ +export async function stampIssuedRefreshToken( + handoffId: string, + refreshToken: string, +): Promise { + await prisma.oidcNativeHandoff.update({ + where: { id: handoffId }, + data: { issuedRefreshTokenHash: hashToken(refreshToken) }, + }); +} diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts index 23987ffc2..ebcd17374 100644 --- a/src/lib/auth/oidc.ts +++ b/src/lib/auth/oidc.ts @@ -47,12 +47,15 @@ export function isOidcConfigured(): boolean { * Enforced server-side on every password/passkey auth route (`/api/auth/ * login`, `/api/auth/register`, `/api/auth/passkey/{login-options, * login-verify}`) — the login page hiding those buttons is not the - * boundary. There is currently no OIDC-compatible login flow for the - * native iOS client (it authenticates via password/passkey only, see - * `src/lib/auth/native-client.ts`), so turning this on locks the iOS app - * out of authentication entirely until a native SSO flow exists. Documented - * in `.env.production.example`; check before recommending this to an - * operator running the iOS app. + * boundary. The native iOS client signs in via the native OIDC SSO handoff + * (`GET /api/auth/oidc/login?client=native` → `healthlog://oidc-callback` → + * `POST /api/auth/oidc/native/token`, see + * `src/lib/auth/oidc-native-handoff.ts`); the MFA-verify and refresh routes + * that complete / rotate that login are deliberately NOT OIDC-only-blocked. + * `OIDC_ONLY` therefore requires the iOS build that ships this flow — an older + * password/passkey-only client is locked out. Documented in + * `.env.production.example`; check the app version before recommending this to + * an operator running the iOS app. */ export function isOidcOnly(): boolean { return isOidcConfigured() && envOrEmpty("OIDC_ONLY").toLowerCase() === "true"; diff --git a/src/lib/auth/refresh-token.ts b/src/lib/auth/refresh-token.ts index 2f6b44b28..812588f57 100644 --- a/src/lib/auth/refresh-token.ts +++ b/src/lib/auth/refresh-token.ts @@ -245,6 +245,41 @@ export async function revokeBearerAccessToken( return apiResult.count > 0; } +/** + * Revoke a refresh token identified by its stored hash (not the raw secret). + * + * Replay-containment reach-back for the native OIDC handoff: the handoff row + * records only `hashToken(refreshToken)` of the pair it issued, never the raw + * token, so a replay must revoke by hash. Same effect as `revokeRefreshToken` + * — flip the refresh row and its paired access token — keyed by the hash the + * caller already holds. + * + * Returns true when a live refresh row was revoked. + */ +export async function revokeRefreshTokenByHash( + refreshTokenHash: string, +): Promise { + const row = await prisma.refreshToken.findUnique({ + where: { tokenHash: refreshTokenHash }, + select: { accessTokenHash: true, revokedAt: true }, + }); + if (!row || row.revokedAt) return false; + + const result = await prisma.refreshToken.updateMany({ + where: { tokenHash: refreshTokenHash, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (result.count === 0) return false; + + if (row.accessTokenHash) { + await prisma.apiToken.updateMany({ + where: { tokenHash: row.accessTokenHash, revoked: false }, + data: { revoked: true }, + }); + } + return true; +} + /** Revoke a specific refresh token (logout-on-device). * Also revokes the paired access token so a leaked access token cannot * outlive the refresh-token sibling that the user just killed. */ diff --git a/src/lib/jobs/oidc-handoff-cleanup.ts b/src/lib/jobs/oidc-handoff-cleanup.ts new file mode 100644 index 000000000..a6fb6e18a --- /dev/null +++ b/src/lib/jobs/oidc-handoff-cleanup.ts @@ -0,0 +1,24 @@ +/** + * v1.30.x — daily cleanup for the `oidc_native_handoffs` table. + * + * Mirrors the OAuth-state sweeps (`whoop-oauth-state-cleanup`). Native OIDC + * handoff codes are single-use + 90-second-lived: every exchange consumes its + * row (`consumedAt`), and expiry is enforced at read, so a row only lingers + * when the app never came back to exchange the code. Once `expiresAt` passes + * the row is dead weight — deleting expired rows (consumed or not) keeps the + * table and its `expires_at` index tight. + * + * Idempotent: a re-run within the same window matches zero rows the second time + * because the first pass deleted everything older than `now()`. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +export async function cleanupExpiredOidcNativeHandoffs( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const { count } = await prisma.oidcNativeHandoff.deleteMany({ + where: { expiresAt: { lt: now } }, + }); + return count; +} diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index e6530188b..105367bdb 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -15,6 +15,7 @@ import { cleanupExpiredMcpTokens } from "@/lib/jobs/mcp-token-cleanup"; import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup"; import { cleanupOldCoachMessages } from "@/lib/jobs/coach-message-cleanup"; import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup"; +import { cleanupExpiredOidcNativeHandoffs } from "@/lib/jobs/oidc-handoff-cleanup"; import { cleanupExpiredMeasurementTombstones, cleanupExpiredMoodTombstones, @@ -265,6 +266,27 @@ export async function handleWithingsOAuthStateCleanup( }); } +export interface OidcNativeHandoffCleanupPayload { + triggeredAt?: string; +} + +export async function handleOidcNativeHandoffCleanup( + jobs: Job[], +) { + void jobs; + await withBackgroundEvent("job.oidc_native_handoff_cleanup", async (evt) => { + const p = getWorkerPrisma(); + try { + const deleted = await cleanupExpiredOidcNativeHandoffs(p); + evt.addMeta("oidc_native_handoff_cleanup_deleted", deleted); + } catch (err) { + // The handoff flow tolerates a stale row for an extra day (expiry is + // enforced at read) — log + carry on so the boss queue doesn't retry-loop. + evt.addWarning(`oidc-native-handoff-cleanup failed: ${err}`); + } + }); +} + export interface WhoopOAuthStateCleanupPayload { triggeredAt?: string; } diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index c1987024d..a3bde40a8 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -86,6 +86,8 @@ import { handleWithingsOAuthStateCleanup, WhoopOAuthStateCleanupPayload, handleWhoopOAuthStateCleanup, + OidcNativeHandoffCleanupPayload, + handleOidcNativeHandoffCleanup, } from "./cleanup-handlers"; import { NightscoutSyncPayload, handleNightscoutSync } from "./nightscout-sync"; import { PolarSyncPayload, handlePolarSync } from "./polar-sync"; @@ -219,6 +221,14 @@ const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15 // schedule silently no-ops (the v1.4.37 dead-queue class). const STRAVA_SYNC_QUEUE = "strava-sync"; const STRAVA_SYNC_CRON = "17 * * * *"; // every hour at :17 +// v1.30.x — daily sweep for the native OIDC handoff ledger. Handoff codes are +// single-use + 90s-lived and expiry is enforced at read, so this is pure +// hygiene: it reaps rows the app never came back to exchange. Slots at 03:28, +// next to the Google Health OAuth-state sweep (03:26), inside the maintenance +// window. The queue MUST be registered in `allQueues` below or pg-boss never +// provisions it and the schedule silently no-ops (the v1.4.37 dead-queue class). +const OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE = "oidc-native-handoff-cleanup"; +const OIDC_NATIVE_HANDOFF_CLEANUP_CRON = "28 3 * * *"; // pg-boss v12 requires explicit queue creation before scheduling. Every queue // MUST be registered here or pg-boss never provisions it and both the webhook @@ -273,6 +283,8 @@ const allQueues = [ // v1.28.x — Strava OAuth poll sync + self-converging boot backfill. STRAVA_SYNC_QUEUE, STRAVA_BACKFILL_QUEUE, + // v1.30.x — daily sweep for the native OIDC handoff ledger. + OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -310,6 +322,8 @@ const schedules: ScheduleEntry[] = [ [OURA_SYNC_QUEUE, OURA_SYNC_CRON], // v1.28.x — hourly Strava (:17) OAuth poll. [STRAVA_SYNC_QUEUE, STRAVA_SYNC_CRON], + // v1.30.x — daily 03:28 Europe/Berlin prune for expired native OIDC handoffs. + [OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, OIDC_NATIVE_HANDOFF_CLEANUP_CRON], ]; /** @@ -386,6 +400,12 @@ export async function registerIntegrationSyncQueues( { localConcurrency: 1 }, handleWhoopOAuthStateCleanup, ); + // v1.30.x — daily sweep for the native OIDC handoff ledger. + await boss.work( + OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleOidcNativeHandoffCleanup, + ); // v1.12.0 — Fitbit poll-sync (cron full-iteration; no webhook). Serial // concurrency so a backfill-heavy tick never crowds the request pool. await boss.work( diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 3b21da953..90de17900 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -17,6 +17,7 @@ import { mfaWebauthnLoginOptionsSchema, mfaWebauthnLoginVerifySchema, } from "@/lib/validations/mfa"; +import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; import { dataEnvelope, stdResponses, errorEnvelope } from "./shared"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -354,6 +355,33 @@ export const authPaths: NonNullable = { }, }, }, + "/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=` 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.\n\n" + + "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: oidcNativeTokenSchema } }, + }, + responses: { + "200": { + description: "Handoff accepted — native access + refresh bundle.", + content: { + "application/json": { + schema: dataEnvelope( + accessRefreshBundle, + "OidcNativeTokenResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/mfa/totp/setup": { post: { tags: ["Auth"], diff --git a/src/lib/validations/oidc-native.ts b/src/lib/validations/oidc-native.ts new file mode 100644 index 000000000..59b1f0eb0 --- /dev/null +++ b/src/lib/validations/oidc-native.ts @@ -0,0 +1,42 @@ +/** + * 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; From ada147522f789f350c9506473fe45c1b9c4ada99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 16:49:15 +0200 Subject: [PATCH 2/4] test(auth): cover the native OIDC SSO handoff Unit-test the handoff library (mint stores only the hash + binds the challenge + 90s TTL; consume covers not-found / replay-with-revoke / expiry-burn / PKCE-burn / atomic consume / lost-race), the token-exchange route (rate limit, transport gate, one identical generic 401 across every invalid-code class, success with issued-hash stamp + audit, and OIDC_ONLY does not block it), the callback native branches (handoff mint + scheme redirect with no session, MFA ticket on the scheme with no session/cookie, post-identity + IdP errors to the scheme, CSRF web-fallback), the login native-param handling, and the by-hash refresh-token revoke used for replay containment. --- .../oidc/callback/__tests__/route.test.ts | 137 +++++++++++ .../auth/oidc/login/__tests__/route.test.ts | 63 +++++ .../oidc/native/token/__tests__/route.test.ts | 225 +++++++++++++++++ .../__tests__/oidc-native-handoff.test.ts | 227 ++++++++++++++++++ src/lib/auth/__tests__/refresh-token.test.ts | 40 +++ 5 files changed, 692 insertions(+) create mode 100644 src/app/api/auth/oidc/native/token/__tests__/route.test.ts create mode 100644 src/lib/auth/__tests__/oidc-native-handoff.test.ts diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts index d547e64e3..aa41f7b01 100644 --- a/src/app/api/auth/oidc/callback/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts @@ -68,6 +68,18 @@ vi.mock("@/lib/auth/oidc", () => ({ deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]), })); +// The handoff lib is unit-tested separately; here we assert the callback +// branches into it and redirects to the custom scheme (real +// `buildNativeCallbackUrl`, mocked `mintNativeHandoff`). +vi.mock("@/lib/auth/oidc-native-handoff", () => ({ + buildNativeCallbackUrl: (params: Record) => + `healthlog://oidc-callback?${new URLSearchParams(params).toString()}`, + mintNativeHandoff: vi.fn(async () => ({ + code: "hlh_test_code", + handoffId: "ho-1", + })), +})); + import { GET } from "../route"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -80,6 +92,7 @@ import { exchangeCodeForTokens, verifyIdToken, } from "@/lib/auth/oidc"; +import { mintNativeHandoff } from "@/lib/auth/oidc-native-handoff"; const CONFIG = { issuerUrl: "https://idp.example.com", @@ -577,3 +590,127 @@ describe("GET /api/auth/oidc/callback", () => { expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc"); }); }); + +describe("GET /api/auth/oidc/callback — native branch", () => { + beforeEach(() => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 20, + reset: 0, + ip: "1.2.3.4", + } as never); + vi.mocked(getOidcConfig).mockReturnValue(CONFIG); + vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0); + vi.mocked(mintNativeHandoff).mockResolvedValue({ + code: "hlh_test_code", + handoffId: "ho-1", + }); + }); + + function nativeStateCookie(overrides: Record = {}): string { + return stateCookie({ + state: "STATE", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next: "/", + native: true, + appCodeChallenge: "app-challenge-123", + ...overrides, + }); + } + + function nativeRequest(): NextRequest { + return makeRequest({ + query: "?code=abc&state=STATE", + cookie: nativeStateCookie(), + }); + } + + it("mints a handoff code and 302s to the custom scheme — no session", async () => { + mockIdentity(); + mockUserLookups({ + byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }), + }); + + const res = await GET(nativeRequest()); + const location = res.headers.get("location")!; + expect(location.startsWith("healthlog://oidc-callback?code=")).toBe(true); + expect(location).toContain("code=hlh_test_code"); + // The app's challenge (from the tamper-authenticated blob) is bound at mint. + expect(mintNativeHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + appCodeChallenge: "app-challenge-123", + }), + ); + // No web session is minted on the native branch. + expect(createSession).not.toHaveBeenCalled(); + }); + + it("hands the MFA ticket to the custom scheme (no session, no MFA cookie)", async () => { + mockIdentity(); + mockUserLookups({ + byIdentity: userRow({ + oidcIssuer: METADATA.issuer, + oidcSub: "sub-1", + totpConfirmedAt: new Date(), + }), + }); + + const res = await GET(nativeRequest()); + const location = res.headers.get("location")!; + expect(location.startsWith("healthlog://oidc-callback?")).toBe(true); + expect(location).toContain("mfa_ticket=mfa-ticket-1"); + expect(location).toContain("methods=totp%2Crecovery"); + // No handoff code on the MFA branch, and no session. + expect(location).not.toContain("code=hlh_"); + expect(mintNativeHandoff).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + // The web-only MFA handoff cookie is never set on the native branch. + const mfaCookie = res.headers + .getSetCookie() + .find((c) => c.startsWith("oidc_mfa_handoff=")); + expect(mfaCookie).toBeUndefined(); + }); + + it("delivers a post-identity error to the custom scheme, not a web page", async () => { + mockIdentity(); + // An account already pinned to a different IdP identity → identity_conflict. + mockUserLookups({ + byIdentity: null, + byEmail: userRow({ oidcIssuer: "https://other.example", oidcSub: "x" }), + }); + + const res = await GET(nativeRequest()); + const location = res.headers.get("location")!; + expect(location).toBe( + "healthlog://oidc-callback?error=oidc_identity_conflict", + ); + }); + + it("delivers an IdP denial to the custom scheme", async () => { + const res = await GET( + makeRequest({ + query: "?error=access_denied&state=STATE", + cookie: nativeStateCookie(), + }), + ); + expect(res.headers.get("location")).toBe( + "healthlog://oidc-callback?error=oidc_denied", + ); + }); + + it("falls back to the WEB oidc_state page on a CSRF mismatch (broken flow)", async () => { + const res = await GET( + makeRequest({ + query: "?code=abc&state=WRONG", + cookie: nativeStateCookie(), + }), + ); + // A tampered/broken round-trip never hands the app a scheme redirect. + expect(res.headers.get("location")).toContain( + "/auth/login?error=oidc_state", + ); + }); +}); diff --git a/src/app/api/auth/oidc/login/__tests__/route.test.ts b/src/app/api/auth/oidc/login/__tests__/route.test.ts index 52b15b8eb..38c13a58f 100644 --- a/src/app/api/auth/oidc/login/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/login/__tests__/route.test.ts @@ -150,4 +150,67 @@ describe("GET /api/auth/oidc/login", () => { const res = await GET(makeRequest()); expect(res.headers.get("location")).toContain("error=oidc_failed"); }); + + describe("native SSO start", () => { + const CONFIG = { + issuerUrl: "https://idp.example.com", + clientId: "client-1", + clientSecret: "secret-1", + scopes: "openid email profile", + buttonLabel: "SSO", + }; + const CHALLENGE = "a".repeat(43); // valid S256 length + + function storedFromCookie(res: Awaited>) { + const cookie = res.cookies.get("oidc_auth_state"); + return JSON.parse(cookie!.value.replace(/^enc\(/, "").replace(/\)$/, "")); + } + + it("rejects a native start with a missing challenge to the custom scheme", async () => { + vi.mocked(getOidcConfig).mockReturnValue(CONFIG); + const res = await GET(makeRequest("/api/auth/oidc/login?client=native")); + expect(res.headers.get("location")).toBe( + "healthlog://oidc-callback?error=oidc_invalid_request", + ); + }); + + it("routes native login errors (disabled) to the custom scheme", async () => { + vi.mocked(getOidcConfig).mockReturnValue(null); + const res = await GET( + makeRequest( + `/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}`, + ), + ); + expect(res.headers.get("location")).toBe( + "healthlog://oidc-callback?error=oidc_disabled", + ); + }); + + it("folds native:true + appCodeChallenge into the state blob and pins next to /", async () => { + vi.mocked(getOidcConfig).mockReturnValue(CONFIG); + vi.mocked(discoverOidcMetadata).mockResolvedValue({ + issuer: "https://idp.example.com", + authorization_endpoint: "https://idp.example.com/authorize", + token_endpoint: "https://idp.example.com/token", + jwks_uri: "https://idp.example.com/jwks", + }); + + const res = await GET( + makeRequest( + `/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}&next=/dashboard`, + ), + ); + // Still a normal redirect to the IdP — the native fact rides the blob. + expect(res.headers.get("location")).toBe( + "https://idp.example.com/authorize?x=1", + ); + const stored = storedFromCookie(res); + expect(stored.native).toBe(true); + expect(stored.appCodeChallenge).toBe(CHALLENGE); + // `next` is meaningless to the app — pinned to "/". + expect(stored.next).toBe("/"); + // The server↔IdP verifier stays independent and present. + expect(typeof stored.codeVerifier).toBe("string"); + }); + }); }); diff --git a/src/app/api/auth/oidc/native/token/__tests__/route.test.ts b/src/app/api/auth/oidc/native/token/__tests__/route.test.ts new file mode 100644 index 000000000..774627b3d --- /dev/null +++ b/src/app/api/auth/oidc/native/token/__tests__/route.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/native-client", () => ({ + isCookielessNativeCaller: vi.fn(() => true), +})); + +vi.mock("@/lib/auth/oidc-native-handoff", () => ({ + consumeNativeHandoff: vi.fn(), + stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { isCookielessNativeCaller } from "@/lib/auth/native-client"; +import { + consumeNativeHandoff, + stampIssuedRefreshToken, +} from "@/lib/auth/oidc-native-handoff"; +import { finishLogin } from "@/lib/auth/login-response"; +import { auditLog } from "@/lib/auth/audit"; + +const VALID_BODY = { + code: "hlh_" + "a".repeat(43), + codeVerifier: "a".repeat(43), +}; + +function makeRequest( + body: unknown = VALID_BODY, + headers: Record = {}, +): NextRequest { + return new NextRequest("http://localhost/api/auth/oidc/native/token", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function nativeBundleResponse() { + return NextResponse.json({ + data: { + user: { id: "user-1", username: "alice" }, + token: "hlk_access", + tokenExpiresAt: new Date().toISOString(), + refreshToken: "hlr_refresh", + refreshTokenExpiresAt: new Date().toISOString(), + }, + error: null, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 10, + resetAt: 0, + ip: "1.2.3.4", + } as never); + vi.mocked(isCookielessNativeCaller).mockReturnValue(true); +}); + +describe("POST /api/auth/oidc/native/token", () => { + it("429 when rate-limited (before any state is touched)", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: 0, + ip: "1.2.3.4", + } as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(429); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("401 and never consumes when the transport is not cookie-less native", async () => { + vi.mocked(isCookielessNativeCaller).mockReturnValue(false); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("422 on a malformed body", async () => { + const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" })); + expect(res.status).toBe(422); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("returns ONE identical generic 401 across every invalid-code class", async () => { + const statuses = [ + { status: "not_found" }, + { status: "replayed", userId: "user-1" }, + { status: "expired" }, + { status: "pkce_mismatch" }, + { status: "race_lost" }, + ] as const; + + const bodies: string[] = []; + for (const s of statuses) { + vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + bodies.push(await res.text()); + } + // Also the deleted-user-mid-flight branch. + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + bodies.push(await res.text()); + + // Byte-identical envelope — a code-guesser learns nothing. + expect(new Set(bodies).size).toBe(1); + }); + + it("does not mint on the deleted-user branch", async () => { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + await POST(makeRequest()); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("200 on success: mints via the native source, stamps the issued hash, audits", async () => { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + username: "alice", + onboardingCompletedAt: new Date(), + } as never); + vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse()); + + const res = await POST( + makeRequest(VALID_BODY, { "x-client-type": "native" }), + ); + expect(res.status).toBe(200); + + const finishArg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(finishArg.source).toBe("login.oidc.native"); + // `mfaVerified` is never set — the SSO login can't satisfy step-up. + expect(finishArg.mfaVerified).toBeUndefined(); + + // The issued refresh token's hash is stamped for replay reach-back. + expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh"); + + expect(auditLog).toHaveBeenCalledWith( + "auth.oidc.login", + expect.objectContaining({ + userId: "user-1", + details: { transport: "native" }, + }), + ); + + // The client still receives the bundle (clone left the stream intact). + const body = await res.json(); + expect(body.data.refreshToken).toBe("hlr_refresh"); + }); + + it("is NOT blocked by OIDC_ONLY (it completes an OIDC-initiated login)", async () => { + const prev = process.env.OIDC_ONLY; + process.env.OIDC_ONLY = "true"; + try { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + username: "alice", + onboardingCompletedAt: new Date(), + } as never); + vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse()); + + const res = await POST( + makeRequest(VALID_BODY, { "x-client-type": "native" }), + ); + expect(res.status).toBe(200); + } finally { + if (prev === undefined) delete process.env.OIDC_ONLY; + else process.env.OIDC_ONLY = prev; + } + }); +}); diff --git a/src/lib/auth/__tests__/oidc-native-handoff.test.ts b/src/lib/auth/__tests__/oidc-native-handoff.test.ts new file mode 100644 index 000000000..fe9dfd9f3 --- /dev/null +++ b/src/lib/auth/__tests__/oidc-native-handoff.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + oidcNativeHandoff: { + create: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +// Deterministic stand-in for the real HMAC — length-keyed so it never contains +// the raw code, making the "raw code is never persisted" assertion meaningful. +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn((t: string) => `H(${t.length})`), +})); + +const revokeRefreshTokenByHash = vi.fn(); +vi.mock("@/lib/auth/refresh-token", () => ({ + revokeRefreshTokenByHash: (h: string) => revokeRefreshTokenByHash(h), +})); + +const auditLog = vi.fn(); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: (...a: unknown[]) => auditLog(...a), +})); + +import { + mintNativeHandoff, + consumeNativeHandoff, + buildNativeCallbackUrl, + stampIssuedRefreshToken, + NATIVE_OIDC_REDIRECT_URI, + OIDC_NATIVE_HANDOFF_TTL_MS, +} from "../oidc-native-handoff"; +import { s256Challenge } from "@/lib/mcp/oauth/pkce"; +import { prisma } from "@/lib/db"; + +// A real RFC 7636 verifier + its real S256 challenge, so the constant-time +// verify inside `consumeNativeHandoff` runs for real. +const VERIFIER = "a".repeat(43); +const CHALLENGE = s256Challenge(VERIFIER); + +function storedRow(overrides: Record = {}) { + return { + id: "ho-1", + userId: "user-1", + codeHash: "H(47)", + codeChallenge: CHALLENGE, + expiresAt: new Date(Date.now() + OIDC_NATIVE_HANDOFF_TTL_MS), + consumedAt: null, + issuedRefreshTokenHash: null, + createdAt: new Date(), + ipAddress: "1.2.3.4", + userAgent: "HealthLog-iOS/1", + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("mintNativeHandoff", () => { + it("mints an hlh_ code, stores only its hash, and binds the challenge + TTL", async () => { + vi.mocked(prisma.oidcNativeHandoff.create).mockResolvedValue({ + id: "ho-1", + } as never); + + const before = Date.now(); + const { code, handoffId } = await mintNativeHandoff({ + userId: "user-1", + appCodeChallenge: CHALLENGE, + ipAddress: "1.2.3.4", + userAgent: "HealthLog-iOS/1", + }); + + expect(code.startsWith("hlh_")).toBe(true); + expect(handoffId).toBe("ho-1"); + + const arg = vi.mocked(prisma.oidcNativeHandoff.create).mock.calls[0][0]; + // userId comes from the resolved identity; challenge bound at mint. + expect(arg.data.userId).toBe("user-1"); + expect(arg.data.codeChallenge).toBe(CHALLENGE); + // Only the hash is persisted — never the raw code. + expect(arg.data.codeHash).toBe(`H(${code.length})`); + expect(JSON.stringify(arg)).not.toContain(code); + // 90-second TTL. + const ttl = (arg.data.expiresAt as Date).getTime() - before; + expect(ttl).toBeGreaterThan(OIDC_NATIVE_HANDOFF_TTL_MS - 2000); + expect(ttl).toBeLessThanOrEqual(OIDC_NATIVE_HANDOFF_TTL_MS + 100); + }); +}); + +describe("buildNativeCallbackUrl", () => { + it("targets the fixed scheme and escapes params", () => { + const url = buildNativeCallbackUrl({ + code: "hlh_abc", + methods: "totp,recovery", + }); + expect(url.startsWith(`${NATIVE_OIDC_REDIRECT_URI}?`)).toBe(true); + expect(url).toContain("code=hlh_abc"); + // Comma is percent-escaped — no unescaped client value in the Location. + expect(url).toContain("methods=totp%2Crecovery"); + }); +}); + +describe("consumeNativeHandoff", () => { + it("returns not_found for an unknown code", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + null as never, + ); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("not_found"); + expect( + vi.mocked(prisma.oidcNativeHandoff.findUnique).mock.calls[0][0], + ).toMatchObject({ where: { codeHash: "H(47)" } }); + }); + + it("replay: a consumed row revokes exactly the issued pair and audits", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ + consumedAt: new Date(), + issuedRefreshTokenHash: "issued-refresh-hash", + }) as never, + ); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("replayed"); + expect(revokeRefreshTokenByHash).toHaveBeenCalledWith( + "issued-refresh-hash", + ); + expect(auditLog).toHaveBeenCalledWith( + "auth.oidc.native.handoff_replay", + expect.objectContaining({ userId: "user-1" }), + ); + // A consumed row is never re-consumed. + expect(prisma.oidcNativeHandoff.updateMany).not.toHaveBeenCalled(); + }); + + it("replay without an issued hash still audits but revokes nothing", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ + consumedAt: new Date(), + issuedRefreshTokenHash: null, + }) as never, + ); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("replayed"); + expect(revokeRefreshTokenByHash).not.toHaveBeenCalled(); + expect(auditLog).toHaveBeenCalledTimes(1); + }); + + it("expired: burns the row (guarded) and never issues", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ expiresAt: new Date(Date.now() - 1000) }) as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("expired"); + expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({ + where: { id: "ho-1", consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + expect(revokeRefreshTokenByHash).not.toHaveBeenCalled(); + }); + + it("pkce_mismatch: a wrong verifier burns the code", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await consumeNativeHandoff( + "hlh_" + "x".repeat(43), + "b".repeat(43), + ); + expect(res.status).toBe("pkce_mismatch"); + // Burned via the guarded update — no issuance. + expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({ + where: { id: "ho-1", consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + }); + + it("ok: a live row + correct verifier consumes atomically and returns the identity", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res).toEqual({ status: "ok", userId: "user-1", handoffId: "ho-1" }); + expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({ + where: { id: "ho-1", consumedAt: null }, + data: { consumedAt: expect.any(Date) }, + }); + }); + + it("race_lost: a lost consume returns race_lost and revokes nothing", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow() as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER); + expect(res.status).toBe("race_lost"); + expect(revokeRefreshTokenByHash).not.toHaveBeenCalled(); + }); +}); + +describe("stampIssuedRefreshToken", () => { + it("stores the hash of the issued refresh token on the handoff row", async () => { + vi.mocked(prisma.oidcNativeHandoff.update).mockResolvedValue({} as never); + await stampIssuedRefreshToken("ho-1", "hlr_secret"); + expect(prisma.oidcNativeHandoff.update).toHaveBeenCalledWith({ + where: { id: "ho-1" }, + data: { issuedRefreshTokenHash: `H(${"hlr_secret".length})` }, + }); + }); +}); diff --git a/src/lib/auth/__tests__/refresh-token.test.ts b/src/lib/auth/__tests__/refresh-token.test.ts index b4eb6d4df..1ee9835cc 100644 --- a/src/lib/auth/__tests__/refresh-token.test.ts +++ b/src/lib/auth/__tests__/refresh-token.test.ts @@ -133,6 +133,7 @@ import { issueAccessAndRefresh, rotateRefreshToken, revokeRefreshToken, + revokeRefreshTokenByHash, revokeBearerAccessToken, } from "../refresh-token"; import type { TokenPolicyDecision } from "../native-client"; @@ -461,3 +462,42 @@ describe("revokeBearerAccessToken (M-2 logout bearer revoke)", () => { expect(ok).toBe(false); }); }); + +describe("revokeRefreshTokenByHash (native OIDC handoff replay containment)", () => { + it("revokes the refresh row + its paired access token by stored hash", async () => { + const bundle = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.oidc.native", + deviceId: "dev-1", + }); + const refreshHash = `hash:${bundle.refreshToken}`; + const accessHash = `hash:${bundle.accessToken}`; + + // The handoff row only ever stored the HASH of the refresh token, never + // the raw secret — so replay containment must revoke by hash. + const ok = await revokeRefreshTokenByHash(refreshHash); + expect(ok).toBe(true); + + expect( + dbState.refreshTokens.find((r) => r.tokenHash === refreshHash)?.revokedAt, + ).not.toBeNull(); + expect( + dbState.apiTokens.find((a) => a.tokenHash === accessHash)?.revoked, + ).toBe(true); + }); + + it("returns false for an unknown or already-revoked hash", async () => { + expect(await revokeRefreshTokenByHash("hash:hlr_unknown")).toBe(false); + + const bundle = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.oidc.native", + }); + const refreshHash = `hash:${bundle.refreshToken}`; + await revokeRefreshTokenByHash(refreshHash); + // Second call is a no-op (idempotent containment). + expect(await revokeRefreshTokenByHash(refreshHash)).toBe(false); + }); +}); From 16fd4966d05f93dc1e3d295a0aa7d766c9168e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 17:02:45 +0200 Subject: [PATCH 3/4] fix(auth): force the native-bundle branch on the OIDC token exchange Adversarial review found the transport gate (isCookielessNativeCaller) was broader than finishLogin's bearer predicate: a blank-UA caller with no X-Client-Type could pass it and fall into the cookie branch, receiving a Set-Cookie session instead of the token bundle (inert, not exploitable, but the endpoint's own invariant was false). Also require isNativeClientRequest so the cookie path is structurally unreachable here, and report the actual revoke outcome on a replay so the wide event never overclaims. --- .../oidc/native/token/__tests__/route.test.ts | 8 ++++++- src/app/api/auth/oidc/native/token/route.ts | 23 +++++++++++++------ src/lib/auth/oidc-native-handoff.ts | 11 +++++++-- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/app/api/auth/oidc/native/token/__tests__/route.test.ts b/src/app/api/auth/oidc/native/token/__tests__/route.test.ts index 774627b3d..6fc48da0a 100644 --- a/src/app/api/auth/oidc/native/token/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/native/token/__tests__/route.test.ts @@ -61,7 +61,13 @@ function makeRequest( ): NextRequest { return new NextRequest("http://localhost/api/auth/oidc/native/token", { method: "POST", - headers: { "content-type": "application/json", ...headers }, + // The genuine iOS client always marks itself native; the transport gate + // requires it. A gate-rejection test overrides `x-client-type` explicitly. + headers: { + "content-type": "application/json", + "x-client-type": "native", + ...headers, + }, body: typeof body === "string" ? body : JSON.stringify(body), }); } diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts index 24e6ed179..d0bd4674e 100644 --- a/src/app/api/auth/oidc/native/token/route.ts +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -31,6 +31,7 @@ import { prisma } from "@/lib/db"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { isCookielessNativeCaller } from "@/lib/auth/native-client"; +import { isNativeClientRequest } from "@/lib/auth/issue-token"; import { finishLogin } from "@/lib/auth/login-response"; import { consumeNativeHandoff, @@ -63,10 +64,17 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - // (2) Transport gate — this endpoint can only ever hand a bundle to a - // genuine cookie-less native caller. A browser (Mozilla UA or an inbound - // session cookie) is rejected before any state is touched. - if (!isCookielessNativeCaller(request.headers)) { + // (2) Transport gate — this endpoint only ever hands a native token bundle. + // A browser (Mozilla UA or an inbound session cookie) is rejected, AND the + // caller must present an explicit native marker (`isNativeClientRequest`, the + // SAME predicate `finishLogin` uses to pick its bearer branch) — otherwise a + // blank-UA caller could slip past `isCookielessNativeCaller` into the cookie + // branch and be handed a `Set-Cookie` session instead of the bundle. Both + // together make the cookie path structurally unreachable here. + if ( + !isCookielessNativeCaller(request.headers) || + !isNativeClientRequest(request.headers) + ) { annotate({ action: { name: "auth.oidc.native.token.wrong_transport" } }); return invalidCode(); } @@ -93,11 +101,12 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.oidc.native.token.not_found" } }); break; case "replayed": - // Containment (revoke the issued pair) + the audit row already ran - // inside `consumeNativeHandoff`. + // Containment + the audit row already ran inside `consumeNativeHandoff`; + // report whether the issued pair was actually revoked (false only in the + // sub-ms pre-stamp window). annotate({ action: { name: "auth.oidc.native.handoff_replay" }, - meta: { revoked_issued_pair: true }, + meta: { revoked_issued_pair: result.revokedIssuedPair }, }); break; case "expired": diff --git a/src/lib/auth/oidc-native-handoff.ts b/src/lib/auth/oidc-native-handoff.ts index bd37a8f64..b14c7a6e6 100644 --- a/src/lib/auth/oidc-native-handoff.ts +++ b/src/lib/auth/oidc-native-handoff.ts @@ -114,7 +114,7 @@ export async function mintNativeHandoff(input: { export type ConsumeHandoffResult = | { status: "ok"; userId: string; handoffId: string } | { status: "not_found" } - | { status: "replayed"; userId: string } + | { status: "replayed"; userId: string; revokedIssuedPair: boolean } | { status: "expired" } | { status: "pkce_mismatch" } | { status: "race_lost" }; @@ -145,14 +145,21 @@ export async function consumeNativeHandoff( // Revoke exactly the pair the first exchange issued and audit; the code // itself is already spent, so no further consume is needed. if (row.consumedAt !== null) { + // `issuedRefreshTokenHash` is null only in the sub-millisecond window + // between the consume and the post-`finishLogin` stamp; a replay there + // finds no pair to revoke (the legitimate pair stays live — a later replay + // once stamped triggers containment). Report what actually happened so the + // wide event never claims a revoke that did not occur. + let revokedIssuedPair = false; if (row.issuedRefreshTokenHash) { await revokeRefreshTokenByHash(row.issuedRefreshTokenHash); + revokedIssuedPair = true; } await auditLog("auth.oidc.native.handoff_replay", { userId: row.userId, ipAddress: row.ipAddress, }); - return { status: "replayed", userId: row.userId }; + return { status: "replayed", userId: row.userId, revokedIssuedPair }; } // Expiry: enforced at read. Burn the row so a lingering expired code has no From c2420d7f0258bcf572b0584062711c615ad227ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 17:03:35 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.30.11=20=E2=80=94=20na?= =?UTF-8?q?tive=20OIDC=20SSO=20sign-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c3e0b84..e35f0557f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.30.11] — 2026-07-18 + +Native single sign-on. + +- **The iOS app can sign in through your identity provider (OIDC SSO).** Until now, single sign-on only worked in the browser — the native app couldn't complete it, which locked native users out entirely on an SSO-only instance. The server now supports a native sign-in handoff (an in-app authorization flow that returns the app's normal token, never a cookie), so the app can offer SSO once it adopts the flow. Existing password and passkey sign-in are unchanged. Multi-factor sign-in still applies, and an SSO session never satisfies a step-up prompt — those still require your own second factor. + +No breaking changes. + ## [1.30.10] — 2026-07-18 Localized insight text, and a richer workout view. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 47c49f594..0e21fe47a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.10 + version: 1.30.11 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 04cdff980..bab013441 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.10", + "version": "1.30.11", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 8ec0244fc..cb9fda04d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.10"; + /* @sw-version-fallback */ "v1.30.11"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`