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
142 changes: 142 additions & 0 deletions packages/core/src/rp-login/step-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,148 @@ export interface StepUpVtaFinishResult {
sessionId: string;
}

/** What the consent surface may show the human for a step-up. Every member is
* taken from *inside* the verified approve-request document (or is the
* page-supplied `rpDid` after it has been checked equal to the proven
* issuer) — nothing here predates verification. */
export interface StepUpConsentContext {
/** The proven signer of the approve-request (== the page's `rpDid`). */
issuer: string;
/** The session subject being elevated, from the verified payload. */
subject: string;
/** The RP session being elevated, from the verified payload. */
sessionId: string;
/** The RP's human-readable reason, from the verified payload. Absent when
* the signed document carried none — the prompt then falls back to its
* origin/rpDid-only text. */
reason?: string;
}

export interface PerformStepUpVtaArgs {
baseUrl: string;
accessToken: string;
/** The wallet's signing identity — must be the DID the RP session
* authenticated as (it signs the approve-response). */
signing: SigningIdentity;
/** The RP DID the page claimed. The verified approve-request's issuer must
* equal it, and the approve-response is audience-bound to it. */
rpDid: string;
/** Executors this wallet is enrolled with; the approve-request's proven
* signer must be in this set. */
enrolledExecutorDids: readonly string[];
/**
* Ask the human. Called ONLY after the signed approve-request verified —
* the `reason` it receives comes from inside the signature, which is what
* lets the prompt show it at all (spec: "consumers MUST verify the proof
* BEFORE surfacing the reason"). Return `false` to decline: nothing is
* signed and nothing is sent to the RP — the pending challenge simply
* lapses server-side.
*/
requestConsent: (ctx: StepUpConsentContext) => Promise<boolean>;
fetchFn?: typeof fetch;
/** Timing hook — called as each flow step completes. */
onMark?: (label: string) => void;
/** Defaults to now. Injected for tests. */
now?: Date;
}

export type PerformStepUpVtaResult =
| { ok: true; tokens: StepUpVtaFinishResult }
| {
ok: false;
error: string;
/** True when the human declined the prompt (as opposed to the
* approve-request being refused before any prompt was shown). */
declined: boolean;
};

/**
* The whole holder-side step-up flow, in its enforced order:
*
* 1. RP `start` (REST) → the signed `approve-request` document
* 2. verify it ({@link verifyStepUpApproveRequest}) + issuer == `rpDid`
* 3. `requestConsent` — the human decides on the VERIFIED reason
* 4. only on approval: sign the `approve-response` and `finish` (REST)
*
* The consent prompt deliberately sits *inside* this function, between
* verification and signing: before it, and the human would be deciding on
* words nobody has authenticated; after it, and the wallet would have signed
* before anyone consented. A decline sends nothing — the RP's challenge
* expires on its own TTL, so the prompt must be answered within the
* challenge's validity window.
*/
export async function performStepUpVta(
args: PerformStepUpVtaArgs,
): Promise<PerformStepUpVtaResult> {
const mark = args.onMark ?? (() => {});
const refuse = (error: string): PerformStepUpVtaResult => ({
ok: false,
error,
declined: false,
});

// 1. RP start (REST) → the signed `auth/step-up/approve-request/0.2`
// Trust-Task document (plus legacy top-level fields for cross-checking).
const start = await stepUpVtaStart(args.baseUrl, args.accessToken, args.fetchFn);
mark("rp start (challenge)");

// 2. Verify BEFORE acting on anything in it — a start response with no
// `document`, a bad proof, or a signer outside the enrolled-executor set
// is refused here, and the human never sees a prompt.
const verified = await verifyStepUpApproveRequest(start, {
enrolledExecutorDids: args.enrolledExecutorDids,
...(args.now ? { now: args.now } : {}),
});
if (!verified.ok) {
return refuse(`step-up approve-request refused: ${verified.reason}`);
}
// The RP the page named is the audience the approve-response will be bound
// to (`recipient: rpDid`); the approve-request's proven issuer must be that
// same party, or the wallet would be answering a question nobody it trusts
// asked.
if (verified.issuer !== args.rpDid) {
return refuse(
`step-up approve-request refused: issuer ${verified.issuer} does not match the page-supplied rpDid`,
);
}
mark("verify approve-request");

// 3. The human decides, on fields that came from inside the signature.
const consented = await args.requestConsent({
issuer: verified.issuer,
subject: verified.request.subject,
sessionId: verified.request.sessionId,
...(typeof verified.request.reason === "string"
? { reason: verified.request.reason }
: {}),
});
if (!consented) {
// Declined = nothing leaves the wallet. No denied approve-response is
// sent; the RP's pending challenge lapses on its TTL.
return { ok: false, error: "step-up denied by user", declined: true };
}
mark("user consent");

// 4. Sign the approve-response/0.2 locally (holder-self-signs — no VTA
// round-trip). Every echoed field comes from the *verified* payload.
const approval = await buildStepUpApproval({
signing: args.signing,
rpDid: args.rpDid,
request: verified.request,
approved: true,
});
mark("sign approval");

const tokens = await stepUpVtaFinish(
args.baseUrl,
args.accessToken,
approval,
args.fetchFn,
);
mark("rp finish (elevate)");
return { ok: true, tokens };
}

/**
* Step 3 — RP finish. Submits the signed `approve-response/0.2` document and
* returns the elevated session tokens. Response body is **snake_case**.
Expand Down
163 changes: 163 additions & 0 deletions packages/core/tests/rp-login.step-up.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import assert from "node:assert/strict";

import {
buildStepUpApproval,
performStepUpVta,
verifyStepUpApproveRequest,
verifyTrustTaskProof,
generateSigningIdentity,
Expand Down Expand Up @@ -194,3 +195,165 @@ test("verifyStepUpApproveRequest: a lapsed request is refused", async () => {
assert.equal(res.ok, false);
assert.match(res.reason, /lapsed/);
});

// ── performStepUpVta: the whole flow, in its enforced order ──────────────────
//
// start → verify → CONSENT → sign → finish. The consent callback stands in
// for the human: it must be shown only post-verification content (the reason
// from inside the signature), a decline must send nothing to the RP, and a
// refused approve-request must never reach it at all.

/** Mock the RP's two REST endpoints; records every request it serves. */
function mockRp(startBody) {
const calls = [];
const fetchFn = async (url, init) => {
const u = String(url);
calls.push({ url: u, body: init?.body ? JSON.parse(init.body) : undefined });
if (u.endsWith("/auth/step-up/vta/start")) {
return new Response(JSON.stringify(startBody), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (u.endsWith("/auth/step-up/vta/finish")) {
return new Response(
JSON.stringify({
session_id: "sess-42",
access_token: "elevated-access",
refresh_token: "elevated-refresh",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
};
return { fetchFn, calls };
}

function flowArgs(holder, fetchFn, requestConsent) {
return {
baseUrl: "https://rp.example",
accessToken: "aal1-token",
signing: holder,
rpDid: RP.did,
enrolledExecutorDids: [RP.did],
fetchFn,
requestConsent,
};
}

test("performStepUpVta: consent sees the reason from INSIDE the signed document, then sign+finish", async () => {
const holder = generateSigningIdentity();
const start = await startResponse();
// Tamper the unsigned top-level reason — the human must never see this copy.
start.reason = "Totally harmless, click approve.";
const { fetchFn, calls } = mockRp(start);

const seen = [];
const res = await performStepUpVta(
flowArgs(holder, fetchFn, async (ctx) => {
seen.push(ctx);
return true;
}),
);

assert.equal(res.ok, true, res.ok ? undefined : res.error);
assert.equal(res.tokens.accessToken, "elevated-access");
assert.equal(res.tokens.refreshToken, "elevated-refresh");
assert.equal(res.tokens.sessionId, "sess-42");

// The prompt content is the verified payload, not the unsigned echo.
assert.equal(seen.length, 1);
assert.equal(seen[0].reason, "Confirm the transfer of $1,000 to ACME Corp.");
assert.equal(seen[0].issuer, RP.did);
assert.equal(seen[0].subject, "did:key:zSubject");
assert.equal(seen[0].sessionId, "sess-42");

// finish carried a signed approve-response echoing only verified fields.
const finish = calls.find((c) => c.url.endsWith("/finish"));
assert.ok(finish, "finish was called");
assert.equal(finish.body.type, APPROVE_RESPONSE_TYPE);
assert.equal(finish.body.payload.decision, "approved");
assert.equal(finish.body.payload.challenge, "a".repeat(32));
const proofCheck = await verifyTrustTaskProof(finish.body, {
expectedProofPurpose: "assertionMethod",
});
assert.equal(proofCheck.verified, true, proofCheck.reason);
assert.equal(proofCheck.signer, holder.did);
});

test("performStepUpVta: a document with no reason still prompts — with no reason member", async () => {
const holder = generateSigningIdentity();
const start = await startResponse({ unsigned: true, legacy: false });
delete start.document.payload.reason; // the RP signed a payload with no reason
await signTrustTask({ envelope: start.document, signing: RP });
const { fetchFn } = mockRp(start);

const seen = [];
const res = await performStepUpVta(
flowArgs(holder, fetchFn, async (ctx) => {
seen.push(ctx);
return true;
}),
);
assert.equal(res.ok, true, res.ok ? undefined : res.error);
assert.equal(seen.length, 1);
assert.equal("reason" in seen[0], false);
});

test("performStepUpVta: declined prompt sends NOTHING to the RP", async () => {
const holder = generateSigningIdentity();
const { fetchFn, calls } = mockRp(await startResponse());

const res = await performStepUpVta(flowArgs(holder, fetchFn, async () => false));

assert.equal(res.ok, false);
assert.equal(res.declined, true);
assert.match(res.error, /denied by user/);
// Only the start fetch happened — no finish, no denied approve-response.
assert.deepEqual(
calls.map((c) => c.url),
["https://rp.example/auth/step-up/vta/start"],
);
});

test("performStepUpVta: missing document refuses WITHOUT prompting", async () => {
const holder = generateSigningIdentity();
const { fetchFn, calls } = mockRp(await startResponse({ withDocument: false }));

let prompted = false;
const res = await performStepUpVta(
flowArgs(holder, fetchFn, async () => {
prompted = true;
return true;
}),
);
assert.equal(res.ok, false);
assert.equal(res.declined, false);
assert.match(res.error, /no signed approve-request document/);
assert.equal(prompted, false, "no prompt for an unverifiable request");
assert.equal(calls.filter((c) => c.url.endsWith("/finish")).length, 0);
});

test("performStepUpVta: issuer ≠ page rpDid refuses WITHOUT prompting", async () => {
const holder = generateSigningIdentity();
// Signed by an executor the wallet IS enrolled with — but not the RP the
// page named. Verification alone passes; the binding check must still stop
// the flow before any human is asked.
const other = generateSigningIdentity();
const { fetchFn, calls } = mockRp(await startResponse({ as: other, legacy: false }));

let prompted = false;
const res = await performStepUpVta({
...flowArgs(holder, fetchFn, async () => {
prompted = true;
return true;
}),
enrolledExecutorDids: [RP.did, other.did],
});
assert.equal(res.ok, false);
assert.equal(res.declined, false);
assert.match(res.error, /does not match the page-supplied rpDid/);
assert.equal(prompted, false);
assert.equal(calls.filter((c) => c.url.endsWith("/finish")).length, 0);
});
Loading