@idpartners/client-attestation-sdk
v1.2.0
Published
Client-side builder SDK for OAuth Attestation-Based Client Authentication
Downloads
760
Readme
client-attestation-sdk (TypeScript)
Client-side builder SDK for OAuth Attestation-Based Client Authentication
(draft-ietf-oauth-attestation-based-client-auth). Mints the three artifacts a client
presents at the token endpoint:
- Client Attestation JWT (
typ=oauth-client-attestation+jwt) - issued by a Client Attester, names the client (sub=client_id) and binds its instance key viacnf.jwk. - Client Attestation PoP JWT (
typ=oauth-client-attestation-pop+jwt) - proves possession of the instance key (PoP methodattestation_pop_jwt). - DPoP proof JWT (
typ=dpop+jwt) - combined mode (PoP methoddpop_combined); its embeddedjwkheader is the instance key bound in the attestation'scnf.
This is one port of a polyglot SDK; it emits wire-compatible artifacts with the Java, Python and Go
ports and is judged by a shared Java verifier. Built on panva jose.
kid values are RFC 7638 JWK thumbprints (SHA-256, base64url); iat/exp are integer epoch seconds.
Install
Requires Node 20+, or an edge runtime with WebCrypto and fetch (Cloudflare Workers, Vercel Edge,
Deno Deploy) with no Node compatibility layer needed. Crypto, encoding and HTTP use web-standard APIs, and
nothing imports a Node built-in at load time, so the builders, the one-call ClientAttestation flow, the
issuance client and the token validator all work on the edge.
CI tests this in a real Workers runtime: npm run test:edge bundles the sources as an edge bundler would
and runs them in workerd, Cloudflare's open-source Workers runtime
(1.20261007.1, compatibility date 2026-10-01, Node compatibility switched off). It generates keys, builds an
attestation, a PoP and a DPoP proof, runs the token validator against the shared validation vectors, and
checks autoDetectEvidence and HttpSvidSource there. The one-call flow and the issuance client are not
run in workerd. Vercel Edge and Deno Deploy are not tested directly.
Evidence gathering is the part that needs Node. Each of these loads the built-in it needs on first use; the
two sources throw a ClientAttestationError naming themselves from fetch on a runtime without it:
SpireWorkloadApiSvidSourceruns thespire-agentCLI (node:child_process);FileTokenSvidSourcereads a projected token file (node:fs);autoDetectEvidencereads environment variables and probes for token files and the SPIRE socket. On the edge those all read as absent, so it falls through to the GCP metadata server and then throwsEvidenceNotFound. Pass an explicitSvidSourcetoClientAttestationthere:StaticSvidSource, your own, orHttpSvidSourcein development.
npm install @idpartners/client-attestation-sdkThe package is ESM only ("type": "module", no require export), so load it with import.
To build from a clone:
cd typescript # from the repository root
npm ci
npm run build # tsc -> dist/The package exports ESM. Import from @idpartners/client-attestation-sdk or
@idpartners/client-attestation-sdk/validator.
Get a token and call an API
The attester must trust the workload identity and map it to an authorised client.
Set CLIENT_ATTESTATION_TOKEN_FILE to an identity token file with the attester's evidence
audience. ATTESTER_URL, EVIDENCE_AUDIENCE and API_URL are application variables.
EVIDENCE_AUDIENCE is the audience your identity token carries, and the attester's discovery document must
name it too.
export ATTESTER_URL=https://attester.example.com
export EVIDENCE_AUDIENCE=https://attester.example.com/evidence
export API_URL=https://api.example.com/data
export CLIENT_ATTESTATION_TOKEN_FILE=/absolute/path/to/identity-token.jwtSave as call-api.mjs in the application that installed the package:
import { ClientAttestation } from "@idpartners/client-attestation-sdk";
const ca = new ClientAttestation(process.env.ATTESTER_URL, { evidenceAudience: process.env.EVIDENCE_AUDIENCE });
const response = await ca.fetch(process.env.API_URL);
if (!response.ok) throw new Error(`API returned HTTP ${response.status}`);
console.log(response.status);Run node call-api.mjs. Retain ca for later calls so it reuses the token cache.
ca.fetch attaches the access token and a DPoP proof when required. DPoP binds the token to
a client key and requires a signed proof for each request.
See Configuration for the options and evidence sources.
Build a credential offline
jose's crypto is async, so key factories and build() return promises.
This complete example generates temporary keys and needs no server.
import {
ClientAttestationBuilder,
ClientAttestationCredential,
SigningKeyPair,
} from "@idpartners/client-attestation-sdk";
// Attester side: issue the attestation.
const attester = await SigningKeyPair.generate("ES256"); // or SigningKeyPair.fromJwk(jwk, "ES256")
const instance = await SigningKeyPair.generate("ES256"); // the client's instance key
const attestation = await new ClientAttestationBuilder(attester, "https://attester.example.com")
.clientId("https://rp.example.com")
.confirmationKey(instance)
.expiresIn(300)
.build();
// Client side: produce token-request headers.
const cred = new ClientAttestationCredential(attestation, instance);
const popHeaders = await cred.popHeaders("https://rp.example.com", "https://as.example.com");
// { "OAuth-Client-Attestation": "...", "OAuth-Client-Attestation-PoP": "..." }
const dpopHeaders = await cred.dpopHeaders("POST", "https://as.example.com/as/token.oauth2");
// { "OAuth-Client-Attestation": "...", "DPoP": "..." }
console.log(Object.keys(popHeaders), Object.keys(dpopHeaders));Save as build-credential.mjs and run node build-credential.mjs after installing the package.
The code also runs as TypeScript. A production authorisation server must trust the attester's
signing key before it accepts a credential.
The one-call ClientAttestation orchestration class (see the root README)
wraps this: { popMethod: "dpop_combined" } in its options switches it to DPoP mode, and a fetched
challenge is carried through automatically as the PoP JWT's challenge claim or the DPoP proof's nonce
claim, whichever mode is active. operator: {...} asks the attester to state the client's operator: it
goes in the mint request and, repeated as compact sorted-key JSON, in the token request's operator form
parameter. DRAFT_REVISION names the draft revision implemented ("10").
Presenting the token at a resource
import { ClientAttestation } from "@idpartners/client-attestation-sdk";
const ca = new ClientAttestation("pf.example.com", {
evidenceAudience: "https://attester.example.com", // the audience your attester expects the identity token for
popMethod: "dpop_combined",
});
await ca.fetch("https://api.example.com/things"); // headers + one nonce retry
const headers = await ca.requestHeaders("GET", "https://api.example.com/things");requestHeaders(method, url) returns what to send for one request. For a Bearer token that is just
Authorization: Bearer <t>. When the AS answers token_type: DPoP (normal in dpop_combined mode) the
token is bound to the instance key that minted the attestation, so the SDK keeps that key with the cached
token and returns Authorization: DPoP <t> plus a fresh DPoP proof (htm, htu without query or
fragment, ath, and the last DPoP-Nonce that origin sent). authorizationHeader() still works, but for
a DPoP-bound token it returns only the DPoP scheme - a resource will also want the proof.
For a DPoP-bound token, ca.fetch resends once when a resource answers with a use_dpop_nonce challenge (401
WWW-Authenticate: DPoP error="use_dpop_nonce", or a 400 JSON error) and a DPoP-Nonce. String,
URLSearchParams, ArrayBuffer and Request bodies are resent; a one-shot ReadableStream body is not,
and the challenge response is returned instead. ca.fetch uses the global fetch with
redirect: "manual", so a redirect comes back to you and credentials are never resent to its target. In
dpop_combined mode the token request itself retries once when the AS answers 400 use_dpop_nonce with
a DPoP-Nonce.
Failing closed
- If the attester advertises a
challenge_endpointand the challenge cannot be fetched,token()throwsClientAttestationError. PassrequireChallenge: falseto mint without one instead. CLIENT_ATTESTATION_TOKEN_FILEorSPIFFE_ENDPOINT_SOCKETpointing at a missing path throwsEvidenceNotFoundnaming the variable - it never falls through to another identity source. Auto-detect checksCLIENT_ATTESTATION_SVIDfirst, then the token file, then the SPIRE socket, so a variable is only checked if nothing before it matched.EvidenceNotFoundextendsError, notClientAttestationError.challengeAtToken: falsekeeps the attester challenge in the mint's instance proof but leaves it out of the token-endpoint PoPchallengeclaim / DPoPnonce- for attesters that consume a challenge at mint and reject it when the token request presents it again.- The attester URL and every endpoint the attester advertises must be
https, except loopback (localhost,127.0.0.1,::1). PassallowInsecure: truefor, say, an in-cluster plain-http PF.AttestationIssuanceClient.create(its endpoint and every call its profile makes) andOpenBaoTransitSigner.create(the vault address) follow the same rule and take the sameallowInsecureoption.
Other attesters
ClientAttestation speaks PingFederate's discovery and mint protocol by default. For another attester,
pass the profile option (your own AttesterProfile), or the metadata option to give its endpoints
directly and skip discovery: new ClientAttestation({ metadata: { evidenceAudience, attestationEndpoint,
tokenEndpoint } }). AttestationIssuanceClient.create takes { profile } too. See
docs/attester-profiles.md.
Resource server: validate an access token
The validator is the second entry point, @idpartners/client-attestation-sdk/validator. The order of
checks and the error codes are shared with the other ports and pinned by
validation/SEMANTICS.md.
import { AccessTokenValidator, ProtectedResource } from "@idpartners/client-attestation-sdk/validator";
const validator = new AccessTokenValidator({
issuer: "https://issuer.example.com",
audiences: ["https://api.example.com"],
jwksUri: "https://issuer.example.com/jwks",
requiredScopes: ["read"],
});
const result = await validator.validate(accessToken);
if (!result.valid) console.log(result.error); // e.g. "expired", "insufficient_scope"
// validate() ignores cnf: refuse result.claims.cnf unless you verify the sender proof (see below)
const resource = new ProtectedResource("https://api.example.com", ["https://issuer.example.com"], validator);
const decision = await resource.authenticate(request.headers.get("authorization"));
// decision.status is 401, or 403 for missing scope; send decision.wwwAuthenticate backBy default a token's header typ, when present, must be at+jwt; pass acceptedTokenTypes: null for an
issuer that stamps something else. jwksUri and the introspection endpoint must use https unless their
host is loopback (localhost, 127.0.0.1, ::1); allowInsecure: true lifts that.
validate checks the token only. It doesn't look at cnf, so a DPoP- or mTLS-bound token is valid
without its sender proof: refuse one unless you verify that proof. ProtectedResource.authenticate is the
request guard, and it refuses a token with a cnf claim with 401, because it never sees the sender proof.
A runnable server is in
examples/protected-resource-server.ts.
The validator's ProtectedResource strips control characters from every quoted WWW-Authenticate value,
and validation failures never echo the token's own alg, kid, iss or aud back in
error_description.
Token validator
This fragment runs inside a resource-server request handler that supplies accessToken:
import { AccessTokenValidator } from "@idpartners/client-attestation-sdk/validator";
const validator = new AccessTokenValidator({
issuer: "https://issuer.example.com",
audiences: ["https://api.example.com"],
jwksUri: "https://issuer.example.com/jwks",
requiredScopes: ["read"],
});
// In the handler:
// const result = await validator.validate(accessToken);
// if (!result.valid) reject(result.error);validate checks the JWT signature and claims. validateActive uses RFC 7662 introspection
with configured resource-server credentials. It does not first run validate.
See resource configuration and the
resource-server example.
ProtectedResource.authenticate handles Bearer requests. It does not verify DPoP proofs.
Build, test and package
npm test # vitest - self-verifies each artifact with jose
npm run coverage
npm run test:edge # the same SDK in workerd, Cloudflare's Workers runtime, with no Node compatibility
npm run build
npm pack # builds and creates a local .tgz archive; does not publish
npm run emit # writes ../vectors/out/typescript.json for the cross-language Java verifierRun these from typescript/. See Build and test for outputs
and cross-language checks, Examples for server prerequisites, and
Deployment for deploying an application with the SDK.
