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..a2516be 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,17 @@ export function parseX401Payload( return value as unknown as X401Payload; } +function assertOAuthMetadata(oauth: unknown): void { + 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.", + ); + } +} + 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..46f59cd 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,26 @@ 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), + (error: unknown) => + error instanceof X401ValidationError && + error.message === + "oauth.token_endpoint is required when oauth is present.", + ); }); test("parseX401Payload rejects a non-string token_endpoint", () => { @@ -200,7 +212,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,9 +231,15 @@ 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); + assert.throws( + () => agent.decodePayload(bad), + (error: unknown) => + error instanceof X401ValidationError && + error.message === "oauth must be an object.", + ); }); test("parseX401Payload rejects a leftover wrapper", () => {