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/); + }); +});