From e468784889dd620b6bb7f43d6519941bd5329217 Mon Sep 17 00:00:00 2001 From: Jeremie Charrier Date: Fri, 31 Jul 2026 13:38:36 +0200 Subject: [PATCH] feat: add allowInsecureUri opt-out to return_uri validation Adds an explicit `ReturnUriOptions { allowInsecureUri?: boolean }` opt-out to the https requirement on `return_uri`, for local/dev transports (e.g. http://localhost). The library does not read NODE_ENV; the caller, which owns its environment, opts in. Threaded through addReturnUri, decodePayload, detectProofRequirement, and parseX401Payload via a shared assertReturnUri helper so both sides apply the same rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agent.ts | 22 ++++++++++++++-------- src/index.ts | 1 + src/validate.ts | 37 +++++++++++++++++++++++++++++++------ tests/x401.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index 3468b42..f10bc24 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -9,10 +9,12 @@ import { } from "./constants.ts"; import { decodeProofHeader, encodeJson } from "./encoding.ts"; import { + assertReturnUri, parseX401ErrorObject, parseX401Payload, X401ValidationError, } from "./validate.ts"; +import type { ReturnUriOptions } from "./validate.ts"; import type { CredentialRequestOptions, CredentialResult, @@ -53,8 +55,11 @@ function getHeader(headers: HeadersInput, name: string): string | undefined { return undefined; } -export function decodePayload(headerValue: string): X401Payload { - return parseX401Payload(decodeProofHeader(headerValue)); +export function decodePayload( + headerValue: string, + options?: ReturnUriOptions, +): X401Payload { + return parseX401Payload(decodeProofHeader(headerValue), options); } export function decodeErrorObject(headerValue: string): X401ErrorObject { @@ -80,11 +85,12 @@ const EMBEDDED_RE = new RegExp( export function detectProofRequirement( input: DetectInput, + options?: ReturnUriOptions, ): ProofRequirement | null { if (input.headers !== undefined) { const headerValue = getHeader(input.headers, HEADER.PROOF_REQUEST); if (headerValue) { - return { source: "header", payload: decodePayload(headerValue) }; + return { source: "header", payload: decodePayload(headerValue, options) }; } } if (input.body) { @@ -99,7 +105,7 @@ export function detectProofRequirement( } return { source: "embedded", - payload: parseX401Payload(parsed), + payload: parseX401Payload(parsed, options), }; } } @@ -150,15 +156,15 @@ export function getDigitalCredentialRequest( /** * Returns a copy of the payload with `return_uri` added, for an intermediary relaying the * request to a remote handler that POSTs the credential result back. Only a relaying - * intermediary sets this; never the Verifier. The URL must be https. + * intermediary sets this; never the Verifier. The URL must be https, unless the + * caller opts in to an insecure URL via `options.allowInsecureUri`. */ export function addReturnUri( payload: X401Payload, returnUri: string, + options?: ReturnUriOptions, ): X401Payload { - if (!returnUri.startsWith("https://")) { - throw new X401ValidationError("return_uri must be an https URL."); - } + assertReturnUri(returnUri, options); return { ...payload, return_uri: returnUri }; } diff --git a/src/index.ts b/src/index.ts index 77c5b9d..d31e136 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * as agent from "./agent.ts"; export * as verifier from "./verifier.ts"; export { X401ValidationError } from "./validate.ts"; +export type { ReturnUriOptions } from "./validate.ts"; export { ACCESS_TOKEN_TYPE, diff --git a/src/validate.ts b/src/validate.ts index 6f7ea2c..72d940f 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -24,7 +24,35 @@ function isString(value: unknown): value is string { const DC_API_PROTOCOLS: readonly string[] = Object.values(DC_API_PROTOCOL); -export function parseX401Payload(value: unknown): X401Payload { +export interface ReturnUriOptions { + /** + * Skip the https requirement on `return_uri`, permitting an http URL (e.g. a + * `http://localhost` dev transport). Defaults to false. + */ + allowInsecureUri?: boolean; +} + +/** + * Asserts `return_uri` is a string and, unless `allowInsecureUri` is set, an + * https URL. Shared by the intermediary that adds it and the recipient that + * decodes it so both sides apply the same rule. + */ +export function assertReturnUri( + returnUri: unknown, + options?: ReturnUriOptions, +): void { + if ( + !isString(returnUri) || + (!options?.allowInsecureUri && !returnUri.startsWith("https://")) + ) { + throw new X401ValidationError("return_uri must be an https URL."); + } +} + +export function parseX401Payload( + value: unknown, + options?: ReturnUriOptions, +): X401Payload { if (!isObject(value)) { throw new X401ValidationError("x401 payload must be a JSON object."); } @@ -69,11 +97,8 @@ export function parseX401Payload(value: unknown): X401Payload { if (!isObject(oauth) || !isString(oauth.token_endpoint)) { throw new X401ValidationError("oauth.token_endpoint is required."); } - if ( - value.return_uri !== undefined && - (!isString(value.return_uri) || !value.return_uri.startsWith("https://")) - ) { - throw new X401ValidationError("return_uri must be an https URL."); + if (value.return_uri !== undefined) { + assertReturnUri(value.return_uri, options); } return value as unknown as X401Payload; } diff --git a/tests/x401.test.ts b/tests/x401.test.ts index 395fd4b..0a1b5b1 100644 --- a/tests/x401.test.ts +++ b/tests/x401.test.ts @@ -134,6 +134,29 @@ test("addReturnUri rejects a non-https return_uri", () => { ); }); +test("addReturnUri allows an http return_uri when allowInsecureUri is set", () => { + const relayed = agent.addReturnUri( + buildRequirement(), + "http://localhost:3000/x401/return", + { allowInsecureUri: true }, + ); + assert.equal(relayed.return_uri, "http://localhost:3000/x401/return"); + const decoded = agent.decodePayload(verifier.encodePayload(relayed), { + allowInsecureUri: true, + }); + assert.equal(decoded.return_uri, "http://localhost:3000/x401/return"); +}); + +test("addReturnUri still rejects a non-string return_uri under allowInsecureUri", () => { + assert.throws( + () => + agent.addReturnUri(buildRequirement(), 42 as unknown as string, { + allowInsecureUri: true, + }), + X401ValidationError, + ); +}); + test("parseX401Payload rejects a non-https return_uri", () => { const bad = Buffer.from( JSON.stringify({