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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion spec/conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
6 changes: 3 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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. */
Expand Down
19 changes: 13 additions & 6 deletions src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,26 @@ 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);
}
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.");
Expand Down
4 changes: 2 additions & 2 deletions src/verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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,
Expand Down
49 changes: 38 additions & 11 deletions tests/x401.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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", () => {
Expand All @@ -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",
Expand All @@ -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", () => {
Expand Down
Loading