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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
A Yarn 4 monorepo publishing two ESM TypeScript packages:

- **`@proof.com/proof-vc-common`** (`packages/common`) — public/frontend, runs in the browser **and** Node, **zero runtime deps**. Builds OID4VP Authorization Request URLs (`createClient` / `buildAuthorizationUrl`) and reads the `vp_token` from the redirect (`parseAuthorizationResponse`). No secrets, no `nonce` generation, no transaction data.
- **`@proof.com/proof-vc-server`** (`packages/server`) — privileged/backend, **Node only**. Adds `verify` / `verifyVPToken` (SD-JWT-VC verification, X.509 chain validation against an embedded trust root), Pushed Authorization Requests, transaction data, and DC API requests. Depends on `proof-vc-common` and **re-exports it**, so backend integrators need one import. Yarn links it locally via transparent workspaces.
- **`@proof.com/proof-vc-server`** (`packages/server`) — privileged/backend, **Node only**. Adds `verify` / `verifyVPToken` (SD-JWT-VC verification, X.509 chain validation against an embedded trust root), Pushed Authorization Requests, JWT-Secured Authorization Requests (RFC 9101 / "JAR", by value and by reference), transaction data, and signed DC API requests. Depends on `proof-vc-common` and **re-exports it**, so backend integrators need one import. Yarn links it locally via transparent workspaces.

`server` reuses `common`'s URL/param builders via the `@proof.com/proof-vc-common/internal` subpath export (server-only; not public frontend surface).

Expand All @@ -29,7 +29,7 @@ Versioning is **lockstep**: both packages always share one version, published to
`packages/server/src` (Node only):

- `index.ts` — `export * from "@proof.com/proof-vc-common"` then the server surface (its `createClient` intentionally shadows the frontend one)
- `client.ts` — server `createClient` (PAR, `transactionData`, `dcApiRequest`); `authorizationUrl` is `async` (PAR fetches)
- `client.ts` — server `createClient` (PAR, `transactionData`, RFC 9101 JAR signing via `jose`: `authorizationUrl` by value, `signedAuthorizationRequest` + `jarByReferenceAuthorizationUrl` by reference, `signedDcApiRequest`); `authorizationUrl` is `async` (PAR fetches + JAR signing). Secured requests need `useSecuredAuthorizationRequest: true` and a `privateKeyFactory` (ES256 JWK); `aud` is the AS `issuer` fetched from `AS_METADATA_URL`
- `verifier.ts` — `createVerifier` → `verify` / `verifyVPToken`
- `transaction_data.ts`, `proof_credentials.ts`, `proof_credential_factory.ts`, `utils.ts`, `types.ts` (`ProofCredential`/`VPToken`/`TrustRoot`), `certificates/**`

Expand Down
66 changes: 62 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Read our [documentation](https://dev.proof.com/docs/digital-credentials-overview
- [Server Side](#server-side)
- [Response Modes](#response-modes)
- [Pushed Authorization Requests](#pushed-authorization-requests)
- [Secured Authorization Requests](#secured-authorization-requests)
- [Verifiable Credential Presentation](#verifiable-credential-presentation)
- [Credential Types](#credential-type)
- [Request](#request)
Expand All @@ -28,10 +29,10 @@ Read our [documentation](https://dev.proof.com/docs/digital-credentials-overview

## Packages

| Package | Runtime | Usage | Runtime deps |
| --------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------- | ------------ |
| **[`@proof.com/proof-vc-common`](packages/common)** | browser **or** Node | Request a Verifiable Presentation | **0** ✅ |
| **[`@proof.com/proof-vc-server`](packages/server)** | Node | `proof-vc-common` **plus** Presentation Verification, Pushed Authorization Requests, Transaction Templates | sd-jwt, owf |
| Package | Runtime | Usage | Runtime deps |
| --------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------- |
| **[`@proof.com/proof-vc-common`](packages/common)** | browser **or** Node | Request a Verifiable Presentation | **0** ✅ |
| **[`@proof.com/proof-vc-server`](packages/server)** | Node | `proof-vc-common` **plus** Presentation Verification, Pushed Authorization Requests, Secured Authorization Requests (JAR), Transaction Templates | sd-jwt, owf, jose |

## Installation

Expand Down Expand Up @@ -172,6 +173,63 @@ const redirect = await proof.authorizationUrl({
});
```

### Secured Authorization Requests

Proof supports [JWT-Secured Authorization Requests](https://datatracker.ietf.org/doc/html/rfc9101) (JAR): the Authorization Request parameters are sent as a single signed JWT ("request object") instead of plain query parameters.
JAR is available **only from `@proof.com/proof-vc-server`** and requires:

- `useSecuredAuthorizationRequest: true`
- a `privateKeyFactory` returning your EC private key as a JWK

#### By value

`authorizationUrl` signs the request object and embeds it in the `request` parameter. It can be combined with [Pushed Authorization Requests](#pushed-authorization-requests).

```javascript
import { createClient } from "@proof.com/proof-vc-server";

const proof = createClient({
environment: "sandbox",
clientId: "caxdw5a7d",
callbackUri: "https://example.com/verify_vp_token",
responseMode: "direct_post",
useSecuredAuthorizationRequest: true,
privateKeyFactory: () => myPrivateKeyJwk,
});

const redirect = await proof.authorizationUrl({
nonce: "3e8e4918-e9fb-453a-a538-81152be15c1b",
scope: "urn:proof:params:scope:verifiable-credentials:basic",
});
```

#### By reference

If you'd rather host the request object yourself, use `signedAuthorizationRequest` to obtain the signed JWT, store it on your backend, then hand Proof a `request_uri` pointing at it with `jarByReferenceAuthorizationUrl`.
JAR by reference cannot be combined with Pushed Authorization Requests.

```javascript
const jar = await proof.signedAuthorizationRequest({
nonce: "3e8e4918-e9fb-453a-a538-81152be15c1b",
scope: "urn:proof:params:scope:verifiable-credentials:basic",
});

const redirect = proof.jarByReferenceAuthorizationUrl({
requestUri: "https://example.com/jar/42",
});
```

#### Digital Credentials API

For the [W3C Digital Credentials API](https://www.w3.org/TR/digital-credentials/) (`dc_api` response mode), `signedDcApiRequest` returns a signed request object you pass to `navigator.credentials.get`.

```javascript
const request = await proof.signedDcApiRequest({
nonce: "3e8e4918-e9fb-453a-a538-81152be15c1b",
dcqlQuery: DCQL_QUERY_BASIC,
});
```

## Verifiable Credential Presentation

### Credential Type
Expand Down
3 changes: 2 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"@owf/identity-common": "^0.3.2",
"@proof.com/proof-vc-common": "0.3.1",
"@sd-jwt/core": "^0.20.0",
"@sd-jwt/sd-jwt-vc": "^0.20.0"
"@sd-jwt/sd-jwt-vc": "^0.20.0",
"jose": "^6.2.3"
},
"devDependencies": {
"publint": "^0.3.21",
Expand Down
69 changes: 60 additions & 9 deletions packages/server/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@ import {
type AuthorizationRequestParams,
} from "@proof.com/proof-vc-common/internal";
import type { DCQLQuery } from "@proof.com/proof-vc-common";
import type { JWK } from "jose";
import {
encodeTransactionData,
type TransactionData,
} from "./transaction_data.ts";
import { signRequestObject, requestObjectClaims } from "./secured_request.ts";

export type PrivateKeyFactory = () => JWK | Promise<JWK>;

export type ServerClientConfig = ClientConfig & {
clientSecret?: string;
usePushedAuthorizationRequest?: boolean;
useSecuredAuthorizationRequest?: boolean;
privateKeyFactory?: PrivateKeyFactory;
};

export type ServerAuthorizationRequestParams = AuthorizationRequestParams & {
Expand All @@ -28,17 +34,26 @@ export type DCAPIAuthorizationRequestParams = {
transactionData?: TransactionData | string;
};

export type AuthorizationRequest = {
export type DCAPIAuthorizationRequest = {
client_id: string;
response_type: typeof RESPONSE_TYPE;
response_mode: "dc_api";
nonce: string;
dcql_query: DCQLQuery;
transaction_data?: string[];
};

export type JarByReferenceParams = {
requestUri: string;
};

export interface ServerVCClient {
authorizationUrl(params: ServerAuthorizationRequestParams): Promise<string>;
dcApiRequest(params: DCAPIAuthorizationRequestParams): AuthorizationRequest;
signedAuthorizationRequest(
params: ServerAuthorizationRequestParams,
): Promise<string>;
signedDcApiRequest(params: DCAPIAuthorizationRequestParams): Promise<string>;
jarByReferenceAuthorizationUrl(params: JarByReferenceParams): string;
}

function encodeTxData(
Expand All @@ -54,16 +69,19 @@ export function createClient(config: ServerClientConfig): ServerVCClient {
params: ServerAuthorizationRequestParams,
): URLSearchParams {
const search = buildAuthorizationSearchParams(config, params);
if (config.clientSecret !== undefined) {
search.set("client_secret", config.clientSecret);
}
const encoded = encodeTxData(params.transactionData);
if (encoded !== undefined) {
search.set("transaction_data", encoded);
}
return search;
}

function signedRequest(
params: ServerAuthorizationRequestParams,
): Promise<string> {
return signRequestObject(config, requestObjectClaims(buildParams(params)));
}

async function pushAuthorizationRequest(
search: URLSearchParams,
): Promise<string> {
Expand All @@ -72,6 +90,7 @@ export function createClient(config: ServerClientConfig): ServerVCClient {
"pushed authorization requests require `clientSecret` in the client config",
);
}
search.set("client_secret", config.clientSecret);
const parURL = new URL(
`${OID4VP_URI}/par`,
resolveBaseUrl(config.environment),
Expand Down Expand Up @@ -108,24 +127,56 @@ export function createClient(config: ServerClientConfig): ServerVCClient {
async authorizationUrl(
params: ServerAuthorizationRequestParams,
): Promise<string> {
const search = buildParams(params);
const search =
config.useSecuredAuthorizationRequest === true
? new URLSearchParams({
client_id: config.clientId,
request: await signedRequest(params),
})
: buildParams(params);
return config.usePushedAuthorizationRequest === true
? pushAuthorizationRequest(search)
: authorizeUrlFromSearchParams(config.environment, search);
},
dcApiRequest({

signedAuthorizationRequest(
params: ServerAuthorizationRequestParams,
): Promise<string> {
return signedRequest(params);
},

signedDcApiRequest({
dcqlQuery,
nonce,
transactionData,
}: DCAPIAuthorizationRequestParams): AuthorizationRequest {
}: DCAPIAuthorizationRequestParams): Promise<string> {
const encoded = encodeTxData(transactionData);
return {
const request: DCAPIAuthorizationRequest = {
client_id: config.clientId,
response_type: RESPONSE_TYPE,
response_mode: "dc_api",
nonce,
dcql_query: dcqlQuery,
...(encoded !== undefined && { transaction_data: [encoded] }),
};
return signRequestObject(config, { ...request });
},

jarByReferenceAuthorizationUrl({
requestUri,
}: JarByReferenceParams): string {
if (config.usePushedAuthorizationRequest === true) {
throw new Error(
"JAR by reference cannot be combined with pushed authorization requests",
);
}
return authorizeUrlFromSearchParams(
config.environment,
new URLSearchParams({
client_id: config.clientId,
request_uri: requestUri,
}),
);
},
};
}
4 changes: 3 additions & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ export { TX_DATA_TYPE, transactionData } from "./transaction_data.ts";

export type {
ServerClientConfig,
PrivateKeyFactory,
ServerAuthorizationRequestParams,
ServerVCClient,
DCAPIAuthorizationRequestParams,
AuthorizationRequest,
DCAPIAuthorizationRequest,
JarByReferenceParams,
} from "./client.ts";
export { createClient } from "./client.ts";

Expand Down
28 changes: 28 additions & 0 deletions packages/server/src/issuer_metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { resolveBaseUrl } from "@proof.com/proof-vc-common/internal";
import type { ServerClientConfig } from "./client.ts";

const AS_METADATA_URL =
"/.well-known/oauth-authorization-server/verifiable-credentials/v1/presentation";

export async function authorizationServerIssuer(
config: ServerClientConfig,
): Promise<string> {
const metadataURL = new URL(
AS_METADATA_URL,
resolveBaseUrl(config.environment),
).toString();
const response = await fetch(metadataURL);
const data = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(
`failed to fetch authorization server metadata (${response.status})`,
);
}
const issuer = data["issuer"];
if (typeof issuer !== "string") {
throw new Error(
"authorization server metadata is missing a string `issuer`",
);
}
return issuer;
}
40 changes: 40 additions & 0 deletions packages/server/src/secured_request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { SignJWT, calculateJwkThumbprint } from "jose";
import type { ServerClientConfig } from "./client.ts";
import { authorizationServerIssuer } from "./issuer_metadata.ts";

const REQUEST_OBJECT_TYP = "oauth-authz-req+jwt";
const REQUEST_OBJECT_ALG = "ES256";

export function requestObjectClaims(
search: URLSearchParams,
): Record<string, string> {
return Object.fromEntries(search.entries());
}

export async function signRequestObject(
config: ServerClientConfig,
claims: Record<string, unknown>,
): Promise<string> {
if (config.useSecuredAuthorizationRequest !== true) {
throw new Error(
"signing a request object requires `useSecuredAuthorizationRequest` in the client config",
);
}
if (config.privateKeyFactory === undefined) {
throw new Error(
"signing a request object requires `privateKeyFactory` in the client config",
);
}
const privateKey = await config.privateKeyFactory();
const kid = await calculateJwkThumbprint(privateKey);
const aud = await authorizationServerIssuer(config);
return new SignJWT(claims)
.setProtectedHeader({
typ: REQUEST_OBJECT_TYP,
alg: REQUEST_OBJECT_ALG,
kid,
})
.setIssuer(config.clientId)
.setAudience(aud)
.sign(privateKey);
}
Loading