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
13 changes: 13 additions & 0 deletions .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ jobs:
with:
fetch-depth: 0

- name: Clone WMP spec (test vectors and schemas)
run: git clone https://github.com/leifj/wmp.git ../wmp

- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm

- run: npm ci

- name: Run tests with coverage
run: npx vitest run --coverage

- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
dist/
coverage/
*.tsbuildinfo
534 changes: 373 additions & 161 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"devDependencies": {
"@types/node": "^26.0.0",
"@vitest/coverage-v8": "^4.1.10",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
},
Expand Down
3 changes: 3 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
sonar.sources=src
sonar.tests=test
sonar.javascript.lcov.reportPaths=coverage/lcov.info
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ export {
VPStep,
OID4Action,
OpenID4xProfile,
buildVCIFlowStart,
withAttestation,
} from "./openid4x.js";
export type {
CredentialFormatType,
Expand All @@ -150,6 +152,10 @@ export type {
CredentialEvent,
CredentialNotificationParams,
VPTokenResult,
ClientAttestation,
ClientAttestationProvider,
OID4VCIFlowParams,
OID4VPFlowParams,
SignSubFlowParams,
SelectionAction,
ConsentAction,
Expand Down
146 changes: 145 additions & 1 deletion src/openid4x.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
ResolveHandler,
} from "./profile.js";
import { WMPError } from "./jsonrpc.js";
import { ErrorCode } from "./types.js";
import { ErrorCode, VERSION } from "./types.js";

// ---------------------------------------------------------------------------
// Credential format constants — aligned with wallet-common VerifiableCredentialFormat
Expand Down Expand Up @@ -201,6 +201,150 @@ export interface VPTokenResult {
response_code?: string;
}

// ---------------------------------------------------------------------------
// OID4VCI flow start params — the protocol-specific payload inside
// FlowStartParams.params for OID4VCI flows.
// ---------------------------------------------------------------------------

/**
* Client attestation credentials (WIA + PoP) for issuer authentication.
* Produced by a {@link ClientAttestationProvider}.
*/
export interface ClientAttestation {
/** WIA JWT (typ: oauth-client-attestation+jwt) */
client_attestation: string;
/** PoP JWT (typ: oauth-client-attestation-pop+jwt) signed by wallet instance key */
client_attestation_pop: string;
}

/**
* Provider interface for obtaining OAuth client attestation credentials.
*
* Callers implement this to decouple wmp-js from any specific wallet backend.
* The provider is responsible for:
* 1. Obtaining a WIA JWT from the wallet provider (implementation-defined)
* 2. Signing the PoP JWT with the wallet instance key (aud = issuer AS URL)
*
* Example implementations:
* - SIROS: calls /wallet-provider/wia/generate, signs PoP with passkey-PRF key
* - EUDI: calls the PID provider's WIA endpoint, signs PoP with device key
* - Test: returns static test JWTs
*/
export interface ClientAttestationProvider {
/**
* Get attestation credentials for a specific issuer.
* @param audience - The issuer's AS URL (used as PoP aud claim)
* @returns WIA + PoP JWTs, or null if attestation is not available/required
*/
getAttestation(audience: string): Promise<ClientAttestation | null>;
}

/**
* OID4VCI-specific parameters for wmp.flow.start.
* Passed as the `params` field of FlowStartParams when flow_type = "oid4vci".
*
* Client attestation fields support draft-ietf-oauth-attestation-based-client-auth-04:
* - `client_attestation`: WIA JWT from the provider
* - `client_attestation_pop`: PoP JWT signed by the wallet instance key (aud = AS URL)
*
* The instance key is held client-side (never on the backend).
* The backend forwards these as HTTP headers without modification.
*/
/** Common fields shared by all OID4VCI flow param variants. */
interface OID4VCIFlowParamsBase {
/** OAuth redirect URI for authorization code flow */
redirect_uri?: string;

// --- Client attestation (draft-ietf-oauth-attestation-based-client-auth-04) ---

/** WIA JWT (typ: oauth-client-attestation+jwt) obtained via ClientAttestationProvider */
client_attestation?: string;
/** PoP JWT (typ: oauth-client-attestation-pop+jwt) signed by wallet instance key */
client_attestation_pop?: string;

// --- Resumption fields (same-tab redirect flow) ---

/** Authorization code from OAuth redirect */
auth_code?: string;
/** PKCE code verifier (saved by client before redirect) */
code_verifier?: string;
}

/** OID4VCI params with an inline credential offer. */
export interface OID4VCIFlowParamsWithOffer extends OID4VCIFlowParamsBase {
/** Credential offer URI (openid-credential-offer://...) */
offer: string;
credential_offer_uri?: never;
}

/** OID4VCI params with a credential offer by reference. */
export interface OID4VCIFlowParamsWithURI extends OID4VCIFlowParamsBase {
offer?: never;
/** Credential offer URI by reference (https://...) */
credential_offer_uri: string;
}

/**
* OID4VCI-specific parameters for wmp.flow.start.
* Passed as the `params` field of FlowStartParams when flow_type = "oid4vci".
*
* Exactly one of `offer` or `credential_offer_uri` must be provided.
*/
export type OID4VCIFlowParams = OID4VCIFlowParamsWithOffer | OID4VCIFlowParamsWithURI;

/**
* OID4VP-specific parameters for wmp.flow.start.
* Passed as the `params` field of FlowStartParams when flow_type = "oid4vp".
*/
export interface OID4VPFlowParams {
/** Request URI (openid4vp://...) */
request_uri?: string;
/** Request URI by reference (https://...) */
request_uri_ref?: string;
}

/**
* Helper to build WMP FlowStartParams for an OID4VCI flow.
* If attestation is provided, it is included in the params.
*/
export function buildVCIFlowStart(
sessionId: string,
flowId: string,
params: OID4VCIFlowParams,
timeout?: number,
): FlowStartParams {
return {
wmp: { version: VERSION, session_id: sessionId },
flow_type: OID4FlowType.OID4VCI,
flow_id: flowId,
params,
timeout,
};
}

/**
* Helper to build VCI flow params with attestation from a provider.
* Calls the provider to obtain WIA + PoP, then merges into the params.
*
* @param provider - The attestation provider (caller-supplied)
* @param audience - The issuer's AS URL for PoP audience binding
* @param params - Base OID4VCI flow params (offer, redirect_uri, etc.)
* @returns params with attestation fields populated (or unchanged if provider returns null)
*/
export async function withAttestation(
provider: ClientAttestationProvider,
audience: string,
params: OID4VCIFlowParams,
): Promise<OID4VCIFlowParams> {
const attestation = await provider.getAttestation(audience);
if (!attestation) return params;
return {
...params,
client_attestation: attestation.client_attestation,
client_attestation_pop: attestation.client_attestation_pop,
};
}

/**
* TransactionData represents a single transaction data object from
* the verifier's OID4VP authorization request (TS12/SCA).
Expand Down
111 changes: 110 additions & 1 deletion test/openid4x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import {
VPStep,
OID4Action,
OpenID4xProfile,
buildVCIFlowStart,
withAttestation,
} from "../src/openid4x.js";
import type {
ClientAttestationProvider,
OID4VCIFlowParams,
CredentialNotificationParams,
} from "../src/openid4x.js";
import type { FlowStartParams, FlowActionParams, Metadata } from "../src/types.js";
import { ErrorCode } from "../src/types.js";
import { ErrorCode, VERSION } from "../src/types.js";

const wmp: Metadata = { version: "0.1", session_id: "ses-1" };

Expand Down Expand Up @@ -213,3 +220,105 @@ describe("OpenID4xProfile resolve", () => {
).rejects.toMatchObject({ code: ErrorCode.CapabilityNotSupported });
});
});

describe("buildVCIFlowStart", () => {
it("builds flow start params with offer", () => {
const params: OID4VCIFlowParams = { offer: "openid-credential-offer://example" };
const result = buildVCIFlowStart("ses-1", "flow-1", params);

expect(result.wmp.version).toBe(VERSION);
expect(result.wmp.session_id).toBe("ses-1");
expect(result.flow_type).toBe("oid4vci");
expect(result.flow_id).toBe("flow-1");
expect(result.params).toBe(params);
expect(result.timeout).toBeUndefined();
});

it("builds flow start params with credential_offer_uri", () => {
const params: OID4VCIFlowParams = { credential_offer_uri: "https://issuer.example/offer/123" };
const result = buildVCIFlowStart("ses-2", "flow-2", params, 30000);

expect(result.wmp.version).toBe(VERSION);
expect(result.flow_type).toBe("oid4vci");
expect(result.flow_id).toBe("flow-2");
expect(result.params).toEqual({ credential_offer_uri: "https://issuer.example/offer/123" });
expect(result.timeout).toBe(30000);
});

it("includes attestation fields when present in params", () => {
const params: OID4VCIFlowParams = {
offer: "openid-credential-offer://example",
client_attestation: "wia-jwt",
client_attestation_pop: "pop-jwt",
};
const result = buildVCIFlowStart("ses-3", "flow-3", params);
expect((result.params as typeof params).client_attestation).toBe("wia-jwt");
expect((result.params as typeof params).client_attestation_pop).toBe("pop-jwt");
});
});

describe("withAttestation", () => {
it("merges attestation into params when provider returns credentials", async () => {
const provider: ClientAttestationProvider = {
getAttestation: async (_audience) => ({
client_attestation: "wia-test-jwt",
client_attestation_pop: "pop-test-jwt",
}),
};
const params: OID4VCIFlowParams = { offer: "openid-credential-offer://example" };
const result = await withAttestation(provider, "https://issuer.example", params);

expect(result.client_attestation).toBe("wia-test-jwt");
expect(result.client_attestation_pop).toBe("pop-test-jwt");
expect((result as { offer: string }).offer).toBe("openid-credential-offer://example");
});

it("returns params unchanged when provider returns null", async () => {
const provider: ClientAttestationProvider = {
getAttestation: async () => null,
};
const params: OID4VCIFlowParams = { offer: "openid-credential-offer://example" };
const result = await withAttestation(provider, "https://issuer.example", params);

expect(result).toBe(params); // same reference — no modification
});

it("passes audience to provider", async () => {
let receivedAudience = "";
const provider: ClientAttestationProvider = {
getAttestation: async (audience) => {
receivedAudience = audience;
return null;
},
};
const params: OID4VCIFlowParams = { credential_offer_uri: "https://issuer.example/offer" };
await withAttestation(provider, "https://as.issuer.example", params);

expect(receivedAudience).toBe("https://as.issuer.example");
});
});

describe("CredentialNotificationParams type", () => {
it("can construct a valid notification payload", () => {
const notification: CredentialNotificationParams = {
wmp: { version: VERSION, session_id: "ses-1" },
flow_id: "flow-1",
notification_id: "notif-123",
event: "credential_accepted",
};
expect(notification.event).toBe("credential_accepted");
expect(notification.notification_id).toBe("notif-123");
});

it("supports event_description", () => {
const notification: CredentialNotificationParams = {
wmp: { version: VERSION, session_id: "ses-1" },
flow_id: "flow-1",
notification_id: "notif-456",
event: "credential_failure",
event_description: "User revoked consent",
};
expect(notification.event).toBe("credential_failure");
expect(notification.event_description).toBe("User revoked consent");
});
});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"],
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*.ts"],
Expand Down
6 changes: 6 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["lcov", "text"],
reportsDirectory: "coverage",
include: ["src/**/*.ts"],
},
},
});
Loading