mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #535 from MBombeck/release/v1.30.11
release/v1.30.11 — native OIDC SSO sign-in
This commit is contained in:
24 files changed
+1625
-73
No files matched your search
@@ -115,10 +115,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=<S256>` 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
|
||||
|
||||
|
||||
+65
-1
@@ -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.
|
||||
|
||||
@@ -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=<S256>` 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:
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
`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, unknown> = {}): 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).state === "string" &&
|
||||
typeof (value as Record<string, unknown>).nonce === "string" &&
|
||||
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
|
||||
typeof (value as Record<string, unknown>).next === "string"
|
||||
);
|
||||
const v = value as Record<string, unknown>;
|
||||
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=<reason>` 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");
|
||||
}
|
||||
});
|
||||
@@ -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<ReturnType<typeof GET>>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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=<reason>` 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,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
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<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/oidc/native/token", {
|
||||
method: "POST",
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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 { isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
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 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();
|
||||
}
|
||||
|
||||
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 + 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: result.revokedIssuedPair },
|
||||
});
|
||||
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;
|
||||
});
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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})` },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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, string>): 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<MintedHandoff> {
|
||||
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; revokedIssuedPair: boolean }
|
||||
| { 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<ConsumeHandoffResult> {
|
||||
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) {
|
||||
// `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, revokedIssuedPair };
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
await prisma.oidcNativeHandoff.update({
|
||||
where: { id: handoffId },
|
||||
data: { issuedRefreshTokenHash: hashToken(refreshToken) },
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<boolean> {
|
||||
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. */
|
||||
|
||||
@@ -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<number> {
|
||||
const { count } = await prisma.oidcNativeHandoff.deleteMany({
|
||||
where: { expiresAt: { lt: now } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
@@ -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<OidcNativeHandoffCleanupPayload>[],
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<OidcNativeHandoffCleanupPayload>(
|
||||
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<FitbitSyncPayload>(
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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=<S256>` 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"],
|
||||
|
||||
@@ -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<typeof oidcNativeTokenSchema>;
|
||||
Reference in new issue
Block a user