From b2d920a381fd9017c7a826ae7051a3b540d6a4cf Mon Sep 17 00:00:00 2001 From: Jeremie Charrier Date: Tue, 29 Sep 2026 14:01:10 +0200 Subject: [PATCH 1/2] Make the oauth object optional The spec marks oauth as REQUIRED, but that is being corrected. The whole oauth object is now optional in the payload type, the parser, and buildPayload. When oauth is present, token_endpoint is required again. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- spec/conformance.md | 2 +- src/types.ts | 6 +++--- src/validate.ts | 16 ++++++++++------ src/verifier.ts | 4 ++-- tests/x401.test.ts | 36 ++++++++++++++++++++++++++---------- 6 files changed, 43 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f8ab43c..08acf26 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ const payload = verifier.buildPayload({ ``` `protocol` is `openid4vp-v1-signed` or `openid4vp-v1-unsigned`, and its `data` carries the request -you composed and signed. `requestId`, `satisfiedRequirements`, and `oauth.token_endpoint` are optional. +you composed and signed. `requestId`, `satisfiedRequirements`, and `oauth` are optional. Return it as a header: diff --git a/spec/conformance.md b/spec/conformance.md index 9a929bc..4d9741e 100644 --- a/spec/conformance.md +++ b/spec/conformance.md @@ -21,7 +21,7 @@ they are out of scope. The goal is to make "did we miss a MUST?" answerable. | `scheme` MUST be `"x401"` (payload, error, token) | `validate.ts` `parseX401Payload` / `parseX401ErrorObject` / `parseX401TokenObject`; builders in `verifier.ts`/`agent.ts` set the constant. Tests: `x401.test.ts`, `spec-fixtures.test.ts` | | `version` REQUIRED | `validate.ts` (all three parsers); `X401_VERSION` constant | | `credential_requirements` REQUIRED; `digital.requests` a non-empty array; each `protocol` is `openid4vp-v1-signed` or `openid4vp-v1-unsigned`; each `data` an object | `validate.ts` `parseX401Payload`, `verifier.ts` `buildPayload`. Tests: `spec-schema.test.ts`, `x401.test.ts` | -| `oauth` REQUIRED; `oauth.token_endpoint` accepted as optional (spec text says REQUIRED; the spec is being corrected) | `validate.ts` `parseX401Payload`. Tests: `spec-schema.test.ts`, `x401.test.ts` | +| `oauth` accepted as optional (spec text says REQUIRED; the spec is being corrected); when present, `oauth.token_endpoint` REQUIRED | `validate.ts` `parseX401Payload`. Tests: `spec-schema.test.ts`, `x401.test.ts` | | Payload encoded value MUST be base64url UTF-8 JSON (RFC 4648 section 5, no padding); decoded value MUST be a single JSON object | `encoding.ts` (`@owf/identity-common` base64url) | | MUST NOT combine multiple objects in one proof header through commas or lists; comma value MUST be treated as invalid | `encoding.ts` `decodeProofHeader` comma guard. Test: `x401.test.ts` | | Result Artifact MUST contain exactly one of `credential_result` or `credential_result_uri` | `validate.ts` `parseResultArtifact`. Tests: `x401.test.ts`, `spec-fixtures.test.ts` | diff --git a/src/types.ts b/src/types.ts index 0ef23a4..79444f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,7 +50,7 @@ export interface CredentialResult { /** OAuth token-exchange metadata carried in `oauth`. */ export interface OAuthMetadata { - token_endpoint?: string; + token_endpoint: string; audience?: string; resource?: string; } @@ -68,8 +68,8 @@ export interface X401Payload { version: string; /** The composed credential request. Required. */ credential_requirements: CredentialRequestOptions; - /** OAuth token-exchange metadata. Required. */ - oauth: OAuthMetadata; + /** OAuth token-exchange metadata. Optional. */ + oauth?: OAuthMetadata; /** Stable verifier-defined identifier for the proof template. Optional hint. */ request_id?: string; /** Stable identifiers for reusable proof requirements this proof would satisfy. Optional hint. */ diff --git a/src/validate.ts b/src/validate.ts index 727dc68..fdc95c2 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -93,12 +93,8 @@ export function parseX401Payload( throw new X401ValidationError("request data must be an object."); } } - const oauth = value.oauth; - if (!isObject(oauth)) { - throw new X401ValidationError("oauth must be an object."); - } - if (oauth.token_endpoint !== undefined && !isString(oauth.token_endpoint)) { - throw new X401ValidationError("oauth.token_endpoint must be a string."); + if (value.oauth !== undefined) { + assertOAuthMetadata(value.oauth); } if (value.return_uri !== undefined) { assertReturnUri(value.return_uri, options); @@ -106,6 +102,14 @@ export function parseX401Payload( return value as unknown as X401Payload; } +function assertOAuthMetadata(oauth: unknown): void { + if (!isObject(oauth) || !isString(oauth.token_endpoint)) { + throw new X401ValidationError( + "oauth.token_endpoint is required when oauth is present.", + ); + } +} + export function parseResultArtifact(value: unknown): ResultArtifact { if (!isObject(value)) { throw new X401ValidationError("Result Artifact must be a JSON object."); diff --git a/src/verifier.ts b/src/verifier.ts index 25685fb..136b58b 100644 --- a/src/verifier.ts +++ b/src/verifier.ts @@ -33,7 +33,7 @@ interface BuildPayloadInput { */ credentialRequirements: CredentialRequestOptions; /** OAuth token-exchange metadata for the Agent. */ - oauth: OAuthMetadata; + oauth?: OAuthMetadata; /** Stable verifier-defined identifier for the proof template. Optional hint. */ requestId?: string; /** Reusable proof-requirement identifiers this proof would satisfy. Optional hint. */ @@ -60,7 +60,7 @@ export function buildPayload(input: BuildPayloadInput): X401Payload { scheme: X401_SCHEME, version: X401_VERSION, credential_requirements: input.credentialRequirements, - oauth: input.oauth, + ...(input.oauth !== undefined && { oauth: input.oauth }), ...(input.requestId !== undefined && { request_id: input.requestId }), ...(input.satisfiedRequirements !== undefined && { satisfied_requirements: input.satisfiedRequirements, diff --git a/tests/x401.test.ts b/tests/x401.test.ts index 202a711..eff3b59 100644 --- a/tests/x401.test.ts +++ b/tests/x401.test.ts @@ -42,7 +42,7 @@ test("buildPayload emits a flat payload with credential_requirements", () => { payload.credential_requirements.digital.requests[0]?.protocol, "openid4vp-v1-signed", ); - assert.equal(payload.oauth.token_endpoint, TOKEN_ENDPOINT); + assert.equal(payload.oauth?.token_endpoint, TOKEN_ENDPOINT); assert.equal("proof" in payload, false); }); @@ -174,14 +174,20 @@ test("parseX401Payload rejects a non-https return_uri", () => { assert.throws(() => agent.decodePayload(bad), X401ValidationError); }); -test("buildPayload accepts oauth without a token_endpoint and it round-trips", () => { - const payload = verifier.buildPayload({ - credentialRequirements: SIGNED_REQUEST, - oauth: { resource: RESOURCE }, - }); - assert.equal(payload.oauth.token_endpoint, undefined); - const decoded = agent.decodePayload(verifier.encodePayload(payload)); - assert.deepEqual(decoded.oauth, { resource: RESOURCE }); +test("parseX401Payload rejects oauth without a token_endpoint", () => { + const bad = Buffer.from( + JSON.stringify({ + scheme: "x401", + version: "0.2.0", + credential_requirements: { + digital: { + requests: [{ protocol: "openid4vp-v1-signed", data: {} }], + }, + }, + oauth: { resource: RESOURCE }, + }), + ).toString("base64url"); + assert.throws(() => agent.decodePayload(bad), X401ValidationError); }); test("parseX401Payload rejects a non-string token_endpoint", () => { @@ -200,7 +206,16 @@ test("parseX401Payload rejects a non-string token_endpoint", () => { assert.throws(() => agent.decodePayload(bad), X401ValidationError); }); -test("parseX401Payload rejects a missing oauth object", () => { +test("buildPayload accepts a missing oauth object and it round-trips", () => { + const payload = verifier.buildPayload({ + credentialRequirements: SIGNED_REQUEST, + }); + assert.equal("oauth" in payload, false); + const decoded = agent.decodePayload(verifier.encodePayload(payload)); + assert.equal(decoded.oauth, undefined); +}); + +test("parseX401Payload rejects a non-object oauth", () => { const bad = Buffer.from( JSON.stringify({ scheme: "x401", @@ -210,6 +225,7 @@ test("parseX401Payload rejects a missing oauth object", () => { requests: [{ protocol: "openid4vp-v1-signed", data: {} }], }, }, + oauth: "https://bank.example.com/oauth/token", }), ).toString("base64url"); assert.throws(() => agent.decodePayload(bad), X401ValidationError); From 09f2f190fb940d0b8d170a59f8c27c476ea796d1 Mon Sep 17 00:00:00 2001 From: Jeremie Charrier Date: Tue, 29 Sep 2026 14:03:30 +0200 Subject: [PATCH 2/2] wip Co-Authored-By: Claude Fable 5.1 --- src/validate.ts | 5 ++++- tests/x401.test.ts | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/validate.ts b/src/validate.ts index fdc95c2..a2516be 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -103,7 +103,10 @@ export function parseX401Payload( } function assertOAuthMetadata(oauth: unknown): void { - if (!isObject(oauth) || !isString(oauth.token_endpoint)) { + if (!isObject(oauth)) { + throw new X401ValidationError("oauth must be an object."); + } + if (!isString(oauth.token_endpoint)) { throw new X401ValidationError( "oauth.token_endpoint is required when oauth is present.", ); diff --git a/tests/x401.test.ts b/tests/x401.test.ts index eff3b59..46f59cd 100644 --- a/tests/x401.test.ts +++ b/tests/x401.test.ts @@ -187,7 +187,13 @@ test("parseX401Payload rejects oauth without a token_endpoint", () => { oauth: { resource: RESOURCE }, }), ).toString("base64url"); - assert.throws(() => agent.decodePayload(bad), X401ValidationError); + assert.throws( + () => agent.decodePayload(bad), + (error: unknown) => + error instanceof X401ValidationError && + error.message === + "oauth.token_endpoint is required when oauth is present.", + ); }); test("parseX401Payload rejects a non-string token_endpoint", () => { @@ -228,7 +234,12 @@ test("parseX401Payload rejects a non-object oauth", () => { oauth: "https://bank.example.com/oauth/token", }), ).toString("base64url"); - assert.throws(() => agent.decodePayload(bad), X401ValidationError); + assert.throws( + () => agent.decodePayload(bad), + (error: unknown) => + error instanceof X401ValidationError && + error.message === "oauth must be an object.", + ); }); test("parseX401Payload rejects a leftover wrapper", () => {