@idpartners/client-attestation-sdk 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +142 -2
- package/dist/attester.d.ts +93 -0
- package/dist/attester.js +139 -0
- package/dist/builders.d.ts +92 -0
- package/dist/builders.js +284 -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 +55 -0
- package/dist/evidence.js +168 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +183 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +20 -0
- package/dist/issuance.d.ts +61 -0
- package/dist/issuance.js +117 -0
- 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 +32 -0
- package/dist/signer.js +121 -0
- package/dist/signing-key-pair.d.ts +44 -0
- package/dist/signing-key-pair.js +130 -0
- package/dist/svid-source.d.ts +41 -0
- package/dist/svid-source.js +77 -0
- package/dist/token-source.d.ts +146 -0
- package/dist/token-source.js +510 -0
- package/dist/validator/config.d.ts +61 -0
- package/dist/validator/config.js +61 -0
- package/dist/validator/errors.d.ts +20 -0
- package/dist/validator/errors.js +18 -0
- package/dist/validator/index.d.ts +19 -0
- package/dist/validator/index.js +12 -0
- package/dist/validator/jwks.d.ts +2 -0
- package/dist/validator/jwks.js +150 -0
- package/dist/validator/resource.d.ts +37 -0
- package/dist/validator/resource.js +98 -0
- package/dist/validator/result.d.ts +17 -0
- package/dist/validator/result.js +28 -0
- package/dist/validator/strict.d.ts +1 -0
- package/dist/validator/strict.js +202 -0
- package/dist/validator/validator.d.ts +41 -0
- package/dist/validator/validator.js +240 -0
- package/package.json +56 -4
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One call to get an access token with client attestation.
|
|
3
|
+
*
|
|
4
|
+
* Point it at an attester (PingFederate by default) 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 { base64url } from "jose";
|
|
23
|
+
import { DpopProofBuilder } from "./builders.js";
|
|
24
|
+
import { ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
|
|
25
|
+
import { ClientAttestationError, TransportError } from "./errors.js";
|
|
26
|
+
import { autoDetectEvidence } from "./evidence.js";
|
|
27
|
+
import { MintRequest, PingFederateProfile, metadataDocument, } from "./attester.js";
|
|
28
|
+
import { jsonOk, request } from "./http.js";
|
|
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";
|
|
32
|
+
export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
33
|
+
export const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
34
|
+
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
35
|
+
export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
36
|
+
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
37
|
+
const USE_DPOP_NONCE = "use_dpop_nonce";
|
|
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
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Gets and caches an access token using client attestation.
|
|
47
|
+
*
|
|
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.
|
|
54
|
+
*/
|
|
55
|
+
export class ClientAttestation {
|
|
56
|
+
/** The normalised attester URL, or `""` when the client was built from static metadata alone. */
|
|
57
|
+
baseUrl;
|
|
58
|
+
#profile;
|
|
59
|
+
#evidence;
|
|
60
|
+
#authorizationDetails;
|
|
61
|
+
#operator;
|
|
62
|
+
#challengeAtToken;
|
|
63
|
+
#algorithm;
|
|
64
|
+
#skew;
|
|
65
|
+
#timeoutMs;
|
|
66
|
+
#popMethod;
|
|
67
|
+
#requireChallenge;
|
|
68
|
+
#allowInsecure;
|
|
69
|
+
#logger;
|
|
70
|
+
/** The token, its type and its bound key: always replaced together, never one without the others. */
|
|
71
|
+
#state = null;
|
|
72
|
+
#metadata;
|
|
73
|
+
#refreshing = null;
|
|
74
|
+
/** The last `DPoP-Nonce` each origin returned. */
|
|
75
|
+
#nonces = new Map();
|
|
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;
|
|
84
|
+
this.#evidence = opts.evidence;
|
|
85
|
+
this.#authorizationDetails = opts.authorizationDetails;
|
|
86
|
+
this.#operator = opts.operator && Object.keys(opts.operator).length > 0 ? opts.operator : undefined;
|
|
87
|
+
this.#challengeAtToken = opts.challengeAtToken ?? true;
|
|
88
|
+
this.#algorithm = opts.algorithm ?? "ES256";
|
|
89
|
+
this.#skew = opts.expirySkewSeconds ?? DEFAULT_EXPIRY_SKEW_SECONDS;
|
|
90
|
+
this.#timeoutMs = opts.timeoutMs ?? 20_000;
|
|
91
|
+
this.#popMethod = opts.popMethod ?? "attestation_pop_jwt";
|
|
92
|
+
this.#requireChallenge = opts.requireChallenge ?? true;
|
|
93
|
+
this.#allowInsecure = opts.allowInsecure ?? false;
|
|
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
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ── public API ──────────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
/** Return a valid access token, minting a new one only when the cached one is near expiry. */
|
|
105
|
+
async token(opts = {}) {
|
|
106
|
+
return (await this.#current(opts)).accessToken;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `{ Authorization: "<scheme> <token>" }`. For a Bearer token that is all a resource needs. For a
|
|
110
|
+
* DPoP-bound token (`token_type` DPoP) the scheme is `DPoP` and every request ALSO needs a fresh
|
|
111
|
+
* DPoP proof — use {@link requestHeaders} (or {@link fetch}) instead, which adds it.
|
|
112
|
+
*/
|
|
113
|
+
async authorizationHeader(opts = {}) {
|
|
114
|
+
const state = await this.#current(opts);
|
|
115
|
+
return { Authorization: `${scheme(state)} ${state.accessToken}` };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The headers to send to a resource for one request, obtaining or refreshing the token as needed.
|
|
119
|
+
*
|
|
120
|
+
* - Bearer token: `{ Authorization: "Bearer <t>" }`
|
|
121
|
+
* - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
|
|
122
|
+
* token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
|
|
123
|
+
* url's origin returned, if any.
|
|
124
|
+
*/
|
|
125
|
+
async requestHeaders(method, url, opts = {}) {
|
|
126
|
+
const state = await this.#current(opts);
|
|
127
|
+
const headers = { Authorization: `${scheme(state)} ${state.accessToken}` };
|
|
128
|
+
if (isDpop(state)) {
|
|
129
|
+
headers[DPOP_HEADER] = await new DpopProofBuilder(state.key)
|
|
130
|
+
.method(method)
|
|
131
|
+
.uri(url)
|
|
132
|
+
.accessToken(state.accessToken)
|
|
133
|
+
.nonce(this.#nonces.get(originOf(url)) ?? null)
|
|
134
|
+
.build();
|
|
135
|
+
}
|
|
136
|
+
return headers;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A `fetch` wrapper that attaches the token (refreshing it as needed) to the request. Same signature
|
|
140
|
+
* as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
|
|
141
|
+
* a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
|
|
142
|
+
* unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
|
|
143
|
+
*/
|
|
144
|
+
async fetch(input, init = {}) {
|
|
145
|
+
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
|
146
|
+
const url = isRequest ? input.url : String(input);
|
|
147
|
+
const method = (init.method ?? (isRequest ? input.method : "GET")).toUpperCase();
|
|
148
|
+
// A Request's own body is cloned so the retry can send it again.
|
|
149
|
+
const nextInput = () => (isRequest ? input.clone() : input);
|
|
150
|
+
let dpopBound = false;
|
|
151
|
+
const send = async () => {
|
|
152
|
+
const headers = new Headers(init.headers ?? (isRequest ? input.headers : undefined));
|
|
153
|
+
const auth = await this.requestHeaders(method, url);
|
|
154
|
+
dpopBound = DPOP_HEADER in auth;
|
|
155
|
+
for (const [name, value] of Object.entries(auth))
|
|
156
|
+
headers.set(name, value);
|
|
157
|
+
return fetch(nextInput(), { ...init, headers });
|
|
158
|
+
};
|
|
159
|
+
const first = await send();
|
|
160
|
+
if (!dpopBound)
|
|
161
|
+
return first;
|
|
162
|
+
const nonce = first.headers.get(DPOP_NONCE_HEADER);
|
|
163
|
+
if (nonce)
|
|
164
|
+
this.#nonces.set(originOf(url), nonce);
|
|
165
|
+
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
|
|
166
|
+
return first;
|
|
167
|
+
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
|
|
168
|
+
await first.body?.cancel().catch(() => undefined);
|
|
169
|
+
return send();
|
|
170
|
+
}
|
|
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
|
+
*/
|
|
175
|
+
async discovery() {
|
|
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 });
|
|
184
|
+
}
|
|
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
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// ── the flow ────────────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
/** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
|
|
201
|
+
async #current(opts) {
|
|
202
|
+
const state = this.#state;
|
|
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
|
+
});
|
|
207
|
+
return state;
|
|
208
|
+
}
|
|
209
|
+
if (!this.#refreshing) {
|
|
210
|
+
emit(this.#logger, "debug", "token.refreshing", {
|
|
211
|
+
reason: opts.forceRefresh ? "forced" : state ? "expired" : "initial",
|
|
212
|
+
});
|
|
213
|
+
this.#refreshing = this.#refresh().finally(() => {
|
|
214
|
+
this.#refreshing = null;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return this.#refreshing;
|
|
218
|
+
}
|
|
219
|
+
async #refresh() {
|
|
220
|
+
const { response, key } = await this.#runFlow();
|
|
221
|
+
const expiresIn = response.expires_in;
|
|
222
|
+
const state = {
|
|
223
|
+
accessToken: response.access_token,
|
|
224
|
+
tokenType: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
225
|
+
key,
|
|
226
|
+
expiresAt: expiresIn ? Date.now() + (Number(expiresIn) - this.#skew) * 1000 : 0,
|
|
227
|
+
};
|
|
228
|
+
this.#state = state;
|
|
229
|
+
return state;
|
|
230
|
+
}
|
|
231
|
+
async #runFlow() {
|
|
232
|
+
const meta = await this.attesterMetadata();
|
|
233
|
+
const audience = meta.evidenceAudience;
|
|
234
|
+
if (!audience) {
|
|
235
|
+
throw new ClientAttestationError("attester discovery document has no evidence_audience");
|
|
236
|
+
}
|
|
237
|
+
const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
|
|
238
|
+
const svid = await evidenceSource.fetch(audience);
|
|
239
|
+
emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
|
|
240
|
+
const instanceKey = await SigningKeyPair.generate(this.#algorithm);
|
|
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");
|
|
254
|
+
this.#checkSecure(tokenEndpoint, "token_endpoint");
|
|
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
|
+
});
|
|
261
|
+
// The PoP audience must be PF's configured issuer, which the attester advertises as pop_audience.
|
|
262
|
+
// Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
|
|
263
|
+
// proxy in between) — so prefer the advertised value.
|
|
264
|
+
const popAudience = meta.popAudience || tokenEndpoint;
|
|
265
|
+
// See `challengeAtToken`: some attesters consume the challenge the moment the mint's instance
|
|
266
|
+
// proof uses it, and reject a token request that presents the same value a second time. Against
|
|
267
|
+
// one of those, only the mint gets the challenge.
|
|
268
|
+
const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
|
|
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
|
+
};
|
|
281
|
+
if (this.#popMethod !== "dpop_combined") {
|
|
282
|
+
const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
|
|
283
|
+
return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
|
|
284
|
+
}
|
|
285
|
+
// DPoP combined: the attester challenge rides as the DPoP nonce. If the AS demands its own nonce
|
|
286
|
+
// (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
|
|
287
|
+
let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
|
|
288
|
+
const serverNonce = res.headers.get(DPOP_NONCE_HEADER);
|
|
289
|
+
if (serverNonce)
|
|
290
|
+
this.#nonces.set(originOf(tokenEndpoint), serverNonce);
|
|
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" });
|
|
293
|
+
res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
|
|
294
|
+
}
|
|
295
|
+
return obtained(this.#tokenResponse(tokenEndpoint, res));
|
|
296
|
+
}
|
|
297
|
+
async #fetchChallenge(endpoint) {
|
|
298
|
+
if (!endpoint)
|
|
299
|
+
return undefined;
|
|
300
|
+
this.#checkSecure(endpoint, "challenge_endpoint");
|
|
301
|
+
// The attester advertised a challenge, so minting without one weakens replay protection: fail
|
|
302
|
+
// closed unless the caller opted out with requireChallenge: false.
|
|
303
|
+
let body;
|
|
304
|
+
try {
|
|
305
|
+
body = await this.#getJson("challenge", "POST", endpoint);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
if (!(err instanceof ClientAttestationError))
|
|
309
|
+
throw err;
|
|
310
|
+
if (!this.#requireChallenge) {
|
|
311
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
|
|
315
|
+
status: err.status,
|
|
316
|
+
body: err.body,
|
|
317
|
+
cause: err,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const challenge = body.attestation_challenge;
|
|
321
|
+
if (typeof challenge === "string" && challenge) {
|
|
322
|
+
emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
|
|
323
|
+
return challenge;
|
|
324
|
+
}
|
|
325
|
+
if (!this.#requireChallenge) {
|
|
326
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
|
|
330
|
+
}
|
|
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);
|
|
335
|
+
}
|
|
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;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
#failed(step, method, url, reason, status) {
|
|
343
|
+
emit(this.#logger, "warn", "http.failed", { step, method, ...urlParts(url), status, reason });
|
|
344
|
+
}
|
|
345
|
+
#postToken(endpoint, authHeaders) {
|
|
346
|
+
const params = new URLSearchParams({ grant_type: "client_credentials" });
|
|
347
|
+
// Extension parameter, sent only when configured: the attestation is the authoritative carrier,
|
|
348
|
+
// this repeats the operator on the wire for an AS that maps it into may_act.
|
|
349
|
+
if (this.#operator)
|
|
350
|
+
params.set("operator", canonicalJson(this.#operator));
|
|
351
|
+
const form = params.toString();
|
|
352
|
+
return this.#send("token", endpoint, {
|
|
353
|
+
method: "POST",
|
|
354
|
+
headers: {
|
|
355
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
356
|
+
Accept: "application/json",
|
|
357
|
+
...authHeaders,
|
|
358
|
+
},
|
|
359
|
+
body: form,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/** Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. */
|
|
363
|
+
#checkSecure(url, what) {
|
|
364
|
+
if (this.#allowInsecure)
|
|
365
|
+
return;
|
|
366
|
+
let parsed;
|
|
367
|
+
try {
|
|
368
|
+
parsed = new URL(url);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
throw new ClientAttestationError(`${what} is not a valid URL: ${url}`);
|
|
372
|
+
}
|
|
373
|
+
if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
|
|
374
|
+
return;
|
|
375
|
+
throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
|
|
376
|
+
}
|
|
377
|
+
async #getJson(step, method, url, jsonBody) {
|
|
378
|
+
const headers = { Accept: "application/json" };
|
|
379
|
+
let body;
|
|
380
|
+
if (jsonBody !== undefined) {
|
|
381
|
+
headers["Content-Type"] = "application/json";
|
|
382
|
+
body = JSON.stringify(jsonBody);
|
|
383
|
+
}
|
|
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
|
+
}
|
|
392
|
+
}
|
|
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
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
409
|
+
function tokenResponse(res) {
|
|
410
|
+
if (!res.ok) {
|
|
411
|
+
throw new ClientAttestationError(`token endpoint returned HTTP ${res.status}`, {
|
|
412
|
+
status: res.status,
|
|
413
|
+
body: res.body,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const parsed = jsonOk(res, { allowEmpty: true });
|
|
417
|
+
if (!parsed.access_token) {
|
|
418
|
+
throw new ClientAttestationError("token endpoint returned no access_token", { body: res.body });
|
|
419
|
+
}
|
|
420
|
+
return parsed;
|
|
421
|
+
}
|
|
422
|
+
function isDpop(state) {
|
|
423
|
+
return state.tokenType.toLowerCase() === "dpop";
|
|
424
|
+
}
|
|
425
|
+
function scheme(state) {
|
|
426
|
+
return isDpop(state) ? "DPoP" : "Bearer";
|
|
427
|
+
}
|
|
428
|
+
/** `scheme://host[:port]` of a URL, the key DPoP nonces are remembered under. */
|
|
429
|
+
function originOf(url) {
|
|
430
|
+
try {
|
|
431
|
+
return new URL(url).origin;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return url;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** The `error` member of a JSON object body, or undefined. */
|
|
438
|
+
function jsonError(body) {
|
|
439
|
+
try {
|
|
440
|
+
return JSON.parse(body)?.error;
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Can this fetch body be sent a second time? One-shot streams cannot. */
|
|
447
|
+
function replayable(body) {
|
|
448
|
+
if (body == null)
|
|
449
|
+
return true;
|
|
450
|
+
return !(typeof ReadableStream !== "undefined" && body instanceof ReadableStream) &&
|
|
451
|
+
!(typeof body[Symbol.asyncIterator] === "function");
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* RFC 9449 section 9: a resource asks for a nonce with 401 + `WWW-Authenticate: DPoP error="use_dpop_nonce"`;
|
|
455
|
+
* section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`.
|
|
456
|
+
*/
|
|
457
|
+
async function isNonceChallenge(res) {
|
|
458
|
+
if (res.status === 401) {
|
|
459
|
+
const challenge = res.headers.get("WWW-Authenticate") ?? "";
|
|
460
|
+
return /(^|,)\s*DPoP\b/i.test(challenge) && /error\s*=\s*"?use_dpop_nonce"?/i.test(challenge);
|
|
461
|
+
}
|
|
462
|
+
if (res.status === 400) {
|
|
463
|
+
return jsonError(await res.clone().text()) === USE_DPOP_NONCE;
|
|
464
|
+
}
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
|
|
469
|
+
* bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
|
|
470
|
+
*
|
|
471
|
+
* @internal
|
|
472
|
+
*/
|
|
473
|
+
export function canonicalJson(value) {
|
|
474
|
+
return JSON.stringify(sortKeys(value)).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
475
|
+
}
|
|
476
|
+
function sortKeys(value) {
|
|
477
|
+
if (Array.isArray(value))
|
|
478
|
+
return value.map(sortKeys);
|
|
479
|
+
if (value !== null && typeof value === "object") {
|
|
480
|
+
const obj = value;
|
|
481
|
+
return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, sortKeys(obj[k])]));
|
|
482
|
+
}
|
|
483
|
+
return value;
|
|
484
|
+
}
|
|
485
|
+
/** @internal */
|
|
486
|
+
export function normalizeBaseUrl(attesterUrl) {
|
|
487
|
+
let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");
|
|
488
|
+
if (!host) {
|
|
489
|
+
throw new Error("attesterUrl is required");
|
|
490
|
+
}
|
|
491
|
+
if (!host.includes("://")) {
|
|
492
|
+
host = "https://" + host;
|
|
493
|
+
}
|
|
494
|
+
return host;
|
|
495
|
+
}
|
|
496
|
+
/** @internal */
|
|
497
|
+
export function jwtSub(compactJwt) {
|
|
498
|
+
const payload = compactJwt.split(".")[1] ?? "";
|
|
499
|
+
let claims;
|
|
500
|
+
try {
|
|
501
|
+
claims = JSON.parse(utf8Decode(base64url.decode(payload)));
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
throw new ClientAttestationError("minted attestation is not a well-formed JWT");
|
|
505
|
+
}
|
|
506
|
+
if (!claims?.sub) {
|
|
507
|
+
throw new ClientAttestationError("minted attestation has no sub (client id)");
|
|
508
|
+
}
|
|
509
|
+
return claims.sub;
|
|
510
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validator configuration.
|
|
3
|
+
*/
|
|
4
|
+
import type { JSONWebKeySet } from "jose";
|
|
5
|
+
/** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. Frozen, so it can't be widened. */
|
|
6
|
+
export declare const DEFAULT_ALGORITHMS: readonly string[];
|
|
7
|
+
/** RFC 9068 section 2.1: a JWT access token's `typ` is `at+jwt` (the `application/` prefix is optional). */
|
|
8
|
+
export declare const DEFAULT_TOKEN_TYPES: readonly string[];
|
|
9
|
+
/** How the resource server authenticates to the AS when introspecting. */
|
|
10
|
+
export type IntrospectionAuthMethod = "client_secret_basic" | "client_secret_post";
|
|
11
|
+
/**
|
|
12
|
+
* RFC 7662 introspection endpoint + client credentials (the resource server authenticating to the AS).
|
|
13
|
+
*/
|
|
14
|
+
export interface IntrospectionConfig {
|
|
15
|
+
endpoint: string;
|
|
16
|
+
clientId: string;
|
|
17
|
+
clientSecret: string;
|
|
18
|
+
/** Defaults to `"client_secret_basic"`. */
|
|
19
|
+
authMethod?: IntrospectionAuthMethod;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* What this resource server accepts: the trusted issuer, its own audience identifier(s), the signing
|
|
23
|
+
* keys (static `jwks` or a `jwksUri` to fetch), required scopes, accepted algorithms, clock leeway,
|
|
24
|
+
* and optional introspection.
|
|
25
|
+
*
|
|
26
|
+
* `issuer` and `audiences` are required: a validator that skips either accepts tokens minted for someone
|
|
27
|
+
* else. `acceptedTokenTypes` is checked against the JWT `typ` header when the token carries one, so an ID
|
|
28
|
+
* token can't stand in for an access token. Pass `null` to turn that check off for an issuer that stamps
|
|
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).
|
|
34
|
+
*/
|
|
35
|
+
export interface ValidatorConfig {
|
|
36
|
+
issuer: string;
|
|
37
|
+
audiences: string | string[];
|
|
38
|
+
jwks?: JSONWebKeySet;
|
|
39
|
+
jwksUri?: string;
|
|
40
|
+
requiredScopes?: string[];
|
|
41
|
+
/** Defaults to {@link DEFAULT_ALGORITHMS}. */
|
|
42
|
+
acceptedAlgorithms?: string[];
|
|
43
|
+
/** Clock skew allowed on `exp` / `nbf` / `iat`, in seconds. Defaults to 60. */
|
|
44
|
+
leewaySeconds?: number;
|
|
45
|
+
introspection?: IntrospectionConfig;
|
|
46
|
+
/** Defaults to {@link DEFAULT_TOKEN_TYPES}; `null` disables the `typ` check. */
|
|
47
|
+
acceptedTokenTypes?: string[] | null;
|
|
48
|
+
}
|
|
49
|
+
/** A {@link ValidatorConfig} with every optional field defaulted. */
|
|
50
|
+
export interface ResolvedConfig {
|
|
51
|
+
issuer: string;
|
|
52
|
+
audiences: string[];
|
|
53
|
+
jwks?: JSONWebKeySet;
|
|
54
|
+
jwksUri?: string;
|
|
55
|
+
requiredScopes: string[];
|
|
56
|
+
acceptedAlgorithms: string[];
|
|
57
|
+
leewaySeconds: number;
|
|
58
|
+
introspection?: IntrospectionConfig;
|
|
59
|
+
/** Normalised with {@link normaliseType}; `null` when the check is off. */
|
|
60
|
+
acceptedTokenTypes: string[] | null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
"ES256",
|
|
4
|
+
"ES384",
|
|
5
|
+
"ES512",
|
|
6
|
+
"RS256",
|
|
7
|
+
"RS384",
|
|
8
|
+
"RS512",
|
|
9
|
+
"PS256",
|
|
10
|
+
"PS384",
|
|
11
|
+
"PS512",
|
|
12
|
+
]);
|
|
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 = 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
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Compare JOSE `typ` values the way RFC 7515 section 4.1.9 says to: case-insensitively, with an optional
|
|
28
|
+
* `application/` prefix.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export function normaliseType(value) {
|
|
33
|
+
const lower = value.toLowerCase();
|
|
34
|
+
return lower.startsWith("application/") ? lower.slice("application/".length) : lower;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`).
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export function resolveConfig(config) {
|
|
42
|
+
if (!config.issuer) {
|
|
43
|
+
throw new Error("issuer is required");
|
|
44
|
+
}
|
|
45
|
+
const audiences = typeof config.audiences === "string" ? [config.audiences] : [...(config.audiences ?? [])];
|
|
46
|
+
if (audiences.length === 0) {
|
|
47
|
+
throw new Error("at least one audience is required");
|
|
48
|
+
}
|
|
49
|
+
const types = config.acceptedTokenTypes === undefined ? DEFAULT_TOKEN_TYPES : config.acceptedTokenTypes;
|
|
50
|
+
return {
|
|
51
|
+
issuer: config.issuer,
|
|
52
|
+
audiences,
|
|
53
|
+
jwks: config.jwks,
|
|
54
|
+
jwksUri: config.jwksUri,
|
|
55
|
+
requiredScopes: [...(config.requiredScopes ?? [])],
|
|
56
|
+
acceptedAlgorithms: [...(config.acceptedAlgorithms ?? DEFAULT_ALGORITHMS)],
|
|
57
|
+
leewaySeconds: config.leewaySeconds ?? 60,
|
|
58
|
+
introspection: config.introspection,
|
|
59
|
+
acceptedTokenTypes: types === null ? null : types.map(normaliseType),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable validation outcome codes, shared across the Python / TypeScript / Go ports so their verdicts
|
|
3
|
+
* can be compared directly.
|
|
4
|
+
*/
|
|
5
|
+
export declare const VALID = "valid";
|
|
6
|
+
export declare const EXPIRED = "expired";
|
|
7
|
+
export declare const NOT_YET_VALID = "not_yet_valid";
|
|
8
|
+
export declare const INVALID_SIGNATURE = "invalid_signature";
|
|
9
|
+
export declare const INVALID_ISSUER = "invalid_issuer";
|
|
10
|
+
export declare const INVALID_AUDIENCE = "invalid_audience";
|
|
11
|
+
export declare const INSUFFICIENT_SCOPE = "insufficient_scope";
|
|
12
|
+
export declare const INVALID_TOKEN = "invalid_token";
|
|
13
|
+
export declare const UNSUPPORTED_ALGORITHM = "unsupported_algorithm";
|
|
14
|
+
export declare const KEY_NOT_FOUND = "key_not_found";
|
|
15
|
+
export declare const INACTIVE = "inactive";
|
|
16
|
+
export declare const MISSING_EXPIRY = "missing_expiry";
|
|
17
|
+
export declare const ISSUED_IN_FUTURE = "issued_in_future";
|
|
18
|
+
export declare const INVALID_TOKEN_TYPE = "invalid_token_type";
|
|
19
|
+
/** Every stable error code a validation can report. */
|
|
20
|
+
export type ValidationError = typeof EXPIRED | typeof NOT_YET_VALID | typeof INVALID_SIGNATURE | typeof INVALID_ISSUER | typeof INVALID_AUDIENCE | typeof INSUFFICIENT_SCOPE | typeof INVALID_TOKEN | typeof UNSUPPORTED_ALGORITHM | typeof KEY_NOT_FOUND | typeof INACTIVE | typeof MISSING_EXPIRY | typeof ISSUED_IN_FUTURE | typeof INVALID_TOKEN_TYPE;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable validation outcome codes, shared across the Python / TypeScript / Go ports so their verdicts
|
|
3
|
+
* can be compared directly.
|
|
4
|
+
*/
|
|
5
|
+
export const VALID = "valid";
|
|
6
|
+
export const EXPIRED = "expired";
|
|
7
|
+
export const NOT_YET_VALID = "not_yet_valid";
|
|
8
|
+
export const INVALID_SIGNATURE = "invalid_signature";
|
|
9
|
+
export const INVALID_ISSUER = "invalid_issuer";
|
|
10
|
+
export const INVALID_AUDIENCE = "invalid_audience";
|
|
11
|
+
export const INSUFFICIENT_SCOPE = "insufficient_scope";
|
|
12
|
+
export const INVALID_TOKEN = "invalid_token";
|
|
13
|
+
export const UNSUPPORTED_ALGORITHM = "unsupported_algorithm";
|
|
14
|
+
export const KEY_NOT_FOUND = "key_not_found";
|
|
15
|
+
export const INACTIVE = "inactive";
|
|
16
|
+
export const MISSING_EXPIRY = "missing_expiry";
|
|
17
|
+
export const ISSUED_IN_FUTURE = "issued_in_future";
|
|
18
|
+
export const INVALID_TOKEN_TYPE = "invalid_token_type";
|