From 4fc973268f2723904c45454137b827eab2331a78 Mon Sep 17 00:00:00 2001 From: Allan Kimmer Jensen Date: Mon, 15 Jun 2026 13:46:35 +0200 Subject: [PATCH] feat(criipto): handle DanishMitID CprEntry exchange screen Criipto now interposes a CPR-entry screen after the auth-code callback when the OIDC scope includes "ssn": DKMitId/Exchange returns a 200 React page instead of a 3xx, so followRedirects stalled there and never reached the relying-party session. Add an optional `advance` hook to ProviderSession; after the exchange the login flow loops it until the broker stops handing back screens. Criipto's advance submits the CPR (threaded from the resolved identity) to the screen's form and follows the redirect on to the RP callback. It's a no-op for brokers that don't interpose screens, so nemlogin and directMitid are unaffected. --- README.md | 29 +++++++ src/cli.ts | 14 +++- src/index.ts | 14 +++- src/login.ts | 47 ++++++++++- src/providers.ts | 131 +++++++++++++++++++++++++++-- test/providers.test.ts | 185 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 408 insertions(+), 12 deletions(-) create mode 100644 test/providers.test.ts diff --git a/README.md b/README.md index 27ba900..2635f48 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,35 @@ const myProvider: Provider = { const result = await login('username', 'https://my-service.com/login', console.log, myProvider); ``` +If your broker shows extra screens *after* the auth-code callback (Criipto, for +example, can ask for a CPR number when the OIDC scope includes `ssn`), add an +optional **`advance`** hook. After the auth code is exchanged, the login flow +follows redirects and then repeatedly calls `advance` with the page it landed +on. Return the next URL to follow, or `null` once you've reached the relying +party's own page: + +```typescript +advance: async (page, cookies, context) => { + // page.body is the HTML the redirect chain stopped on; inspect it to decide + // whether the broker is asking for more input. + if (/* not one of my broker's screens */) return null; + + // e.g. submit the CPR the broker is asking for and hand back the redirect. + const resp = await fetch(formAction, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `cpr=${context.cpr}`, + redirect: 'manual', + }); + const location = resp.headers.get('location'); + if (!location) throw new Error('Broker did not redirect after CPR submit'); + return { redirectUrl: location, screen: 'CprEntry' }; +}, +``` + +`context.cpr` is populated from the resolved test identity, so screens that ask +for a CPR can be completed without prompting. + PRs adding new providers to `src/providers.ts` are welcome. ## Requirements diff --git a/src/cli.ts b/src/cli.ts index 75d6a30..6660d53 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -223,9 +223,14 @@ const loginCmd = defineCommand({ stderr(`Logging in as ${args.query} to ${new URL(serviceUrl).hostname}...`); + // The CPR is needed by brokers that ask for it after auth (e.g. Criipto + // with an "ssn" scope); we read it off the resolved identity. + let cpr: string | undefined; let approvePromise: Promise | undefined; if (!args["no-approve"]) { + // Fail fast on an unknown identity, as before auto-approve existed. const { identity, codeApp } = await resolve(username, baseUrl); + cpr = identity.cprNumber; if (codeApp) { stderr("Auto-approving in background...\n"); approvePromise = approve( @@ -241,12 +246,19 @@ const loginCmd = defineCommand({ ); } } else { + // With external approval the CPR is best-effort: don't fail the flow if + // the lookup fails (the broker may not even ask for it). + cpr = await resolve(username, baseUrl) + .then((r) => r.identity.cprNumber) + .catch(() => undefined); stderr( `Run 'mitid approve ${args.query}' in another terminal to auto-approve.\n`, ); } - const result = await login(username, serviceUrl, stderr); + const result = await login(username, serviceUrl, stderr, undefined, { + cpr, + }); if (approvePromise) { await approvePromise.catch(() => {}); diff --git a/src/index.ts b/src/index.ts index 3f5a69a..fd311a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,9 +17,19 @@ export { searchIdentity, simulatorUrl, } from "./identity.js"; -export type { LoginResult, LoginStatusCallback } from "./login.js"; +export type { + LoginOptions, + LoginResult, + LoginStatusCallback, +} from "./login.js"; export { login } from "./login.js"; -export type { CookieJar, Provider, ProviderSession } from "./providers.js"; +export type { + CookieJar, + ExchangePage, + LoginContext, + Provider, + ProviderSession, +} from "./providers.js"; export { detectProvider, getProvider, listProviders } from "./providers.js"; export type { SimulatorStatusCallback } from "./simulator.js"; export { approve, watch } from "./simulator.js"; diff --git a/src/login.ts b/src/login.ts index 3fca0f3..e063cd7 100644 --- a/src/login.ts +++ b/src/login.ts @@ -1,11 +1,17 @@ // MitID login flow orchestration // Auto-detects the broker/provider and handles the full OAuth → MitID → session flow import { MitIDClient } from "./client.js"; -import type { CookieJar, Provider } from "./providers.js"; +import type { CookieJar, LoginContext, Provider } from "./providers.js"; import { detectProvider, listProviders } from "./providers.js"; export type LoginStatusCallback = (message: string) => void; +export interface LoginOptions { + // CPR number, needed by brokers that ask for it after auth (e.g. Criipto + // when the OIDC scope includes "ssn"). + cpr?: string; +} + export interface LoginResult { cookies: CookieJar; body: string; @@ -52,6 +58,16 @@ export async function followRedirects( } } + // Set MITID_DEBUG to trace the redirect chain; invaluable when a broker + // changes its flow (as Criipto did by adding the CPR-entry screen). + if (process.env.MITID_DEBUG) + console.error( + `[hop] ${resp.status} ${currentUrl}` + + (resp.headers.get("location") + ? ` -> ${resp.headers.get("location")}` + : ""), + ); + if (resp.status >= 300 && resp.status < 400) { const location = resp.headers.get("location"); if (!location) throw new Error("Redirect without location header"); @@ -80,6 +96,7 @@ export async function login( serviceLoginUrl: string, onStatus?: LoginStatusCallback, providerOverride?: Provider, + options?: LoginOptions, ): Promise { const log = onStatus ?? console.log; @@ -124,7 +141,33 @@ export async function login( log("Exchanging auth code..."); const { redirectUrl } = await session.exchange(authCode, cookies); - const final = await followRedirects(redirectUrl, cookies); + let final = await followRedirects(redirectUrl, cookies); + + // Some brokers interpose extra screens between the auth-code callback and the + // relying-party callback (e.g. Criipto's CPR entry when the scope includes + // "ssn"), served as a 200 page that `followRedirects` can't advance on its + // own. Let the provider drive through them until we reach the final page. + const context: LoginContext = { cpr: options?.cpr }; + const maxScreens = 5; + let settled = !session.advance; + for (let i = 0; session.advance && i < maxScreens; i++) { + const next = await session.advance( + { url: final.finalUrl, body: final.body }, + final.cookies, + context, + ); + if (!next) { + settled = true; + break; + } + log(`Completing broker screen: ${next.screen}`); + final = await followRedirects(next.redirectUrl, final.cookies); + } + if (!settled) + throw new Error( + `Broker did not reach a final page after ${maxScreens} screens`, + ); + log("Login complete!"); return { diff --git a/src/providers.ts b/src/providers.ts index d4e23e9..2ca35d2 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -6,6 +6,19 @@ export interface CookieJar { [key: string]: string; } +// Data the broker may need to complete an intermediate screen after the +// auth-code callback (e.g. Criipto's CPR entry when the scope includes ssn). +export interface LoginContext { + cpr?: string; +} + +// A page reached while following the post-callback redirect chain. Passed to a +// provider's `advance` so it can decide whether more steps are required. +export interface ExchangePage { + url: string; + body: string; +} + export interface ProviderSession { clientHash: string; authenticationSessionId: string; @@ -17,6 +30,14 @@ export interface ProviderSession { ) => Promise<{ redirectUrl: string; }>; + // Optional hook for brokers that interpose extra screens between the + // auth-code callback and the relying-party callback. Returns the next URL to + // follow, or null when `page` is already the final (non-broker) page. + advance?: ( + page: ExchangePage, + cookies: CookieJar, + context: LoginContext, + ) => Promise<{ redirectUrl: string; screen: string } | null>; } export interface Provider { @@ -33,12 +54,48 @@ export interface Provider { interface CriiptoBootstrapData { screen?: { + // The screen the broker wants the user to complete, e.g. + // "DanishMitID/CprEntry". Absent on the relying-party's own pages. + screen?: string; rendition?: { coreClientScriptSource?: string; + // Server endpoint the screen's form POSTs to (may be relative). + formAction?: string; + error?: string | null; }; }; } +// Criipto renders its screens client-side from a base64-ish JSON blob in +// data-bootstrap. Returns null when the page carries no such blob (i.e. it is +// the relying party's page, not a Criipto screen). +function parseCriiptoBootstrap(body: string): CriiptoBootstrapData | null { + const match = body.match(/data-bootstrap="([^"]+)"/); + if (!match) return null; + try { + return JSON.parse( + match[1]!.replace(/"/g, '"').replace(/&/g, "&"), + ) as CriiptoBootstrapData; + } catch { + return null; + } +} + +function cookieHeader(cookies: CookieJar): string { + return Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join("; "); +} + +function mergeSetCookies(resp: Response, cookies: CookieJar): void { + for (const sc of resp.headers.getSetCookie?.() ?? []) { + const [pair] = sc.split(";"); + if (!pair) continue; + const [name, ...rest] = pair.split("="); + if (name) cookies[name.trim()] = rest.join("=").trim(); + } +} + interface CriiptoClientData { CoreClientAux: string; CallbackEndpoint: string; @@ -61,13 +118,8 @@ const criipto: Provider = { body.includes("coreClientScriptSource"), async bootstrap(url, body, cookies) { - const bootstrapMatch = body.match(/data-bootstrap="([^"]+)"/); - if (!bootstrapMatch) - throw new Error("Could not find Criipto bootstrap data"); - - const data = JSON.parse( - bootstrapMatch[1]!.replace(/"/g, '"').replace(/&/g, "&"), - ) as CriiptoBootstrapData; + const data = parseCriiptoBootstrap(body); + if (!data) throw new Error("Could not find Criipto bootstrap data"); const coreClientUrl = data.screen?.rendition?.coreClientScriptSource; if (!coreClientUrl) @@ -105,6 +157,71 @@ const criipto: Provider = { cbUrl.searchParams.set("code", authCode); return { redirectUrl: cbUrl.href }; }, + + advance: async (page, advanceCookies, context) => { + const data = parseCriiptoBootstrap(page.body); + const screen = data?.screen?.screen; + // No Criipto screen blob means we've left the broker and reached the + // relying party's page; nothing left to advance. + if (!screen) return null; + + // Criipto requests the CPR when the OIDC scope includes "ssn". The + // rendered screen is a plain form POST back to the broker. + if (screen === "DanishMitID/CprEntry") { + if (!context.cpr) + throw new Error( + "Criipto requested a CPR number (scope includes 'ssn') but none was provided", + ); + + const formAction = data?.screen?.rendition?.formAction; + if (!formAction) + throw new Error("Criipto CPR screen has no formAction"); + + // formAction is trusted: it comes from the broker-issued bootstrap + // blob on the page we were redirected to, the same broker we sent the + // CPR-bearing user to. Resolved relative to that page's origin. + const postUrl = new URL(formAction, page.url); + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + Origin: postUrl.origin, + Referer: page.url, + }; + const cookieStr = cookieHeader(advanceCookies); + if (cookieStr) headers.Cookie = cookieStr; + + const resp = await fetch(postUrl.href, { + method: "POST", + headers, + body: `cpr=${encodeURIComponent(context.cpr)}`, + redirect: "manual", + }); + mergeSetCookies(resp, advanceCookies); + + if (resp.status < 300 || resp.status >= 400) { + const errBody = await resp.text(); + const err = + parseCriiptoBootstrap(errBody)?.screen?.rendition?.error; + throw new Error( + `Criipto CPR submission failed (${resp.status})${err ? `: ${err}` : ""}`, + ); + } + + const location = resp.headers.get("location"); + if (!location) + throw new Error("Criipto CPR redirect without location header"); + + return { redirectUrl: new URL(location, postUrl).href, screen }; + } + + throw new Error( + `Unhandled Criipto screen after callback: ${screen}. ` + + `Please open an issue: https://github.com/Saturate/mitid-cli/issues`, + ); + }, }; }, }; diff --git a/test/providers.test.ts b/test/providers.test.ts new file mode 100644 index 0000000..7f47c09 --- /dev/null +++ b/test/providers.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type CookieJar, + detectProvider, + getProvider, + type ProviderSession, +} from "../src/providers.js"; + +const ORIGIN = "https://oidc-login-test.example.com"; + +// Minimal AuxData blob the Criipto bootstrap expects to base64-decode. +const aux = Buffer.from( + JSON.stringify({ + coreClient: { checksum: Buffer.from("checksum").toString("base64") }, + parameters: { + authenticationSessionId: "session-123", + apiUrl: "https://core.example.com/mitid-core-client-backend/v1/", + }, + }), +).toString("utf-8"); + +function bootstrapHtml(): string { + const data = { + screen: { + rendition: { + coreClientScriptSource: `${ORIGIN}/coreclient`, + }, + }, + }; + const blob = JSON.stringify(data) + .replace(/&/g, "&") + .replace(/"/g, """); + return `
`; +} + +function cprScreenHtml(): string { + const data = { + scenario: "auth", + screen: { + screen: "DanishMitID/CprEntry", + rendition: { formAction: "/DKMitId/CprEntry?cs_v1=abc", error: null }, + }, + }; + const blob = JSON.stringify(data) + .replace(/&/g, "&") + .replace(/"/g, """); + return `
`; +} + +async function bootstrapCriipto(): Promise { + const provider = getProvider("Criipto"); + if (!provider) throw new Error("Criipto provider missing"); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify({ + CoreClientAux: Buffer.from(aux).toString("base64"), + CallbackEndpoint: `${ORIGIN}/callback`, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + return provider.bootstrap(`${ORIGIN}/page`, bootstrapHtml(), {}); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("detectProvider", () => { + it("detects Criipto from the coreClientScriptSource marker", () => { + const p = detectProvider(`${ORIGIN}/page`, bootstrapHtml()); + expect(p?.name).toBe("Criipto"); + }); +}); + +describe("Criipto exchange", () => { + it("appends the auth code to the callback endpoint", async () => { + const session = await bootstrapCriipto(); + const { redirectUrl } = await session.exchange("the-code", {}); + expect(redirectUrl).toBe(`${ORIGIN}/callback?code=the-code`); + }); +}); + +describe("Criipto advance", () => { + it("returns null for a non-broker (relying-party) page", async () => { + const session = await bootstrapCriipto(); + const result = await session.advance?.( + { url: `${ORIGIN}/done`, body: "logged in" }, + {}, + {}, + ); + expect(result).toBeNull(); + }); + + it("submits the CPR and follows the redirect off the CprEntry screen", async () => { + const session = await bootstrapCriipto(); + const cookies: CookieJar = { existing: "1" }; + + const postSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { + location: "https://rp.example.com/connect/authorize/callback?code=x", + "set-cookie": "broker-session=abc; Path=/", + }, + }), + ); + + const result = await session.advance?.( + { url: `${ORIGIN}/DKMitId/Exchange?cs_v1=abc`, body: cprScreenHtml() }, + cookies, + { cpr: "0401900004" }, + ); + + expect(result).toEqual({ + redirectUrl: "https://rp.example.com/connect/authorize/callback?code=x", + screen: "DanishMitID/CprEntry", + }); + + // Posts the CPR form-encoded to the screen's formAction (resolved absolute). + const [calledUrl, init] = postSpy.mock.calls.at(-1)!; + expect(calledUrl).toBe(`${ORIGIN}/DKMitId/CprEntry?cs_v1=abc`); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe("cpr=0401900004"); + + // Folds the broker's Set-Cookie back into the shared jar. + expect(cookies["broker-session"]).toBe("abc"); + }); + + it("throws when the CprEntry screen needs a CPR but none was provided", async () => { + const session = await bootstrapCriipto(); + await expect( + session.advance?.( + { url: `${ORIGIN}/DKMitId/Exchange?cs_v1=abc`, body: cprScreenHtml() }, + {}, + {}, + ), + ).rejects.toThrow(/CPR/); + }); + + it("throws a named error for an unhandled broker screen", async () => { + const session = await bootstrapCriipto(); + const data = { screen: { screen: "DanishMitID/SomeNewScreen" } }; + const blob = JSON.stringify(data) + .replace(/&/g, "&") + .replace(/"/g, """); + await expect( + session.advance?.( + { + url: `${ORIGIN}/screen`, + body: `
`, + }, + {}, + { cpr: "0401900004" }, + ), + ).rejects.toThrow(/DanishMitID\/SomeNewScreen/); + }); + + it("surfaces the screen error when the CPR POST does not redirect", async () => { + const session = await bootstrapCriipto(); + const errData = { + screen: { + screen: "DanishMitID/CprEntry", + rendition: { error: "Invalid CPR" }, + }, + }; + const errBlob = JSON.stringify(errData) + .replace(/&/g, "&") + .replace(/"/g, """); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(`
`, { status: 200 }), + ); + + await expect( + session.advance?.( + { url: `${ORIGIN}/DKMitId/Exchange?cs_v1=abc`, body: cprScreenHtml() }, + {}, + { cpr: "0401900004" }, + ), + ).rejects.toThrow(/Invalid CPR/); + }); +});