@idpartners/client-attestation-sdk 0.2.0 → 1.0.1
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/README.md +35 -4
- package/dist/attester.d.ts +93 -0
- package/dist/attester.js +139 -0
- package/dist/builders.d.ts +4 -8
- package/dist/builders.js +18 -10
- package/dist/evidence.d.ts +4 -19
- package/dist/evidence.js +38 -20
- package/dist/http.d.ts +1 -41
- package/dist/http.js +30 -5
- package/dist/index.d.ts +11 -3
- package/dist/index.js +8 -2
- package/dist/issuance.d.ts +22 -3
- package/dist/issuance.js +37 -30
- package/dist/logging.d.ts +12 -0
- package/dist/logging.js +77 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +64 -0
- package/dist/signer.d.ts +2 -3
- package/dist/signer.js +9 -4
- package/dist/signing-key-pair.d.ts +8 -14
- package/dist/signing-key-pair.js +20 -5
- package/dist/svid-source.d.ts +15 -1
- package/dist/svid-source.js +20 -7
- package/dist/token-source.d.ts +43 -16
- package/dist/token-source.js +162 -75
- package/dist/validator/config.d.ts +5 -8
- package/dist/validator/config.js +22 -5
- package/dist/validator/index.d.ts +4 -3
- package/dist/validator/index.js +3 -3
- package/dist/validator/jwks.d.ts +0 -37
- package/dist/validator/jwks.js +17 -2
- package/dist/validator/resource.d.ts +1 -6
- package/dist/validator/resource.js +10 -1
- package/dist/validator/result.d.ts +0 -2
- package/dist/validator/result.js +2 -0
- package/dist/validator/strict.d.ts +1 -0
- package/dist/validator/strict.js +202 -0
- package/dist/validator/validator.d.ts +8 -0
- package/dist/validator/validator.js +120 -77
- package/package.json +9 -6
package/dist/svid-source.js
CHANGED
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
* bootstrapping ({@link AttestationIssuanceClient}). The default {@link SpireWorkloadApiSvidSource}
|
|
4
4
|
* fetches it from a real SPIRE agent; the others cover dev and tests.
|
|
5
5
|
*/
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
6
|
import { jsonOk, request } from "./http.js";
|
|
9
|
-
|
|
7
|
+
import { importNodeBuiltin } from "./runtime.js";
|
|
10
8
|
/** Per-call timeout for {@link HttpSvidSource}. */
|
|
11
9
|
const HTTP_SVID_TIMEOUT_MS = 20_000;
|
|
12
10
|
/** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
|
|
@@ -19,7 +17,15 @@ export class StaticSvidSource {
|
|
|
19
17
|
return this.svid;
|
|
20
18
|
}
|
|
21
19
|
}
|
|
22
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint.
|
|
22
|
+
*
|
|
23
|
+
* For development and testing only. It trusts whatever the URL returns and the endpoint has no caller
|
|
24
|
+
* authentication, so anyone who can reach it can obtain the workload's SVID. In production use a SPIRE,
|
|
25
|
+
* file or cloud source.
|
|
26
|
+
*
|
|
27
|
+
* @experimental See docs/api.md: a development and test source with no caller authentication.
|
|
28
|
+
*/
|
|
23
29
|
export class HttpSvidSource {
|
|
24
30
|
url;
|
|
25
31
|
constructor(url) {
|
|
@@ -37,6 +43,12 @@ export class HttpSvidSource {
|
|
|
37
43
|
* The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
|
|
38
44
|
* CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
|
|
39
45
|
* subprocess.
|
|
46
|
+
*
|
|
47
|
+
* Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
|
|
48
|
+
* a `ClientAttestationError` on a runtime without it.
|
|
49
|
+
*
|
|
50
|
+
* @experimental See docs/api.md: it shells out to the spire-agent CLI, and its constructor will change
|
|
51
|
+
* when it moves to the Workload API socket.
|
|
40
52
|
*/
|
|
41
53
|
export class SpireWorkloadApiSvidSource {
|
|
42
54
|
socketPath;
|
|
@@ -46,9 +58,10 @@ export class SpireWorkloadApiSvidSource {
|
|
|
46
58
|
this.spireAgentBin = spireAgentBin;
|
|
47
59
|
}
|
|
48
60
|
async fetch(audience) {
|
|
49
|
-
const {
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
const { execFile } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
|
|
62
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
63
|
+
execFile(this.spireAgentBin, ["api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath], (err, out) => (err ? reject(err) : resolve(String(out))));
|
|
64
|
+
});
|
|
52
65
|
for (let line of stdout.split("\n")) {
|
|
53
66
|
line = line.trim();
|
|
54
67
|
if (line.startsWith("token(")) {
|
package/dist/token-source.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { type AttesterMetadata, type AttesterProfile } from "./attester.js";
|
|
2
|
+
import { type Logger } from "./logging.js";
|
|
1
3
|
import type { SvidSource } from "./svid-source.js";
|
|
2
4
|
export declare const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
3
5
|
export declare const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
4
|
-
export declare const WELL_KNOWN_PATH = "/.well-known/client-attester";
|
|
5
6
|
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
6
7
|
export declare const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
7
8
|
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
8
|
-
/** The attester's `/.well-known/client-attester`
|
|
9
|
-
interface AttesterDiscovery {
|
|
9
|
+
/** The attester's discovery document (PingFederate: `/.well-known/client-attester`). */
|
|
10
|
+
export interface AttesterDiscovery {
|
|
11
|
+
[member: string]: unknown;
|
|
10
12
|
evidence_audience?: string;
|
|
11
13
|
attestation_endpoint?: string;
|
|
12
14
|
challenge_endpoint?: string;
|
|
@@ -15,6 +17,21 @@ interface AttesterDiscovery {
|
|
|
15
17
|
}
|
|
16
18
|
/** Options for {@link ClientAttestation}. */
|
|
17
19
|
export interface ClientAttestationOptions {
|
|
20
|
+
/**
|
|
21
|
+
* The attester's host, when the options object is the only argument. The positional argument wins
|
|
22
|
+
* when both are given.
|
|
23
|
+
*/
|
|
24
|
+
attesterUrl?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The attester profile that discovers the attester and mints the attestation. Default
|
|
27
|
+
* {@link PingFederateProfile}, the behaviour of every earlier release. See docs/attester-profiles.md.
|
|
28
|
+
*/
|
|
29
|
+
profile?: AttesterProfile;
|
|
30
|
+
/**
|
|
31
|
+
* Static attester metadata (evidence audience, attestation and token endpoints, optionally challenge
|
|
32
|
+
* endpoint and PoP audience). When set, no discovery request is made; the profile still mints.
|
|
33
|
+
*/
|
|
34
|
+
metadata?: AttesterMetadata;
|
|
18
35
|
/**
|
|
19
36
|
* How to get the workload's platform identity. Leave unset to auto-detect the runtime (env token,
|
|
20
37
|
* projected token file, SPIRE socket, or GCP metadata). Pass an {@link SvidSource} to override.
|
|
@@ -63,21 +80,32 @@ export interface ClientAttestationOptions {
|
|
|
63
80
|
*/
|
|
64
81
|
requireChallenge?: boolean;
|
|
65
82
|
/**
|
|
66
|
-
* Allow plain `http` for the
|
|
83
|
+
* Allow plain `http` for the attester URL and the endpoints the attester advertises (default
|
|
67
84
|
* `false`). Loopback hosts (`localhost`, `127.0.0.1`, `::1`) are always allowed.
|
|
68
85
|
*/
|
|
69
86
|
allowInsecure?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Where to send log events (see docs/logging.md). Any object with `debug`, `info`, `warn` and
|
|
89
|
+
* `error(msg, attrs)` works, `console` included. Unset, nothing is logged. Tokens, proofs,
|
|
90
|
+
* attestations, SVIDs and key material are never logged.
|
|
91
|
+
*/
|
|
92
|
+
logger?: Logger;
|
|
70
93
|
}
|
|
71
94
|
/**
|
|
72
|
-
* Gets and caches an access token
|
|
95
|
+
* Gets and caches an access token using client attestation.
|
|
96
|
+
*
|
|
97
|
+
* `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
|
|
98
|
+
* discovery) or a custom `opts.profile`.
|
|
73
99
|
*
|
|
74
|
-
* @param
|
|
75
|
-
* `"https://pf.example.com"`. Plain hostnames default to https.
|
|
100
|
+
* @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
|
|
101
|
+
* `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
|
|
102
|
+
* is given.
|
|
76
103
|
*/
|
|
77
104
|
export declare class ClientAttestation {
|
|
78
105
|
#private;
|
|
106
|
+
/** The normalised attester URL, or `""` when the client was built from static metadata alone. */
|
|
79
107
|
readonly baseUrl: string;
|
|
80
|
-
constructor(
|
|
108
|
+
constructor(attesterUrl?: string | ClientAttestationOptions, opts?: ClientAttestationOptions);
|
|
81
109
|
/** Return a valid access token, minting a new one only when the cached one is near expiry. */
|
|
82
110
|
token(opts?: {
|
|
83
111
|
forceRefresh?: boolean;
|
|
@@ -106,15 +134,14 @@ export declare class ClientAttestation {
|
|
|
106
134
|
* as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
|
|
107
135
|
* a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
|
|
108
136
|
* unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
|
|
137
|
+
* Redirects are returned without following them, even if the input or init requests redirect following.
|
|
109
138
|
*/
|
|
110
139
|
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
111
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
|
|
142
|
+
* then cached. With static `metadata`, the fields that were given, in the document's snake_case.
|
|
143
|
+
*/
|
|
112
144
|
discovery(): Promise<AttesterDiscovery>;
|
|
145
|
+
/** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
|
|
146
|
+
attesterMetadata(): Promise<AttesterMetadata>;
|
|
113
147
|
}
|
|
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;
|
package/dist/token-source.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One call to get an access token with client attestation.
|
|
3
3
|
*
|
|
4
|
-
* Point it at
|
|
4
|
+
* Point it at an attester (PingFederate by default) and call `.token()`. It runs the whole flow:
|
|
5
5
|
*
|
|
6
6
|
* discover -> get platform identity (evidence) -> get a challenge -> mint the attestation ->
|
|
7
7
|
* call the token endpoint with the attestation headers -> cache the access token
|
|
@@ -19,30 +19,43 @@
|
|
|
19
19
|
* // or build the headers for any other client:
|
|
20
20
|
* const headers = await ca.requestHeaders("GET", "https://api.example.com/things");
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { base64url } from "jose";
|
|
23
23
|
import { DpopProofBuilder } from "./builders.js";
|
|
24
24
|
import { ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
|
|
25
|
-
import { ClientAttestationError } from "./errors.js";
|
|
25
|
+
import { ClientAttestationError, TransportError } from "./errors.js";
|
|
26
26
|
import { autoDetectEvidence } from "./evidence.js";
|
|
27
|
-
import {
|
|
27
|
+
import { MintRequest, PingFederateProfile, metadataDocument, } from "./attester.js";
|
|
28
28
|
import { jsonOk, request } from "./http.js";
|
|
29
|
-
import {
|
|
29
|
+
import { emit, failureReason, hostOf, NOOP_LOGGER, urlParts } from "./logging.js";
|
|
30
|
+
import { utf8Decode } from "./runtime.js";
|
|
31
|
+
import { SigningKeyPair } from "./signing-key-pair.js";
|
|
30
32
|
export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
31
33
|
export const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
32
|
-
export const WELL_KNOWN_PATH = "/.well-known/client-attester";
|
|
33
34
|
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
34
35
|
export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
35
36
|
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
36
37
|
const USE_DPOP_NONCE = "use_dpop_nonce";
|
|
37
38
|
const DPOP_NONCE_HEADER = "DPoP-Nonce";
|
|
39
|
+
/** What the https check calls the URL a profile's call goes to, by step. */
|
|
40
|
+
const STEP_LABELS = {
|
|
41
|
+
discover: "attesterUrl",
|
|
42
|
+
mint: "attestation_endpoint",
|
|
43
|
+
challenge: "challenge_endpoint",
|
|
44
|
+
};
|
|
38
45
|
/**
|
|
39
|
-
* Gets and caches an access token
|
|
46
|
+
* Gets and caches an access token using client attestation.
|
|
40
47
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
48
|
+
* `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
|
|
49
|
+
* discovery) or a custom `opts.profile`.
|
|
50
|
+
*
|
|
51
|
+
* @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
|
|
52
|
+
* `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
|
|
53
|
+
* is given.
|
|
43
54
|
*/
|
|
44
55
|
export class ClientAttestation {
|
|
56
|
+
/** The normalised attester URL, or `""` when the client was built from static metadata alone. */
|
|
45
57
|
baseUrl;
|
|
58
|
+
#profile;
|
|
46
59
|
#evidence;
|
|
47
60
|
#authorizationDetails;
|
|
48
61
|
#operator;
|
|
@@ -53,14 +66,21 @@ export class ClientAttestation {
|
|
|
53
66
|
#popMethod;
|
|
54
67
|
#requireChallenge;
|
|
55
68
|
#allowInsecure;
|
|
69
|
+
#logger;
|
|
56
70
|
/** The token, its type and its bound key: always replaced together, never one without the others. */
|
|
57
71
|
#state = null;
|
|
58
|
-
#
|
|
72
|
+
#metadata;
|
|
59
73
|
#refreshing = null;
|
|
60
74
|
/** The last `DPoP-Nonce` each origin returned. */
|
|
61
75
|
#nonces = new Map();
|
|
62
|
-
constructor(
|
|
63
|
-
|
|
76
|
+
constructor(attesterUrl, opts = {}) {
|
|
77
|
+
if (typeof attesterUrl === "object" && attesterUrl !== null) {
|
|
78
|
+
opts = attesterUrl;
|
|
79
|
+
attesterUrl = undefined;
|
|
80
|
+
}
|
|
81
|
+
const url = attesterUrl ?? opts.attesterUrl;
|
|
82
|
+
this.#profile = opts.profile ?? new PingFederateProfile();
|
|
83
|
+
this.#metadata = opts.metadata ?? null;
|
|
64
84
|
this.#evidence = opts.evidence;
|
|
65
85
|
this.#authorizationDetails = opts.authorizationDetails;
|
|
66
86
|
this.#operator = opts.operator && Object.keys(opts.operator).length > 0 ? opts.operator : undefined;
|
|
@@ -71,7 +91,14 @@ export class ClientAttestation {
|
|
|
71
91
|
this.#popMethod = opts.popMethod ?? "attestation_pop_jwt";
|
|
72
92
|
this.#requireChallenge = opts.requireChallenge ?? true;
|
|
73
93
|
this.#allowInsecure = opts.allowInsecure ?? false;
|
|
74
|
-
this.#
|
|
94
|
+
this.#logger = opts.logger ?? NOOP_LOGGER;
|
|
95
|
+
if (url === undefined && (opts.metadata || opts.profile)) {
|
|
96
|
+
this.baseUrl = "";
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
this.baseUrl = normalizeBaseUrl(url);
|
|
100
|
+
this.#checkSecure(this.baseUrl, "attesterUrl");
|
|
101
|
+
}
|
|
75
102
|
}
|
|
76
103
|
// ── public API ──────────────────────────────────────────────────────────────────────────────────
|
|
77
104
|
/** Return a valid access token, minting a new one only when the cached one is near expiry. */
|
|
@@ -113,6 +140,7 @@ export class ClientAttestation {
|
|
|
113
140
|
* as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
|
|
114
141
|
* a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
|
|
115
142
|
* unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
|
|
143
|
+
* Redirects are returned without following them, even if the input or init requests redirect following.
|
|
116
144
|
*/
|
|
117
145
|
async fetch(input, init = {}) {
|
|
118
146
|
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
|
@@ -127,7 +155,7 @@ export class ClientAttestation {
|
|
|
127
155
|
dpopBound = DPOP_HEADER in auth;
|
|
128
156
|
for (const [name, value] of Object.entries(auth))
|
|
129
157
|
headers.set(name, value);
|
|
130
|
-
return fetch(nextInput(), { ...init, headers });
|
|
158
|
+
return fetch(nextInput(), { ...init, headers, redirect: "manual" });
|
|
131
159
|
};
|
|
132
160
|
const first = await send();
|
|
133
161
|
if (!dpopBound)
|
|
@@ -137,24 +165,52 @@ export class ClientAttestation {
|
|
|
137
165
|
this.#nonces.set(originOf(url), nonce);
|
|
138
166
|
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
|
|
139
167
|
return first;
|
|
168
|
+
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
|
|
140
169
|
await first.body?.cancel().catch(() => undefined);
|
|
141
170
|
return send();
|
|
142
171
|
}
|
|
143
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
|
|
174
|
+
* then cached. With static `metadata`, the fields that were given, in the document's snake_case.
|
|
175
|
+
*/
|
|
144
176
|
async discovery() {
|
|
145
|
-
|
|
146
|
-
|
|
177
|
+
return metadataDocument(await this.attesterMetadata());
|
|
178
|
+
}
|
|
179
|
+
/** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
|
|
180
|
+
async attesterMetadata() {
|
|
181
|
+
const host = this.baseUrl ? hostOf(this.baseUrl) : undefined;
|
|
182
|
+
if (this.#metadata === null) {
|
|
183
|
+
this.#metadata = await this.#profile.discover(this.#transport(), this.baseUrl || undefined);
|
|
184
|
+
emit(this.#logger, "debug", "discovery.fetched", { host });
|
|
147
185
|
}
|
|
148
|
-
|
|
186
|
+
else {
|
|
187
|
+
emit(this.#logger, "debug", "discovery.cached", { host });
|
|
188
|
+
}
|
|
189
|
+
return this.#metadata;
|
|
190
|
+
}
|
|
191
|
+
/** The transport handed to the profile: this client's https rule, timeout and logging. */
|
|
192
|
+
#transport() {
|
|
193
|
+
return {
|
|
194
|
+
requestJson: (step, method, url, body) => {
|
|
195
|
+
this.#checkSecure(url, STEP_LABELS[step] ?? `${step} URL`);
|
|
196
|
+
return this.#getJson(step, method, url, body);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
149
199
|
}
|
|
150
200
|
// ── the flow ────────────────────────────────────────────────────────────────────────────────────
|
|
151
201
|
/** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
|
|
152
202
|
async #current(opts) {
|
|
153
203
|
const state = this.#state;
|
|
154
204
|
if (!opts.forceRefresh && state && Date.now() < state.expiresAt) {
|
|
205
|
+
emit(this.#logger, "debug", "token.cache_hit", {
|
|
206
|
+
expires_in: Math.max(0, Math.round((state.expiresAt - Date.now()) / 1000)),
|
|
207
|
+
});
|
|
155
208
|
return state;
|
|
156
209
|
}
|
|
157
210
|
if (!this.#refreshing) {
|
|
211
|
+
emit(this.#logger, "debug", "token.refreshing", {
|
|
212
|
+
reason: opts.forceRefresh ? "forced" : state ? "expired" : "initial",
|
|
213
|
+
});
|
|
158
214
|
this.#refreshing = this.#refresh().finally(() => {
|
|
159
215
|
this.#refreshing = null;
|
|
160
216
|
});
|
|
@@ -174,33 +230,58 @@ export class ClientAttestation {
|
|
|
174
230
|
return state;
|
|
175
231
|
}
|
|
176
232
|
async #runFlow() {
|
|
177
|
-
const
|
|
178
|
-
const audience =
|
|
233
|
+
const meta = await this.attesterMetadata();
|
|
234
|
+
const audience = meta.evidenceAudience;
|
|
179
235
|
if (!audience) {
|
|
180
236
|
throw new ClientAttestationError("attester discovery document has no evidence_audience");
|
|
181
237
|
}
|
|
182
238
|
const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
|
|
183
239
|
const svid = await evidenceSource.fetch(audience);
|
|
240
|
+
emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
|
|
184
241
|
const instanceKey = await SigningKeyPair.generate(this.#algorithm);
|
|
185
|
-
const challenge = await this.#fetchChallenge(
|
|
186
|
-
const attestation = await this.#mint(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
242
|
+
const challenge = await this.#fetchChallenge(meta.challengeEndpoint);
|
|
243
|
+
const { attestation } = await this.#profile.mint(this.#transport(), meta, new MintRequest({
|
|
244
|
+
instanceKey,
|
|
245
|
+
evidence: svid,
|
|
246
|
+
challenge,
|
|
247
|
+
authorizationDetails: this.#authorizationDetails,
|
|
248
|
+
operator: this.#operator,
|
|
249
|
+
}));
|
|
250
|
+
// The profile resolves the token endpoint (PingFederate: the advertised one, else the standard path
|
|
251
|
+
// on the attester host).
|
|
252
|
+
const tokenEndpoint = meta.tokenEndpoint;
|
|
253
|
+
if (!tokenEndpoint)
|
|
254
|
+
throw new ClientAttestationError("attester metadata has no token_endpoint");
|
|
190
255
|
this.#checkSecure(tokenEndpoint, "token_endpoint");
|
|
191
256
|
const clientId = jwtSub(attestation);
|
|
257
|
+
emit(this.#logger, "debug", "attestation.minted", {
|
|
258
|
+
host: meta.attestationEndpoint ? hostOf(meta.attestationEndpoint) : undefined,
|
|
259
|
+
client_id: clientId,
|
|
260
|
+
alg: this.#algorithm,
|
|
261
|
+
});
|
|
192
262
|
// The PoP audience must be PF's configured issuer, which the attester advertises as pop_audience.
|
|
193
263
|
// Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
|
|
194
264
|
// proxy in between) — so prefer the advertised value.
|
|
195
|
-
const popAudience =
|
|
265
|
+
const popAudience = meta.popAudience || tokenEndpoint;
|
|
196
266
|
// See `challengeAtToken`: some attesters consume the challenge the moment the mint's instance
|
|
197
267
|
// proof uses it, and reject a token request that presents the same value a second time. Against
|
|
198
268
|
// one of those, only the mint gets the challenge.
|
|
199
269
|
const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
|
|
200
270
|
const credential = new ClientAttestationCredential(attestation, instanceKey);
|
|
271
|
+
const obtained = (response) => {
|
|
272
|
+
const expiresIn = Number(response.expires_in);
|
|
273
|
+
emit(this.#logger, "info", "token.obtained", {
|
|
274
|
+
host: hostOf(tokenEndpoint),
|
|
275
|
+
client_id: clientId,
|
|
276
|
+
token_type: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
277
|
+
expires_in: response.expires_in != null && Number.isFinite(expiresIn) ? expiresIn : undefined,
|
|
278
|
+
pop_method: this.#popMethod,
|
|
279
|
+
});
|
|
280
|
+
return { response, key: instanceKey };
|
|
281
|
+
};
|
|
201
282
|
if (this.#popMethod !== "dpop_combined") {
|
|
202
283
|
const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
|
|
203
|
-
return
|
|
284
|
+
return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
|
|
204
285
|
}
|
|
205
286
|
// DPoP combined: the attester challenge rides as the DPoP nonce. If the AS demands its own nonce
|
|
206
287
|
// (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
|
|
@@ -209,12 +290,12 @@ export class ClientAttestation {
|
|
|
209
290
|
if (serverNonce)
|
|
210
291
|
this.#nonces.set(originOf(tokenEndpoint), serverNonce);
|
|
211
292
|
if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
|
|
293
|
+
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
|
|
212
294
|
res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
|
|
213
295
|
}
|
|
214
|
-
return
|
|
296
|
+
return obtained(this.#tokenResponse(tokenEndpoint, res));
|
|
215
297
|
}
|
|
216
|
-
async #fetchChallenge(
|
|
217
|
-
const endpoint = doc.challenge_endpoint;
|
|
298
|
+
async #fetchChallenge(endpoint) {
|
|
218
299
|
if (!endpoint)
|
|
219
300
|
return undefined;
|
|
220
301
|
this.#checkSecure(endpoint, "challenge_endpoint");
|
|
@@ -222,13 +303,15 @@ export class ClientAttestation {
|
|
|
222
303
|
// closed unless the caller opted out with requireChallenge: false.
|
|
223
304
|
let body;
|
|
224
305
|
try {
|
|
225
|
-
body = await this.#getJson("POST", endpoint);
|
|
306
|
+
body = await this.#getJson("challenge", "POST", endpoint);
|
|
226
307
|
}
|
|
227
308
|
catch (err) {
|
|
228
309
|
if (!(err instanceof ClientAttestationError))
|
|
229
310
|
throw err;
|
|
230
|
-
if (!this.#requireChallenge)
|
|
311
|
+
if (!this.#requireChallenge) {
|
|
312
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
231
313
|
return undefined;
|
|
314
|
+
}
|
|
232
315
|
throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
|
|
233
316
|
status: err.status,
|
|
234
317
|
body: err.body,
|
|
@@ -236,46 +319,29 @@ export class ClientAttestation {
|
|
|
236
319
|
});
|
|
237
320
|
}
|
|
238
321
|
const challenge = body.attestation_challenge;
|
|
239
|
-
if (typeof challenge === "string" && challenge)
|
|
322
|
+
if (typeof challenge === "string" && challenge) {
|
|
323
|
+
emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
|
|
240
324
|
return challenge;
|
|
241
|
-
|
|
325
|
+
}
|
|
326
|
+
if (!this.#requireChallenge) {
|
|
327
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
242
328
|
return undefined;
|
|
329
|
+
}
|
|
243
330
|
throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
|
|
244
331
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
332
|
+
/** The parsed token response; a failure is logged as `http.failed` before it is thrown. */
|
|
333
|
+
#tokenResponse(endpoint, res) {
|
|
334
|
+
try {
|
|
335
|
+
return tokenResponse(res);
|
|
248
336
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
});
|
|
337
|
+
catch (err) {
|
|
338
|
+
const reason = err instanceof TransportError ? failureReason(err) : res.ok ? "malformed" : "status";
|
|
339
|
+
this.#failed("token", "POST", endpoint, reason, res.status);
|
|
340
|
+
throw err;
|
|
274
341
|
}
|
|
275
|
-
return response.attestation;
|
|
276
342
|
}
|
|
277
|
-
|
|
278
|
-
|
|
343
|
+
#failed(step, method, url, reason, status) {
|
|
344
|
+
emit(this.#logger, "warn", "http.failed", { step, method, ...urlParts(url), status, reason });
|
|
279
345
|
}
|
|
280
346
|
#postToken(endpoint, authHeaders) {
|
|
281
347
|
const params = new URLSearchParams({ grant_type: "client_credentials" });
|
|
@@ -284,7 +350,7 @@ export class ClientAttestation {
|
|
|
284
350
|
if (this.#operator)
|
|
285
351
|
params.set("operator", canonicalJson(this.#operator));
|
|
286
352
|
const form = params.toString();
|
|
287
|
-
return this.#send(endpoint, {
|
|
353
|
+
return this.#send("token", endpoint, {
|
|
288
354
|
method: "POST",
|
|
289
355
|
headers: {
|
|
290
356
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -309,18 +375,35 @@ export class ClientAttestation {
|
|
|
309
375
|
return;
|
|
310
376
|
throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
|
|
311
377
|
}
|
|
312
|
-
async #getJson(method, url, jsonBody) {
|
|
378
|
+
async #getJson(step, method, url, jsonBody) {
|
|
313
379
|
const headers = { Accept: "application/json" };
|
|
314
380
|
let body;
|
|
315
381
|
if (jsonBody !== undefined) {
|
|
316
382
|
headers["Content-Type"] = "application/json";
|
|
317
383
|
body = JSON.stringify(jsonBody);
|
|
318
384
|
}
|
|
319
|
-
|
|
385
|
+
const res = await this.#send(step, url, { method, headers, body });
|
|
386
|
+
try {
|
|
387
|
+
return jsonOk(res, { allowEmpty: true });
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
this.#failed(step, method, url, failureReason(err), res.status);
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
320
393
|
}
|
|
321
|
-
/**
|
|
322
|
-
|
|
323
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Every call the flow makes goes through the shared transport, with this client's timeout. A call that
|
|
396
|
+
* gets no usable response is logged as `http.failed`.
|
|
397
|
+
*/
|
|
398
|
+
async #send(step, url, opts) {
|
|
399
|
+
try {
|
|
400
|
+
return await request(url, { ...opts, timeoutMs: this.#timeoutMs });
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
const status = err instanceof ClientAttestationError ? err.status : undefined;
|
|
404
|
+
this.#failed(step, opts.method ?? "GET", url, failureReason(err), status);
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
324
407
|
}
|
|
325
408
|
}
|
|
326
409
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
@@ -385,6 +468,8 @@ async function isNonceChallenge(res) {
|
|
|
385
468
|
/**
|
|
386
469
|
* Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
|
|
387
470
|
* bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
|
|
471
|
+
*
|
|
472
|
+
* @internal
|
|
388
473
|
*/
|
|
389
474
|
export function canonicalJson(value) {
|
|
390
475
|
return JSON.stringify(sortKeys(value)).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
@@ -398,21 +483,23 @@ function sortKeys(value) {
|
|
|
398
483
|
}
|
|
399
484
|
return value;
|
|
400
485
|
}
|
|
401
|
-
|
|
402
|
-
|
|
486
|
+
/** @internal */
|
|
487
|
+
export function normalizeBaseUrl(attesterUrl) {
|
|
488
|
+
let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");
|
|
403
489
|
if (!host) {
|
|
404
|
-
throw new Error("
|
|
490
|
+
throw new Error("attesterUrl is required");
|
|
405
491
|
}
|
|
406
492
|
if (!host.includes("://")) {
|
|
407
493
|
host = "https://" + host;
|
|
408
494
|
}
|
|
409
495
|
return host;
|
|
410
496
|
}
|
|
497
|
+
/** @internal */
|
|
411
498
|
export function jwtSub(compactJwt) {
|
|
412
499
|
const payload = compactJwt.split(".")[1] ?? "";
|
|
413
500
|
let claims;
|
|
414
501
|
try {
|
|
415
|
-
claims = JSON.parse(
|
|
502
|
+
claims = JSON.parse(utf8Decode(base64url.decode(payload)));
|
|
416
503
|
}
|
|
417
504
|
catch {
|
|
418
505
|
throw new ClientAttestationError("minted attestation is not a well-formed JWT");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Validator configuration.
|
|
3
3
|
*/
|
|
4
4
|
import type { JSONWebKeySet } from "jose";
|
|
5
|
-
/** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. */
|
|
5
|
+
/** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. Frozen, so it can't be widened. */
|
|
6
6
|
export declare const DEFAULT_ALGORITHMS: readonly string[];
|
|
7
7
|
/** RFC 9068 section 2.1: a JWT access token's `typ` is `at+jwt` (the `application/` prefix is optional). */
|
|
8
8
|
export declare const DEFAULT_TOKEN_TYPES: readonly string[];
|
|
@@ -27,6 +27,10 @@ export interface IntrospectionConfig {
|
|
|
27
27
|
* else. `acceptedTokenTypes` is checked against the JWT `typ` header when the token carries one, so an ID
|
|
28
28
|
* token can't stand in for an access token. Pass `null` to turn that check off for an issuer that stamps
|
|
29
29
|
* something other than `at+jwt`.
|
|
30
|
+
*
|
|
31
|
+
* An explicitly empty `acceptedAlgorithms` or `acceptedTokenTypes` accepts nothing, and `leewaySeconds: 0`
|
|
32
|
+
* allows no clock skew: only an omitted field takes the default. `none` and the `HS*` algorithms are
|
|
33
|
+
* refused even when listed (see validation/SEMANTICS.md).
|
|
30
34
|
*/
|
|
31
35
|
export interface ValidatorConfig {
|
|
32
36
|
issuer: string;
|
|
@@ -55,10 +59,3 @@ export interface ResolvedConfig {
|
|
|
55
59
|
/** Normalised with {@link normaliseType}; `null` when the check is off. */
|
|
56
60
|
acceptedTokenTypes: string[] | null;
|
|
57
61
|
}
|
|
58
|
-
/**
|
|
59
|
-
* Compare JOSE `typ` values the way RFC 7515 section 4.1.9 says to: case-insensitively, with an optional
|
|
60
|
-
* `application/` prefix.
|
|
61
|
-
*/
|
|
62
|
-
export declare function normaliseType(value: string): string;
|
|
63
|
-
/** Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`). */
|
|
64
|
-
export declare function resolveConfig(config: ValidatorConfig): ResolvedConfig;
|
package/dist/validator/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. */
|
|
2
|
-
export const DEFAULT_ALGORITHMS = [
|
|
1
|
+
/** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. Frozen, so it can't be widened. */
|
|
2
|
+
export const DEFAULT_ALGORITHMS = Object.freeze([
|
|
3
3
|
"ES256",
|
|
4
4
|
"ES384",
|
|
5
5
|
"ES512",
|
|
@@ -9,18 +9,35 @@ export const DEFAULT_ALGORITHMS = [
|
|
|
9
9
|
"PS256",
|
|
10
10
|
"PS384",
|
|
11
11
|
"PS512",
|
|
12
|
-
];
|
|
12
|
+
]);
|
|
13
13
|
/** RFC 9068 section 2.1: a JWT access token's `typ` is `at+jwt` (the `application/` prefix is optional). */
|
|
14
|
-
export const DEFAULT_TOKEN_TYPES = ["at+jwt"];
|
|
14
|
+
export const DEFAULT_TOKEN_TYPES = Object.freeze(["at+jwt"]);
|
|
15
|
+
/**
|
|
16
|
+
* `none` (in any case) and the HMAC algorithms are refused whatever `acceptedAlgorithms` says: an unsigned
|
|
17
|
+
* token proves nothing, and an HMAC "signature" checked against an issuer's public key is forgeable by
|
|
18
|
+
* anyone who has that key.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export function isForbiddenAlgorithm(alg) {
|
|
23
|
+
const lowered = alg.toLowerCase();
|
|
24
|
+
return lowered === "none" || lowered.startsWith("hs");
|
|
25
|
+
}
|
|
15
26
|
/**
|
|
16
27
|
* Compare JOSE `typ` values the way RFC 7515 section 4.1.9 says to: case-insensitively, with an optional
|
|
17
28
|
* `application/` prefix.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
18
31
|
*/
|
|
19
32
|
export function normaliseType(value) {
|
|
20
33
|
const lower = value.toLowerCase();
|
|
21
34
|
return lower.startsWith("application/") ? lower.slice("application/".length) : lower;
|
|
22
35
|
}
|
|
23
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`).
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
24
41
|
export function resolveConfig(config) {
|
|
25
42
|
if (!config.issuer) {
|
|
26
43
|
throw new Error("issuer is required");
|