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;