@idpartners/client-attestation-sdk 0.2.0 → 1.0.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/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 +42 -16
- package/dist/token-source.js +160 -74
- 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 +0 -5
- package/dist/validator/resource.js +2 -0
- 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;
|
|
@@ -108,13 +136,11 @@ export declare class ClientAttestation {
|
|
|
108
136
|
* unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
|
|
109
137
|
*/
|
|
110
138
|
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
111
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
|
|
141
|
+
* then cached. With static `metadata`, the fields that were given, in the document's snake_case.
|
|
142
|
+
*/
|
|
112
143
|
discovery(): Promise<AttesterDiscovery>;
|
|
144
|
+
/** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
|
|
145
|
+
attesterMetadata(): Promise<AttesterMetadata>;
|
|
113
146
|
}
|
|
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. */
|
|
@@ -137,24 +164,52 @@ export class ClientAttestation {
|
|
|
137
164
|
this.#nonces.set(originOf(url), nonce);
|
|
138
165
|
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
|
|
139
166
|
return first;
|
|
167
|
+
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
|
|
140
168
|
await first.body?.cancel().catch(() => undefined);
|
|
141
169
|
return send();
|
|
142
170
|
}
|
|
143
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
|
|
173
|
+
* then cached. With static `metadata`, the fields that were given, in the document's snake_case.
|
|
174
|
+
*/
|
|
144
175
|
async discovery() {
|
|
145
|
-
|
|
146
|
-
|
|
176
|
+
return metadataDocument(await this.attesterMetadata());
|
|
177
|
+
}
|
|
178
|
+
/** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
|
|
179
|
+
async attesterMetadata() {
|
|
180
|
+
const host = this.baseUrl ? hostOf(this.baseUrl) : undefined;
|
|
181
|
+
if (this.#metadata === null) {
|
|
182
|
+
this.#metadata = await this.#profile.discover(this.#transport(), this.baseUrl || undefined);
|
|
183
|
+
emit(this.#logger, "debug", "discovery.fetched", { host });
|
|
147
184
|
}
|
|
148
|
-
|
|
185
|
+
else {
|
|
186
|
+
emit(this.#logger, "debug", "discovery.cached", { host });
|
|
187
|
+
}
|
|
188
|
+
return this.#metadata;
|
|
189
|
+
}
|
|
190
|
+
/** The transport handed to the profile: this client's https rule, timeout and logging. */
|
|
191
|
+
#transport() {
|
|
192
|
+
return {
|
|
193
|
+
requestJson: (step, method, url, body) => {
|
|
194
|
+
this.#checkSecure(url, STEP_LABELS[step] ?? `${step} URL`);
|
|
195
|
+
return this.#getJson(step, method, url, body);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
149
198
|
}
|
|
150
199
|
// ── the flow ────────────────────────────────────────────────────────────────────────────────────
|
|
151
200
|
/** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
|
|
152
201
|
async #current(opts) {
|
|
153
202
|
const state = this.#state;
|
|
154
203
|
if (!opts.forceRefresh && state && Date.now() < state.expiresAt) {
|
|
204
|
+
emit(this.#logger, "debug", "token.cache_hit", {
|
|
205
|
+
expires_in: Math.max(0, Math.round((state.expiresAt - Date.now()) / 1000)),
|
|
206
|
+
});
|
|
155
207
|
return state;
|
|
156
208
|
}
|
|
157
209
|
if (!this.#refreshing) {
|
|
210
|
+
emit(this.#logger, "debug", "token.refreshing", {
|
|
211
|
+
reason: opts.forceRefresh ? "forced" : state ? "expired" : "initial",
|
|
212
|
+
});
|
|
158
213
|
this.#refreshing = this.#refresh().finally(() => {
|
|
159
214
|
this.#refreshing = null;
|
|
160
215
|
});
|
|
@@ -174,33 +229,58 @@ export class ClientAttestation {
|
|
|
174
229
|
return state;
|
|
175
230
|
}
|
|
176
231
|
async #runFlow() {
|
|
177
|
-
const
|
|
178
|
-
const audience =
|
|
232
|
+
const meta = await this.attesterMetadata();
|
|
233
|
+
const audience = meta.evidenceAudience;
|
|
179
234
|
if (!audience) {
|
|
180
235
|
throw new ClientAttestationError("attester discovery document has no evidence_audience");
|
|
181
236
|
}
|
|
182
237
|
const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
|
|
183
238
|
const svid = await evidenceSource.fetch(audience);
|
|
239
|
+
emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
|
|
184
240
|
const instanceKey = await SigningKeyPair.generate(this.#algorithm);
|
|
185
|
-
const challenge = await this.#fetchChallenge(
|
|
186
|
-
const attestation = await this.#mint(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
241
|
+
const challenge = await this.#fetchChallenge(meta.challengeEndpoint);
|
|
242
|
+
const { attestation } = await this.#profile.mint(this.#transport(), meta, new MintRequest({
|
|
243
|
+
instanceKey,
|
|
244
|
+
evidence: svid,
|
|
245
|
+
challenge,
|
|
246
|
+
authorizationDetails: this.#authorizationDetails,
|
|
247
|
+
operator: this.#operator,
|
|
248
|
+
}));
|
|
249
|
+
// The profile resolves the token endpoint (PingFederate: the advertised one, else the standard path
|
|
250
|
+
// on the attester host).
|
|
251
|
+
const tokenEndpoint = meta.tokenEndpoint;
|
|
252
|
+
if (!tokenEndpoint)
|
|
253
|
+
throw new ClientAttestationError("attester metadata has no token_endpoint");
|
|
190
254
|
this.#checkSecure(tokenEndpoint, "token_endpoint");
|
|
191
255
|
const clientId = jwtSub(attestation);
|
|
256
|
+
emit(this.#logger, "debug", "attestation.minted", {
|
|
257
|
+
host: meta.attestationEndpoint ? hostOf(meta.attestationEndpoint) : undefined,
|
|
258
|
+
client_id: clientId,
|
|
259
|
+
alg: this.#algorithm,
|
|
260
|
+
});
|
|
192
261
|
// The PoP audience must be PF's configured issuer, which the attester advertises as pop_audience.
|
|
193
262
|
// Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
|
|
194
263
|
// proxy in between) — so prefer the advertised value.
|
|
195
|
-
const popAudience =
|
|
264
|
+
const popAudience = meta.popAudience || tokenEndpoint;
|
|
196
265
|
// See `challengeAtToken`: some attesters consume the challenge the moment the mint's instance
|
|
197
266
|
// proof uses it, and reject a token request that presents the same value a second time. Against
|
|
198
267
|
// one of those, only the mint gets the challenge.
|
|
199
268
|
const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
|
|
200
269
|
const credential = new ClientAttestationCredential(attestation, instanceKey);
|
|
270
|
+
const obtained = (response) => {
|
|
271
|
+
const expiresIn = Number(response.expires_in);
|
|
272
|
+
emit(this.#logger, "info", "token.obtained", {
|
|
273
|
+
host: hostOf(tokenEndpoint),
|
|
274
|
+
client_id: clientId,
|
|
275
|
+
token_type: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
276
|
+
expires_in: response.expires_in != null && Number.isFinite(expiresIn) ? expiresIn : undefined,
|
|
277
|
+
pop_method: this.#popMethod,
|
|
278
|
+
});
|
|
279
|
+
return { response, key: instanceKey };
|
|
280
|
+
};
|
|
201
281
|
if (this.#popMethod !== "dpop_combined") {
|
|
202
282
|
const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
|
|
203
|
-
return
|
|
283
|
+
return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
|
|
204
284
|
}
|
|
205
285
|
// DPoP combined: the attester challenge rides as the DPoP nonce. If the AS demands its own nonce
|
|
206
286
|
// (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
|
|
@@ -209,12 +289,12 @@ export class ClientAttestation {
|
|
|
209
289
|
if (serverNonce)
|
|
210
290
|
this.#nonces.set(originOf(tokenEndpoint), serverNonce);
|
|
211
291
|
if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
|
|
292
|
+
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
|
|
212
293
|
res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
|
|
213
294
|
}
|
|
214
|
-
return
|
|
295
|
+
return obtained(this.#tokenResponse(tokenEndpoint, res));
|
|
215
296
|
}
|
|
216
|
-
async #fetchChallenge(
|
|
217
|
-
const endpoint = doc.challenge_endpoint;
|
|
297
|
+
async #fetchChallenge(endpoint) {
|
|
218
298
|
if (!endpoint)
|
|
219
299
|
return undefined;
|
|
220
300
|
this.#checkSecure(endpoint, "challenge_endpoint");
|
|
@@ -222,13 +302,15 @@ export class ClientAttestation {
|
|
|
222
302
|
// closed unless the caller opted out with requireChallenge: false.
|
|
223
303
|
let body;
|
|
224
304
|
try {
|
|
225
|
-
body = await this.#getJson("POST", endpoint);
|
|
305
|
+
body = await this.#getJson("challenge", "POST", endpoint);
|
|
226
306
|
}
|
|
227
307
|
catch (err) {
|
|
228
308
|
if (!(err instanceof ClientAttestationError))
|
|
229
309
|
throw err;
|
|
230
|
-
if (!this.#requireChallenge)
|
|
310
|
+
if (!this.#requireChallenge) {
|
|
311
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
231
312
|
return undefined;
|
|
313
|
+
}
|
|
232
314
|
throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
|
|
233
315
|
status: err.status,
|
|
234
316
|
body: err.body,
|
|
@@ -236,46 +318,29 @@ export class ClientAttestation {
|
|
|
236
318
|
});
|
|
237
319
|
}
|
|
238
320
|
const challenge = body.attestation_challenge;
|
|
239
|
-
if (typeof challenge === "string" && challenge)
|
|
321
|
+
if (typeof challenge === "string" && challenge) {
|
|
322
|
+
emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
|
|
240
323
|
return challenge;
|
|
241
|
-
|
|
324
|
+
}
|
|
325
|
+
if (!this.#requireChallenge) {
|
|
326
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
242
327
|
return undefined;
|
|
328
|
+
}
|
|
243
329
|
throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
|
|
244
330
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
331
|
+
/** The parsed token response; a failure is logged as `http.failed` before it is thrown. */
|
|
332
|
+
#tokenResponse(endpoint, res) {
|
|
333
|
+
try {
|
|
334
|
+
return tokenResponse(res);
|
|
248
335
|
}
|
|
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
|
-
});
|
|
336
|
+
catch (err) {
|
|
337
|
+
const reason = err instanceof TransportError ? failureReason(err) : res.ok ? "malformed" : "status";
|
|
338
|
+
this.#failed("token", "POST", endpoint, reason, res.status);
|
|
339
|
+
throw err;
|
|
274
340
|
}
|
|
275
|
-
return response.attestation;
|
|
276
341
|
}
|
|
277
|
-
|
|
278
|
-
|
|
342
|
+
#failed(step, method, url, reason, status) {
|
|
343
|
+
emit(this.#logger, "warn", "http.failed", { step, method, ...urlParts(url), status, reason });
|
|
279
344
|
}
|
|
280
345
|
#postToken(endpoint, authHeaders) {
|
|
281
346
|
const params = new URLSearchParams({ grant_type: "client_credentials" });
|
|
@@ -284,7 +349,7 @@ export class ClientAttestation {
|
|
|
284
349
|
if (this.#operator)
|
|
285
350
|
params.set("operator", canonicalJson(this.#operator));
|
|
286
351
|
const form = params.toString();
|
|
287
|
-
return this.#send(endpoint, {
|
|
352
|
+
return this.#send("token", endpoint, {
|
|
288
353
|
method: "POST",
|
|
289
354
|
headers: {
|
|
290
355
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
@@ -309,18 +374,35 @@ export class ClientAttestation {
|
|
|
309
374
|
return;
|
|
310
375
|
throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
|
|
311
376
|
}
|
|
312
|
-
async #getJson(method, url, jsonBody) {
|
|
377
|
+
async #getJson(step, method, url, jsonBody) {
|
|
313
378
|
const headers = { Accept: "application/json" };
|
|
314
379
|
let body;
|
|
315
380
|
if (jsonBody !== undefined) {
|
|
316
381
|
headers["Content-Type"] = "application/json";
|
|
317
382
|
body = JSON.stringify(jsonBody);
|
|
318
383
|
}
|
|
319
|
-
|
|
384
|
+
const res = await this.#send(step, url, { method, headers, body });
|
|
385
|
+
try {
|
|
386
|
+
return jsonOk(res, { allowEmpty: true });
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
this.#failed(step, method, url, failureReason(err), res.status);
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
320
392
|
}
|
|
321
|
-
/**
|
|
322
|
-
|
|
323
|
-
|
|
393
|
+
/**
|
|
394
|
+
* Every call the flow makes goes through the shared transport, with this client's timeout. A call that
|
|
395
|
+
* gets no usable response is logged as `http.failed`.
|
|
396
|
+
*/
|
|
397
|
+
async #send(step, url, opts) {
|
|
398
|
+
try {
|
|
399
|
+
return await request(url, { ...opts, timeoutMs: this.#timeoutMs });
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
const status = err instanceof ClientAttestationError ? err.status : undefined;
|
|
403
|
+
this.#failed(step, opts.method ?? "GET", url, failureReason(err), status);
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
324
406
|
}
|
|
325
407
|
}
|
|
326
408
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
@@ -385,6 +467,8 @@ async function isNonceChallenge(res) {
|
|
|
385
467
|
/**
|
|
386
468
|
* Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
|
|
387
469
|
* bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
|
|
470
|
+
*
|
|
471
|
+
* @internal
|
|
388
472
|
*/
|
|
389
473
|
export function canonicalJson(value) {
|
|
390
474
|
return JSON.stringify(sortKeys(value)).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
@@ -398,21 +482,23 @@ function sortKeys(value) {
|
|
|
398
482
|
}
|
|
399
483
|
return value;
|
|
400
484
|
}
|
|
401
|
-
|
|
402
|
-
|
|
485
|
+
/** @internal */
|
|
486
|
+
export function normalizeBaseUrl(attesterUrl) {
|
|
487
|
+
let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");
|
|
403
488
|
if (!host) {
|
|
404
|
-
throw new Error("
|
|
489
|
+
throw new Error("attesterUrl is required");
|
|
405
490
|
}
|
|
406
491
|
if (!host.includes("://")) {
|
|
407
492
|
host = "https://" + host;
|
|
408
493
|
}
|
|
409
494
|
return host;
|
|
410
495
|
}
|
|
496
|
+
/** @internal */
|
|
411
497
|
export function jwtSub(compactJwt) {
|
|
412
498
|
const payload = compactJwt.split(".")[1] ?? "";
|
|
413
499
|
let claims;
|
|
414
500
|
try {
|
|
415
|
-
claims = JSON.parse(
|
|
501
|
+
claims = JSON.parse(utf8Decode(base64url.decode(payload)));
|
|
416
502
|
}
|
|
417
503
|
catch {
|
|
418
504
|
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");
|