Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | 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(
Expand All @@ -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(() => {});
Expand Down
14 changes: 12 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
47 changes: 45 additions & 2 deletions src/login.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -80,6 +96,7 @@ export async function login(
serviceLoginUrl: string,
onStatus?: LoginStatusCallback,
providerOverride?: Provider,
options?: LoginOptions,
): Promise<LoginResult> {
const log = onStatus ?? console.log;

Expand Down Expand Up @@ -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 {
Expand Down
131 changes: 124 additions & 7 deletions src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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(/&quot;/g, '"').replace(/&amp;/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;
Expand All @@ -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(/&quot;/g, '"').replace(/&amp;/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)
Expand Down Expand Up @@ -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<string, string> = {
"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`,
);
},
};
},
};
Expand Down
Loading
Loading