From 3c44f3d16705ea7166eeb6c14598bbdb5440eeca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:45:59 +0200 Subject: [PATCH] fix(auth): ask the provider for a fresh sign-in when claiming a profile --- .../auth/oidc/login/__tests__/route.test.ts | 46 ++++++++++++++++++- src/app/api/auth/oidc/login/route.ts | 2 + src/lib/auth/__tests__/oidc.test.ts | 17 +++++++ src/lib/auth/oidc.ts | 8 ++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/oidc/login/__tests__/route.test.ts b/src/app/api/auth/oidc/login/__tests__/route.test.ts index 1d9fc1584..cf283fb6a 100644 --- a/src/app/api/auth/oidc/login/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/login/__tests__/route.test.ts @@ -40,9 +40,25 @@ vi.mock("@/lib/auth/oidc", async () => { }; }); +vi.mock("@/lib/managed-profiles/claim-cookie", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/managed-profiles/claim-cookie") + >()), + readClaimCookie: vi.fn(), +})); +vi.mock("@/lib/managed-profiles/handover", () => ({ + isHandoverUsable: vi.fn(), +})); + import { GET } from "../route"; +import { readClaimCookie } from "@/lib/managed-profiles/claim-cookie"; +import { isHandoverUsable } from "@/lib/managed-profiles/handover"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; -import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc"; +import { + buildAuthorizationUrl, + discoverOidcMetadata, + getOidcConfig, +} from "@/lib/auth/oidc"; function makeRequest(path = "/api/auth/oidc/login"): NextRequest { return new NextRequest(`http://localhost${path}`); @@ -248,6 +264,34 @@ describe("GET /api/auth/oidc/login?intent=claim (v1.43, #959)", () => { expect(res.cookies.get("oidc_auth_state")).toBeUndefined(); }); + it("asks the provider for a fresh sign-in on a claim, and only there", async () => { + 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", + }); + vi.mocked(buildAuthorizationUrl).mockReturnValue( + "https://idp.example.com/authorize?x=1", + ); + vi.mocked(readClaimCookie).mockReturnValue({ + handoverId: "handover-1", + binding: "binding-1", + } as never); + vi.mocked(isHandoverUsable).mockResolvedValue(true); + + await GET(makeRequest("/api/auth/oidc/login?intent=claim")); + expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({ + forceLogin: true, + }); + + vi.mocked(buildAuthorizationUrl).mockClear(); + await GET(makeRequest("/api/auth/oidc/login")); + expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({ + forceLogin: false, + }); + }); + it("routes the claim's other failures to the claim page too", async () => { vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ allowed: false, diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index dd0dc1cc8..f783e171c 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -156,6 +156,8 @@ export const GET = apiHandler(async (req: NextRequest) => { nonce, codeChallenge, redirectUri, + // A claim only; an ordinary sign-in keeps single sign-on. + forceLogin: claimBinding !== null, }); const response = NextResponse.redirect(authorizationUrl); diff --git a/src/lib/auth/__tests__/oidc.test.ts b/src/lib/auth/__tests__/oidc.test.ts index dbcadf1a1..d2f821cdc 100644 --- a/src/lib/auth/__tests__/oidc.test.ts +++ b/src/lib/auth/__tests__/oidc.test.ts @@ -253,6 +253,23 @@ describe("buildAuthorizationUrl", () => { expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); expect(url.searchParams.get("scope")).toBe(config.scopes); + expect(url.searchParams.has("prompt")).toBe(false); + }); + + it("asks for a fresh sign-in only when told to", () => { + configureEnv(); + const url = new URL( + buildAuthorizationUrl({ + metadata: METADATA_DOC, + config: getOidcConfig()!, + state: "state-1", + nonce: "nonce-1", + codeChallenge: "challenge-1", + redirectUri: "https://healthlog.example.com/api/auth/oidc/callback", + forceLogin: true, + }), + ); + expect(url.searchParams.get("prompt")).toBe("login"); }); }); diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts index 3282bd95e..1c5b86f56 100644 --- a/src/lib/auth/oidc.ts +++ b/src/lib/auth/oidc.ts @@ -237,6 +237,13 @@ export function buildAuthorizationUrl(params: { nonce: string; codeChallenge: string; redirectUri: string; + /** + * Ask the provider to authenticate afresh (`prompt=login`, OpenID Connect + * Core 3.1.2.1) instead of reusing the browser's provider session. A claim + * hands a profile to whoever signs in, so it must be the person at the + * keyboard now, not a session someone else left open in this browser. + */ + forceLogin?: boolean; }): string { const url = new URL(params.metadata.authorization_endpoint); url.search = new URLSearchParams({ @@ -248,6 +255,7 @@ export function buildAuthorizationUrl(params: { nonce: params.nonce, code_challenge: params.codeChallenge, code_challenge_method: "S256", + ...(params.forceLogin ? { prompt: "login" } : {}), }).toString(); return url.toString(); }