@idpartners/client-attestation-sdk 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +111 -2
- package/dist/builders.d.ts +96 -0
- package/dist/builders.js +276 -0
- package/dist/credential.d.ts +22 -0
- package/dist/credential.js +44 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +28 -0
- package/dist/evidence.d.ts +70 -0
- package/dist/evidence.js +150 -0
- package/dist/http.d.ts +41 -0
- package/dist/http.js +158 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +14 -0
- package/dist/issuance.d.ts +42 -0
- package/dist/issuance.js +110 -0
- package/dist/signer.d.ts +33 -0
- package/dist/signer.js +116 -0
- package/dist/signing-key-pair.d.ts +50 -0
- package/dist/signing-key-pair.js +115 -0
- package/dist/svid-source.d.ts +27 -0
- package/dist/svid-source.js +64 -0
- package/dist/token-source.d.ts +120 -0
- package/dist/token-source.js +424 -0
- package/dist/validator/config.d.ts +64 -0
- package/dist/validator/config.js +44 -0
- package/dist/validator/errors.d.ts +20 -0
- package/dist/validator/errors.js +18 -0
- package/dist/validator/index.d.ts +18 -0
- package/dist/validator/index.js +12 -0
- package/dist/validator/jwks.d.ts +39 -0
- package/dist/validator/jwks.js +135 -0
- package/dist/validator/resource.d.ts +42 -0
- package/dist/validator/resource.js +96 -0
- package/dist/validator/result.d.ts +19 -0
- package/dist/validator/result.js +26 -0
- package/dist/validator/validator.d.ts +33 -0
- package/dist/validator/validator.js +197 -0
- package/package.json +53 -4
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Anything that can produce a compact JWT-SVID for an audience. */
|
|
2
|
+
export interface SvidSource {
|
|
3
|
+
fetch(audience: string): Promise<string>;
|
|
4
|
+
}
|
|
5
|
+
/** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
|
|
6
|
+
export declare class StaticSvidSource implements SvidSource {
|
|
7
|
+
private readonly svid;
|
|
8
|
+
constructor(svid: string);
|
|
9
|
+
fetch(_audience: string): Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
/** GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint. */
|
|
12
|
+
export declare class HttpSvidSource implements SvidSource {
|
|
13
|
+
private readonly url;
|
|
14
|
+
constructor(url: string);
|
|
15
|
+
fetch(_audience: string): Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
|
|
19
|
+
* CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
|
|
20
|
+
* subprocess.
|
|
21
|
+
*/
|
|
22
|
+
export declare class SpireWorkloadApiSvidSource implements SvidSource {
|
|
23
|
+
private readonly socketPath;
|
|
24
|
+
private readonly spireAgentBin;
|
|
25
|
+
constructor(socketPath?: string, spireAgentBin?: string);
|
|
26
|
+
fetch(audience: string): Promise<string>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable sources for a workload's SPIFFE JWT-SVID — the infra-identity input to attestation
|
|
3
|
+
* bootstrapping ({@link AttestationIssuanceClient}). The default {@link SpireWorkloadApiSvidSource}
|
|
4
|
+
* fetches it from a real SPIRE agent; the others cover dev and tests.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { jsonOk, request } from "./http.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
/** Per-call timeout for {@link HttpSvidSource}. */
|
|
11
|
+
const HTTP_SVID_TIMEOUT_MS = 20_000;
|
|
12
|
+
/** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
|
|
13
|
+
export class StaticSvidSource {
|
|
14
|
+
svid;
|
|
15
|
+
constructor(svid) {
|
|
16
|
+
this.svid = svid;
|
|
17
|
+
}
|
|
18
|
+
async fetch(_audience) {
|
|
19
|
+
return this.svid;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint. */
|
|
23
|
+
export class HttpSvidSource {
|
|
24
|
+
url;
|
|
25
|
+
constructor(url) {
|
|
26
|
+
this.url = url;
|
|
27
|
+
}
|
|
28
|
+
async fetch(_audience) {
|
|
29
|
+
const res = await request(this.url, { method: "GET", timeoutMs: HTTP_SVID_TIMEOUT_MS });
|
|
30
|
+
const body = jsonOk(res);
|
|
31
|
+
if (typeof body.svid !== "string" || !body.svid)
|
|
32
|
+
throw new Error(`no svid in response from ${this.url}`);
|
|
33
|
+
return body.svid;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
|
|
38
|
+
* CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
|
|
39
|
+
* subprocess.
|
|
40
|
+
*/
|
|
41
|
+
export class SpireWorkloadApiSvidSource {
|
|
42
|
+
socketPath;
|
|
43
|
+
spireAgentBin;
|
|
44
|
+
constructor(socketPath = "/tmp/spire-agent/public/api.sock", spireAgentBin = "spire-agent") {
|
|
45
|
+
this.socketPath = socketPath;
|
|
46
|
+
this.spireAgentBin = spireAgentBin;
|
|
47
|
+
}
|
|
48
|
+
async fetch(audience) {
|
|
49
|
+
const { stdout } = await execFileAsync(this.spireAgentBin, [
|
|
50
|
+
"api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath,
|
|
51
|
+
]);
|
|
52
|
+
for (let line of stdout.split("\n")) {
|
|
53
|
+
line = line.trim();
|
|
54
|
+
if (line.startsWith("token(")) {
|
|
55
|
+
const idx = line.indexOf("):");
|
|
56
|
+
if (idx >= 0)
|
|
57
|
+
return line.slice(idx + 2).trim();
|
|
58
|
+
}
|
|
59
|
+
if ((line.match(/\./g) ?? []).length === 2)
|
|
60
|
+
return line;
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`could not parse SVID from spire-agent output:\n${stdout}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { SvidSource } from "./svid-source.js";
|
|
2
|
+
export declare const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
3
|
+
export declare const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
4
|
+
export declare const WELL_KNOWN_PATH = "/.well-known/client-attester";
|
|
5
|
+
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
6
|
+
export declare const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
7
|
+
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
8
|
+
/** The attester's `/.well-known/client-attester` discovery document. */
|
|
9
|
+
interface AttesterDiscovery {
|
|
10
|
+
evidence_audience?: string;
|
|
11
|
+
attestation_endpoint?: string;
|
|
12
|
+
challenge_endpoint?: string;
|
|
13
|
+
token_endpoint?: string;
|
|
14
|
+
pop_audience?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Options for {@link ClientAttestation}. */
|
|
17
|
+
export interface ClientAttestationOptions {
|
|
18
|
+
/**
|
|
19
|
+
* How to get the workload's platform identity. Leave unset to auto-detect the runtime (env token,
|
|
20
|
+
* projected token file, SPIRE socket, or GCP metadata). Pass an {@link SvidSource} to override.
|
|
21
|
+
*/
|
|
22
|
+
evidence?: SvidSource;
|
|
23
|
+
/**
|
|
24
|
+
* Optional RFC 9396 access request sent at mint time; the attester grants at most what the workload
|
|
25
|
+
* is entitled to.
|
|
26
|
+
*/
|
|
27
|
+
authorizationDetails?: Array<Record<string, unknown>>;
|
|
28
|
+
/**
|
|
29
|
+
* The client's operator - who answers for this workload. Sent in the mint request body as
|
|
30
|
+
* `operator`, so the attester (not the client) is what states it; an attester that does not know
|
|
31
|
+
* this key ignores it, one that does can refuse to name an operator it cannot corroborate. When set,
|
|
32
|
+
* it is also repeated at the token endpoint as the form parameter `operator` (compact JSON, sorted
|
|
33
|
+
* keys) so an AS can populate RFC 8693 `may_act` from it without parsing the attestation; an AS that
|
|
34
|
+
* does not know the parameter ignores it. Unset (or empty) sends nothing.
|
|
35
|
+
*/
|
|
36
|
+
operator?: Record<string, unknown>;
|
|
37
|
+
/** Instance-key signature algorithm (default `ES256`). */
|
|
38
|
+
algorithm?: string;
|
|
39
|
+
/** Refresh this long before the token expires (default 30s). */
|
|
40
|
+
expirySkewSeconds?: number;
|
|
41
|
+
/** Per-request HTTP timeout in milliseconds (default 20000). */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* How to prove possession of the instance key at the token endpoint: the dedicated
|
|
45
|
+
* `OAuth-Client-Attestation-PoP` JWT (`"attestation_pop_jwt"`, the default), or a `DPoP` proof
|
|
46
|
+
* carrying the same challenge in `nonce` (`"dpop_combined"`).
|
|
47
|
+
*/
|
|
48
|
+
popMethod?: "attestation_pop_jwt" | "dpop_combined";
|
|
49
|
+
/**
|
|
50
|
+
* Whether the attester-issued challenge also rides in the token-endpoint proof (the PoP's
|
|
51
|
+
* `challenge` claim, or the DPoP `nonce`), on top of the mint-time instance proof it always
|
|
52
|
+
* protects. The default `true` binds token issuance to the same challenge as the mint, so a captured
|
|
53
|
+
* attestation cannot be redeemed for a token without it - but it requires an attester whose
|
|
54
|
+
* challenge store tolerates being checked at both points. Some attesters treat a challenge as
|
|
55
|
+
* consumed the moment the mint's instance proof uses it, and then reject a token request that
|
|
56
|
+
* presents the same value as "unknown or expired" - set this `false` against one of those (the
|
|
57
|
+
* challenge still protects the mint; only the token-endpoint copy is dropped).
|
|
58
|
+
*/
|
|
59
|
+
challengeAtToken?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* When the attester advertises a `challenge_endpoint`, fail the flow if the challenge cannot be
|
|
62
|
+
* fetched (default `true`). Set `false` to proceed without a challenge instead.
|
|
63
|
+
*/
|
|
64
|
+
requireChallenge?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Allow plain `http` for the PingFederate host and the endpoints the attester advertises (default
|
|
67
|
+
* `false`). Loopback hosts (`localhost`, `127.0.0.1`, `::1`) are always allowed.
|
|
68
|
+
*/
|
|
69
|
+
allowInsecure?: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Gets and caches an access token from a PingFederate host using client attestation.
|
|
73
|
+
*
|
|
74
|
+
* @param pfHost the PingFederate host — `"pf.example.com"`, or a full base URL
|
|
75
|
+
* `"https://pf.example.com"`. Plain hostnames default to https.
|
|
76
|
+
*/
|
|
77
|
+
export declare class ClientAttestation {
|
|
78
|
+
#private;
|
|
79
|
+
readonly baseUrl: string;
|
|
80
|
+
constructor(pfHost: string, opts?: ClientAttestationOptions);
|
|
81
|
+
/** Return a valid access token, minting a new one only when the cached one is near expiry. */
|
|
82
|
+
token(opts?: {
|
|
83
|
+
forceRefresh?: boolean;
|
|
84
|
+
}): Promise<string>;
|
|
85
|
+
/**
|
|
86
|
+
* `{ Authorization: "<scheme> <token>" }`. For a Bearer token that is all a resource needs. For a
|
|
87
|
+
* DPoP-bound token (`token_type` DPoP) the scheme is `DPoP` and every request ALSO needs a fresh
|
|
88
|
+
* DPoP proof — use {@link requestHeaders} (or {@link fetch}) instead, which adds it.
|
|
89
|
+
*/
|
|
90
|
+
authorizationHeader(opts?: {
|
|
91
|
+
forceRefresh?: boolean;
|
|
92
|
+
}): Promise<Record<string, string>>;
|
|
93
|
+
/**
|
|
94
|
+
* The headers to send to a resource for one request, obtaining or refreshing the token as needed.
|
|
95
|
+
*
|
|
96
|
+
* - Bearer token: `{ Authorization: "Bearer <t>" }`
|
|
97
|
+
* - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
|
|
98
|
+
* token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
|
|
99
|
+
* url's origin returned, if any.
|
|
100
|
+
*/
|
|
101
|
+
requestHeaders(method: string, url: string, opts?: {
|
|
102
|
+
forceRefresh?: boolean;
|
|
103
|
+
}): Promise<Record<string, string>>;
|
|
104
|
+
/**
|
|
105
|
+
* A `fetch` wrapper that attaches the token (refreshing it as needed) to the request. Same signature
|
|
106
|
+
* as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
|
|
107
|
+
* a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
|
|
108
|
+
* unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
|
|
109
|
+
*/
|
|
110
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
111
|
+
/** The attester's `/.well-known/client-attester` document (fetched once, then cached). */
|
|
112
|
+
discovery(): Promise<AttesterDiscovery>;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
|
|
116
|
+
* bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function canonicalJson(value: unknown): string;
|
|
119
|
+
export declare function normalizeBaseUrl(pfHost: string): string;
|
|
120
|
+
export declare function jwtSub(compactJwt: string): string;
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One call to get an access token with client attestation.
|
|
3
|
+
*
|
|
4
|
+
* Point it at a PingFederate host and call `.token()`. It runs the whole flow:
|
|
5
|
+
*
|
|
6
|
+
* discover -> get platform identity (evidence) -> get a challenge -> mint the attestation ->
|
|
7
|
+
* call the token endpoint with the attestation headers -> cache the access token
|
|
8
|
+
*
|
|
9
|
+
* The workload sends no client secret and no client_id. Its only credential is the attestation, built
|
|
10
|
+
* from the identity its runtime already gives it (auto-detected; see `evidence.ts`).
|
|
11
|
+
*
|
|
12
|
+
* import { ClientAttestation } from "@idpartners/client-attestation-sdk";
|
|
13
|
+
*
|
|
14
|
+
* const ca = new ClientAttestation("pf.example.com");
|
|
15
|
+
* const token = await ca.token(); // cached until it nears expiry
|
|
16
|
+
*
|
|
17
|
+
* // the fetch wrapper attaches the token (and a DPoP proof for a DPoP-bound token):
|
|
18
|
+
* await ca.fetch("https://api.example.com/things");
|
|
19
|
+
* // or build the headers for any other client:
|
|
20
|
+
* const headers = await ca.requestHeaders("GET", "https://api.example.com/things");
|
|
21
|
+
*/
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
import { DpopProofBuilder } from "./builders.js";
|
|
24
|
+
import { ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
|
|
25
|
+
import { ClientAttestationError } from "./errors.js";
|
|
26
|
+
import { autoDetectEvidence } from "./evidence.js";
|
|
27
|
+
import { INSTANCE_PROOF_TYP } from "./issuance.js";
|
|
28
|
+
import { jsonOk, request } from "./http.js";
|
|
29
|
+
import { SigningKeyPair, signCompact } from "./signing-key-pair.js";
|
|
30
|
+
export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
31
|
+
export const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
32
|
+
export const WELL_KNOWN_PATH = "/.well-known/client-attester";
|
|
33
|
+
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
34
|
+
export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
35
|
+
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
36
|
+
const USE_DPOP_NONCE = "use_dpop_nonce";
|
|
37
|
+
const DPOP_NONCE_HEADER = "DPoP-Nonce";
|
|
38
|
+
/**
|
|
39
|
+
* Gets and caches an access token from a PingFederate host using client attestation.
|
|
40
|
+
*
|
|
41
|
+
* @param pfHost the PingFederate host — `"pf.example.com"`, or a full base URL
|
|
42
|
+
* `"https://pf.example.com"`. Plain hostnames default to https.
|
|
43
|
+
*/
|
|
44
|
+
export class ClientAttestation {
|
|
45
|
+
baseUrl;
|
|
46
|
+
#evidence;
|
|
47
|
+
#authorizationDetails;
|
|
48
|
+
#operator;
|
|
49
|
+
#challengeAtToken;
|
|
50
|
+
#algorithm;
|
|
51
|
+
#skew;
|
|
52
|
+
#timeoutMs;
|
|
53
|
+
#popMethod;
|
|
54
|
+
#requireChallenge;
|
|
55
|
+
#allowInsecure;
|
|
56
|
+
/** The token, its type and its bound key: always replaced together, never one without the others. */
|
|
57
|
+
#state = null;
|
|
58
|
+
#discovery = null;
|
|
59
|
+
#refreshing = null;
|
|
60
|
+
/** The last `DPoP-Nonce` each origin returned. */
|
|
61
|
+
#nonces = new Map();
|
|
62
|
+
constructor(pfHost, opts = {}) {
|
|
63
|
+
this.baseUrl = normalizeBaseUrl(pfHost);
|
|
64
|
+
this.#evidence = opts.evidence;
|
|
65
|
+
this.#authorizationDetails = opts.authorizationDetails;
|
|
66
|
+
this.#operator = opts.operator && Object.keys(opts.operator).length > 0 ? opts.operator : undefined;
|
|
67
|
+
this.#challengeAtToken = opts.challengeAtToken ?? true;
|
|
68
|
+
this.#algorithm = opts.algorithm ?? "ES256";
|
|
69
|
+
this.#skew = opts.expirySkewSeconds ?? DEFAULT_EXPIRY_SKEW_SECONDS;
|
|
70
|
+
this.#timeoutMs = opts.timeoutMs ?? 20_000;
|
|
71
|
+
this.#popMethod = opts.popMethod ?? "attestation_pop_jwt";
|
|
72
|
+
this.#requireChallenge = opts.requireChallenge ?? true;
|
|
73
|
+
this.#allowInsecure = opts.allowInsecure ?? false;
|
|
74
|
+
this.#checkSecure(this.baseUrl, "pfHost");
|
|
75
|
+
}
|
|
76
|
+
// ── public API ──────────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
/** Return a valid access token, minting a new one only when the cached one is near expiry. */
|
|
78
|
+
async token(opts = {}) {
|
|
79
|
+
return (await this.#current(opts)).accessToken;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `{ Authorization: "<scheme> <token>" }`. For a Bearer token that is all a resource needs. For a
|
|
83
|
+
* DPoP-bound token (`token_type` DPoP) the scheme is `DPoP` and every request ALSO needs a fresh
|
|
84
|
+
* DPoP proof — use {@link requestHeaders} (or {@link fetch}) instead, which adds it.
|
|
85
|
+
*/
|
|
86
|
+
async authorizationHeader(opts = {}) {
|
|
87
|
+
const state = await this.#current(opts);
|
|
88
|
+
return { Authorization: `${scheme(state)} ${state.accessToken}` };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The headers to send to a resource for one request, obtaining or refreshing the token as needed.
|
|
92
|
+
*
|
|
93
|
+
* - Bearer token: `{ Authorization: "Bearer <t>" }`
|
|
94
|
+
* - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
|
|
95
|
+
* token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
|
|
96
|
+
* url's origin returned, if any.
|
|
97
|
+
*/
|
|
98
|
+
async requestHeaders(method, url, opts = {}) {
|
|
99
|
+
const state = await this.#current(opts);
|
|
100
|
+
const headers = { Authorization: `${scheme(state)} ${state.accessToken}` };
|
|
101
|
+
if (isDpop(state)) {
|
|
102
|
+
headers[DPOP_HEADER] = await new DpopProofBuilder(state.key)
|
|
103
|
+
.method(method)
|
|
104
|
+
.uri(url)
|
|
105
|
+
.accessToken(state.accessToken)
|
|
106
|
+
.nonce(this.#nonces.get(originOf(url)) ?? null)
|
|
107
|
+
.build();
|
|
108
|
+
}
|
|
109
|
+
return headers;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A `fetch` wrapper that attaches the token (refreshing it as needed) to the request. Same signature
|
|
113
|
+
* as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
|
|
114
|
+
* a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
|
|
115
|
+
* unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
|
|
116
|
+
*/
|
|
117
|
+
async fetch(input, init = {}) {
|
|
118
|
+
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
|
119
|
+
const url = isRequest ? input.url : String(input);
|
|
120
|
+
const method = (init.method ?? (isRequest ? input.method : "GET")).toUpperCase();
|
|
121
|
+
// A Request's own body is cloned so the retry can send it again.
|
|
122
|
+
const nextInput = () => (isRequest ? input.clone() : input);
|
|
123
|
+
let dpopBound = false;
|
|
124
|
+
const send = async () => {
|
|
125
|
+
const headers = new Headers(init.headers ?? (isRequest ? input.headers : undefined));
|
|
126
|
+
const auth = await this.requestHeaders(method, url);
|
|
127
|
+
dpopBound = DPOP_HEADER in auth;
|
|
128
|
+
for (const [name, value] of Object.entries(auth))
|
|
129
|
+
headers.set(name, value);
|
|
130
|
+
return fetch(nextInput(), { ...init, headers });
|
|
131
|
+
};
|
|
132
|
+
const first = await send();
|
|
133
|
+
if (!dpopBound)
|
|
134
|
+
return first;
|
|
135
|
+
const nonce = first.headers.get(DPOP_NONCE_HEADER);
|
|
136
|
+
if (nonce)
|
|
137
|
+
this.#nonces.set(originOf(url), nonce);
|
|
138
|
+
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
|
|
139
|
+
return first;
|
|
140
|
+
await first.body?.cancel().catch(() => undefined);
|
|
141
|
+
return send();
|
|
142
|
+
}
|
|
143
|
+
/** The attester's `/.well-known/client-attester` document (fetched once, then cached). */
|
|
144
|
+
async discovery() {
|
|
145
|
+
if (this.#discovery === null) {
|
|
146
|
+
this.#discovery = await this.#getJson("GET", this.baseUrl + WELL_KNOWN_PATH);
|
|
147
|
+
}
|
|
148
|
+
return this.#discovery;
|
|
149
|
+
}
|
|
150
|
+
// ── the flow ────────────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
/** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
|
|
152
|
+
async #current(opts) {
|
|
153
|
+
const state = this.#state;
|
|
154
|
+
if (!opts.forceRefresh && state && Date.now() < state.expiresAt) {
|
|
155
|
+
return state;
|
|
156
|
+
}
|
|
157
|
+
if (!this.#refreshing) {
|
|
158
|
+
this.#refreshing = this.#refresh().finally(() => {
|
|
159
|
+
this.#refreshing = null;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return this.#refreshing;
|
|
163
|
+
}
|
|
164
|
+
async #refresh() {
|
|
165
|
+
const { response, key } = await this.#runFlow();
|
|
166
|
+
const expiresIn = response.expires_in;
|
|
167
|
+
const state = {
|
|
168
|
+
accessToken: response.access_token,
|
|
169
|
+
tokenType: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
170
|
+
key,
|
|
171
|
+
expiresAt: expiresIn ? Date.now() + (Number(expiresIn) - this.#skew) * 1000 : 0,
|
|
172
|
+
};
|
|
173
|
+
this.#state = state;
|
|
174
|
+
return state;
|
|
175
|
+
}
|
|
176
|
+
async #runFlow() {
|
|
177
|
+
const doc = await this.discovery();
|
|
178
|
+
const audience = doc.evidence_audience;
|
|
179
|
+
if (!audience) {
|
|
180
|
+
throw new ClientAttestationError("attester discovery document has no evidence_audience");
|
|
181
|
+
}
|
|
182
|
+
const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
|
|
183
|
+
const svid = await evidenceSource.fetch(audience);
|
|
184
|
+
const instanceKey = await SigningKeyPair.generate(this.#algorithm);
|
|
185
|
+
const challenge = await this.#fetchChallenge(doc);
|
|
186
|
+
const attestation = await this.#mint(doc, instanceKey, svid, challenge);
|
|
187
|
+
// token_endpoint is advertised by current attesters; fall back to the standard PF path so an older
|
|
188
|
+
// discovery document still works from the host alone.
|
|
189
|
+
const tokenEndpoint = doc.token_endpoint || this.baseUrl + "/as/token.oauth2";
|
|
190
|
+
this.#checkSecure(tokenEndpoint, "token_endpoint");
|
|
191
|
+
const clientId = jwtSub(attestation);
|
|
192
|
+
// The PoP audience must be PF's configured issuer, which the attester advertises as pop_audience.
|
|
193
|
+
// Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
|
|
194
|
+
// proxy in between) — so prefer the advertised value.
|
|
195
|
+
const popAudience = doc.pop_audience || tokenEndpoint;
|
|
196
|
+
// See `challengeAtToken`: some attesters consume the challenge the moment the mint's instance
|
|
197
|
+
// proof uses it, and reject a token request that presents the same value a second time. Against
|
|
198
|
+
// one of those, only the mint gets the challenge.
|
|
199
|
+
const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
|
|
200
|
+
const credential = new ClientAttestationCredential(attestation, instanceKey);
|
|
201
|
+
if (this.#popMethod !== "dpop_combined") {
|
|
202
|
+
const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
|
|
203
|
+
return { response: await this.#callTokenEndpoint(tokenEndpoint, headers), key: instanceKey };
|
|
204
|
+
}
|
|
205
|
+
// DPoP combined: the attester challenge rides as the DPoP nonce. If the AS demands its own nonce
|
|
206
|
+
// (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
|
|
207
|
+
let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
|
|
208
|
+
const serverNonce = res.headers.get(DPOP_NONCE_HEADER);
|
|
209
|
+
if (serverNonce)
|
|
210
|
+
this.#nonces.set(originOf(tokenEndpoint), serverNonce);
|
|
211
|
+
if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
|
|
212
|
+
res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
|
|
213
|
+
}
|
|
214
|
+
return { response: tokenResponse(res), key: instanceKey };
|
|
215
|
+
}
|
|
216
|
+
async #fetchChallenge(doc) {
|
|
217
|
+
const endpoint = doc.challenge_endpoint;
|
|
218
|
+
if (!endpoint)
|
|
219
|
+
return undefined;
|
|
220
|
+
this.#checkSecure(endpoint, "challenge_endpoint");
|
|
221
|
+
// The attester advertised a challenge, so minting without one weakens replay protection: fail
|
|
222
|
+
// closed unless the caller opted out with requireChallenge: false.
|
|
223
|
+
let body;
|
|
224
|
+
try {
|
|
225
|
+
body = await this.#getJson("POST", endpoint);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (!(err instanceof ClientAttestationError))
|
|
229
|
+
throw err;
|
|
230
|
+
if (!this.#requireChallenge)
|
|
231
|
+
return undefined;
|
|
232
|
+
throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
|
|
233
|
+
status: err.status,
|
|
234
|
+
body: err.body,
|
|
235
|
+
cause: err,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const challenge = body.attestation_challenge;
|
|
239
|
+
if (typeof challenge === "string" && challenge)
|
|
240
|
+
return challenge;
|
|
241
|
+
if (!this.#requireChallenge)
|
|
242
|
+
return undefined;
|
|
243
|
+
throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
|
|
244
|
+
}
|
|
245
|
+
async #mint(doc, instanceKey, svid, challenge) {
|
|
246
|
+
if (!doc.attestation_endpoint) {
|
|
247
|
+
throw new ClientAttestationError("attester discovery document has no attestation_endpoint");
|
|
248
|
+
}
|
|
249
|
+
this.#checkSecure(doc.attestation_endpoint, "attestation_endpoint");
|
|
250
|
+
const now = Math.floor(Date.now() / 1000);
|
|
251
|
+
const proofClaims = {
|
|
252
|
+
aud: doc.evidence_audience,
|
|
253
|
+
jti: randomUUID(),
|
|
254
|
+
iat: now,
|
|
255
|
+
};
|
|
256
|
+
if (challenge)
|
|
257
|
+
proofClaims.challenge = challenge;
|
|
258
|
+
const proof = await signCompact(proofClaims, instanceKey, INSTANCE_PROOF_TYP, false);
|
|
259
|
+
// Evidence-first: no client_id. The attester maps the identity to a client.
|
|
260
|
+
const body = {
|
|
261
|
+
instance_key: instanceKey.publicJwk(),
|
|
262
|
+
svid,
|
|
263
|
+
proof,
|
|
264
|
+
};
|
|
265
|
+
if (this.#authorizationDetails)
|
|
266
|
+
body.authorization_details = this.#authorizationDetails;
|
|
267
|
+
if (this.#operator)
|
|
268
|
+
body.operator = this.#operator;
|
|
269
|
+
const response = await this.#getJson("POST", doc.attestation_endpoint, body);
|
|
270
|
+
if (!response.attestation) {
|
|
271
|
+
throw new ClientAttestationError("attester response contained no attestation", {
|
|
272
|
+
body: JSON.stringify(response),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return response.attestation;
|
|
276
|
+
}
|
|
277
|
+
async #callTokenEndpoint(endpoint, authHeaders) {
|
|
278
|
+
return tokenResponse(await this.#postToken(endpoint, authHeaders));
|
|
279
|
+
}
|
|
280
|
+
#postToken(endpoint, authHeaders) {
|
|
281
|
+
const params = new URLSearchParams({ grant_type: "client_credentials" });
|
|
282
|
+
// Extension parameter, sent only when configured: the attestation is the authoritative carrier,
|
|
283
|
+
// this repeats the operator on the wire for an AS that maps it into may_act.
|
|
284
|
+
if (this.#operator)
|
|
285
|
+
params.set("operator", canonicalJson(this.#operator));
|
|
286
|
+
const form = params.toString();
|
|
287
|
+
return this.#send(endpoint, {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: {
|
|
290
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
291
|
+
Accept: "application/json",
|
|
292
|
+
...authHeaders,
|
|
293
|
+
},
|
|
294
|
+
body: form,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/** Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. */
|
|
298
|
+
#checkSecure(url, what) {
|
|
299
|
+
if (this.#allowInsecure)
|
|
300
|
+
return;
|
|
301
|
+
let parsed;
|
|
302
|
+
try {
|
|
303
|
+
parsed = new URL(url);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
throw new ClientAttestationError(`${what} is not a valid URL: ${url}`);
|
|
307
|
+
}
|
|
308
|
+
if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
|
|
309
|
+
return;
|
|
310
|
+
throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
|
|
311
|
+
}
|
|
312
|
+
async #getJson(method, url, jsonBody) {
|
|
313
|
+
const headers = { Accept: "application/json" };
|
|
314
|
+
let body;
|
|
315
|
+
if (jsonBody !== undefined) {
|
|
316
|
+
headers["Content-Type"] = "application/json";
|
|
317
|
+
body = JSON.stringify(jsonBody);
|
|
318
|
+
}
|
|
319
|
+
return jsonOk(await this.#send(url, { method, headers, body }), { allowEmpty: true });
|
|
320
|
+
}
|
|
321
|
+
/** Every call the flow makes goes through the shared transport, with this client's timeout. */
|
|
322
|
+
#send(url, opts) {
|
|
323
|
+
return request(url, { ...opts, timeoutMs: this.#timeoutMs });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
327
|
+
function tokenResponse(res) {
|
|
328
|
+
if (!res.ok) {
|
|
329
|
+
throw new ClientAttestationError(`token endpoint returned HTTP ${res.status}`, {
|
|
330
|
+
status: res.status,
|
|
331
|
+
body: res.body,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const parsed = jsonOk(res, { allowEmpty: true });
|
|
335
|
+
if (!parsed.access_token) {
|
|
336
|
+
throw new ClientAttestationError("token endpoint returned no access_token", { body: res.body });
|
|
337
|
+
}
|
|
338
|
+
return parsed;
|
|
339
|
+
}
|
|
340
|
+
function isDpop(state) {
|
|
341
|
+
return state.tokenType.toLowerCase() === "dpop";
|
|
342
|
+
}
|
|
343
|
+
function scheme(state) {
|
|
344
|
+
return isDpop(state) ? "DPoP" : "Bearer";
|
|
345
|
+
}
|
|
346
|
+
/** `scheme://host[:port]` of a URL, the key DPoP nonces are remembered under. */
|
|
347
|
+
function originOf(url) {
|
|
348
|
+
try {
|
|
349
|
+
return new URL(url).origin;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return url;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** The `error` member of a JSON object body, or undefined. */
|
|
356
|
+
function jsonError(body) {
|
|
357
|
+
try {
|
|
358
|
+
return JSON.parse(body)?.error;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Can this fetch body be sent a second time? One-shot streams cannot. */
|
|
365
|
+
function replayable(body) {
|
|
366
|
+
if (body == null)
|
|
367
|
+
return true;
|
|
368
|
+
return !(typeof ReadableStream !== "undefined" && body instanceof ReadableStream) &&
|
|
369
|
+
!(typeof body[Symbol.asyncIterator] === "function");
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* RFC 9449 section 9: a resource asks for a nonce with 401 + `WWW-Authenticate: DPoP error="use_dpop_nonce"`;
|
|
373
|
+
* section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`.
|
|
374
|
+
*/
|
|
375
|
+
async function isNonceChallenge(res) {
|
|
376
|
+
if (res.status === 401) {
|
|
377
|
+
const challenge = res.headers.get("WWW-Authenticate") ?? "";
|
|
378
|
+
return /(^|,)\s*DPoP\b/i.test(challenge) && /error\s*=\s*"?use_dpop_nonce"?/i.test(challenge);
|
|
379
|
+
}
|
|
380
|
+
if (res.status === 400) {
|
|
381
|
+
return jsonError(await res.clone().text()) === USE_DPOP_NONCE;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
|
|
387
|
+
* bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
|
|
388
|
+
*/
|
|
389
|
+
export function canonicalJson(value) {
|
|
390
|
+
return JSON.stringify(sortKeys(value)).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
391
|
+
}
|
|
392
|
+
function sortKeys(value) {
|
|
393
|
+
if (Array.isArray(value))
|
|
394
|
+
return value.map(sortKeys);
|
|
395
|
+
if (value !== null && typeof value === "object") {
|
|
396
|
+
const obj = value;
|
|
397
|
+
return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, sortKeys(obj[k])]));
|
|
398
|
+
}
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
export function normalizeBaseUrl(pfHost) {
|
|
402
|
+
let host = (pfHost ?? "").trim().replace(/\/+$/, "");
|
|
403
|
+
if (!host) {
|
|
404
|
+
throw new Error("pfHost is required");
|
|
405
|
+
}
|
|
406
|
+
if (!host.includes("://")) {
|
|
407
|
+
host = "https://" + host;
|
|
408
|
+
}
|
|
409
|
+
return host;
|
|
410
|
+
}
|
|
411
|
+
export function jwtSub(compactJwt) {
|
|
412
|
+
const payload = compactJwt.split(".")[1] ?? "";
|
|
413
|
+
let claims;
|
|
414
|
+
try {
|
|
415
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
throw new ClientAttestationError("minted attestation is not a well-formed JWT");
|
|
419
|
+
}
|
|
420
|
+
if (!claims?.sub) {
|
|
421
|
+
throw new ClientAttestationError("minted attestation has no sub (client id)");
|
|
422
|
+
}
|
|
423
|
+
return claims.sub;
|
|
424
|
+
}
|