@idpartners/client-attestation-sdk 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -22
- package/dist/attester.d.ts +14 -6
- package/dist/attester.js +36 -12
- package/dist/builders.d.ts +11 -3
- package/dist/builders.js +157 -16
- package/dist/errors.d.ts +6 -2
- package/dist/errors.js +6 -2
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.js +46 -8
- package/dist/http.js +69 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/issuance.d.ts +19 -5
- package/dist/issuance.js +38 -15
- package/dist/logging.js +41 -7
- package/dist/runtime.js +9 -0
- package/dist/signer.d.ts +8 -1
- package/dist/signer.js +32 -5
- package/dist/signing-key-pair.d.ts +6 -0
- package/dist/signing-key-pair.js +91 -5
- package/dist/svid-source.d.ts +13 -2
- package/dist/svid-source.js +204 -17
- package/dist/token-source.d.ts +27 -4
- package/dist/token-source.js +232 -46
- package/dist/validator/config.d.ts +11 -1
- package/dist/validator/config.js +77 -8
- package/dist/validator/index.d.ts +1 -1
- package/dist/validator/index.js +1 -1
- package/dist/validator/jwks.js +65 -20
- package/dist/validator/resource.d.ts +8 -0
- package/dist/validator/resource.js +50 -3
- package/dist/validator/strict.js +6 -1
- package/dist/validator/validator.js +42 -11
- package/package.json +1 -1
package/dist/errors.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* The SDK's error types. {@link ClientAttestationError} is the base for every failure of the
|
|
3
3
|
* one-call flow; {@link TransportError} is the subclass for anything that went wrong on the wire.
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* A step in the flow failed. `status` and `body` are set for HTTP failures: `body` is at most the first 512
|
|
7
|
+
* characters of a non-2xx response, and unset for a 2xx response that could not be used, since that body
|
|
8
|
+
* may hold a token, attestation or SVID.
|
|
9
|
+
*/
|
|
6
10
|
export class ClientAttestationError extends Error {
|
|
7
11
|
status;
|
|
8
12
|
body;
|
|
@@ -16,7 +20,7 @@ export class ClientAttestationError extends Error {
|
|
|
16
20
|
/**
|
|
17
21
|
* An outbound HTTP call failed: unreachable, timed out, redirected, oversized, non-2xx, or malformed
|
|
18
22
|
* JSON (see {@link kind}). `status` is set when a response was received; `body` holds at most the first
|
|
19
|
-
* 512 characters of
|
|
23
|
+
* 512 characters of a non-2xx one.
|
|
20
24
|
*/
|
|
21
25
|
export class TransportError extends ClientAttestationError {
|
|
22
26
|
kind;
|
package/dist/evidence.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export declare class EvidenceNotFound extends Error {
|
|
|
19
19
|
* Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
|
|
20
20
|
* (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
|
|
21
21
|
* file's audience must already be the attester's evidence audience — set it when you declare the volume.
|
|
22
|
+
* The path must be a regular file of at most 64 KiB; anything else is an {@link EvidenceNotFound}.
|
|
22
23
|
*
|
|
23
24
|
* Needs a filesystem: `node:fs/promises` is loaded on the first `fetch`, which throws a
|
|
24
25
|
* `ClientAttestationError` on a runtime without it.
|
|
@@ -33,7 +34,8 @@ export declare class FileTokenSvidSource implements SvidSource {
|
|
|
33
34
|
*
|
|
34
35
|
* Works on any Google runtime that exposes the metadata server (GCE, GKE, Cloud Run, Vertex AI Agent
|
|
35
36
|
* Engine). The token's `aud` is the attester's evidence audience; `sub` is the service account. No
|
|
36
|
-
* google-auth dependency — a plain metadata call.
|
|
37
|
+
* google-auth dependency — a plain metadata call. The answer must be HTTP 200 carrying the
|
|
38
|
+
* `Metadata-Flavor: Google` response header, as the real metadata server sends.
|
|
37
39
|
*/
|
|
38
40
|
export declare class GoogleMetadataSvidSource implements SvidSource {
|
|
39
41
|
private readonly timeoutMs;
|
package/dist/evidence.js
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
* - the GCP metadata server (GCE, GKE, Cloud Run, Vertex AI Agent Engine).
|
|
11
11
|
*
|
|
12
12
|
* {@link autoDetectEvidence} tries these in order and returns the first that applies, so a developer
|
|
13
|
-
* can write `new ClientAttestation(attesterUrl)` and have it work without naming the source. Override by
|
|
13
|
+
* can write `new ClientAttestation(attesterUrl, { evidenceAudience })` and have it work without naming the source. Override by
|
|
14
14
|
* passing an explicit {@link SvidSource} to {@link ClientAttestation}.
|
|
15
15
|
*/
|
|
16
16
|
import { TransportError } from "./errors.js";
|
|
17
17
|
import { request, snippet } from "./http.js";
|
|
18
|
-
import { envVar, importNodeBuiltin } from "./runtime.js";
|
|
18
|
+
import { envVar, importNodeBuiltin, utf8Decode } from "./runtime.js";
|
|
19
19
|
import { SpireWorkloadApiSvidSource, StaticSvidSource } from "./svid-source.js";
|
|
20
20
|
/** Environment variable holding the evidence token itself (static). */
|
|
21
21
|
export const ENV_SVID = "CLIENT_ATTESTATION_SVID";
|
|
@@ -39,6 +39,8 @@ export const GCP_METADATA_BASE = "http://metadata.google.internal";
|
|
|
39
39
|
* @internal
|
|
40
40
|
*/
|
|
41
41
|
export const GCP_METADATA_IDENTITY = GCP_METADATA_BASE + "/computeMetadata/v1/instance/service-accounts/default/identity";
|
|
42
|
+
/** A token file larger than this (64 KiB) is refused: a projected token is a few KiB. */
|
|
43
|
+
const MAX_TOKEN_FILE_BYTES = 64 * 1024;
|
|
42
44
|
/** No platform identity could be detected and none was supplied. */
|
|
43
45
|
export class EvidenceNotFound extends Error {
|
|
44
46
|
constructor(message) {
|
|
@@ -52,6 +54,7 @@ export class EvidenceNotFound extends Error {
|
|
|
52
54
|
* Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
|
|
53
55
|
* (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
|
|
54
56
|
* file's audience must already be the attester's evidence audience — set it when you declare the volume.
|
|
57
|
+
* The path must be a regular file of at most 64 KiB; anything else is an {@link EvidenceNotFound}.
|
|
55
58
|
*
|
|
56
59
|
* Needs a filesystem: `node:fs/promises` is loaded on the first `fetch`, which throws a
|
|
57
60
|
* `ClientAttestationError` on a runtime without it.
|
|
@@ -62,9 +65,29 @@ export class FileTokenSvidSource {
|
|
|
62
65
|
this.path = path;
|
|
63
66
|
}
|
|
64
67
|
async fetch(_audience) {
|
|
65
|
-
const {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
const { open, stat } = await importNodeBuiltin("fs/promises", "FileTokenSvidSource");
|
|
69
|
+
// Checked before opening: opening a FIFO blocks, and a device such as /dev/zero never ends.
|
|
70
|
+
if (!(await stat(this.path)).isFile()) {
|
|
71
|
+
throw new EvidenceNotFound(`token file ${this.path} is not a regular file`);
|
|
72
|
+
}
|
|
73
|
+
const handle = await open(this.path, "r");
|
|
74
|
+
try {
|
|
75
|
+
const buf = new Uint8Array(MAX_TOKEN_FILE_BYTES + 1);
|
|
76
|
+
let total = 0;
|
|
77
|
+
for (;;) {
|
|
78
|
+
const { bytesRead } = await handle.read(buf, total, buf.length - total, null);
|
|
79
|
+
if (bytesRead === 0)
|
|
80
|
+
break;
|
|
81
|
+
total += bytesRead;
|
|
82
|
+
if (total > MAX_TOKEN_FILE_BYTES) {
|
|
83
|
+
throw new EvidenceNotFound(`token file ${this.path} is larger than ${MAX_TOKEN_FILE_BYTES} bytes`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return utf8Decode(buf.subarray(0, total)).trim();
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
68
91
|
}
|
|
69
92
|
}
|
|
70
93
|
/**
|
|
@@ -72,7 +95,8 @@ export class FileTokenSvidSource {
|
|
|
72
95
|
*
|
|
73
96
|
* Works on any Google runtime that exposes the metadata server (GCE, GKE, Cloud Run, Vertex AI Agent
|
|
74
97
|
* Engine). The token's `aud` is the attester's evidence audience; `sub` is the service account. No
|
|
75
|
-
* google-auth dependency — a plain metadata call.
|
|
98
|
+
* google-auth dependency — a plain metadata call. The answer must be HTTP 200 carrying the
|
|
99
|
+
* `Metadata-Flavor: Google` response header, as the real metadata server sends.
|
|
76
100
|
*/
|
|
77
101
|
export class GoogleMetadataSvidSource {
|
|
78
102
|
timeoutMs;
|
|
@@ -92,12 +116,17 @@ export class GoogleMetadataSvidSource {
|
|
|
92
116
|
headers: { "Metadata-Flavor": "Google" },
|
|
93
117
|
timeoutMs: this.timeoutMs,
|
|
94
118
|
});
|
|
95
|
-
if (
|
|
119
|
+
if (res.status !== 200) {
|
|
96
120
|
throw new TransportError(`GCP metadata identity endpoint -> ${res.status}`, "status", {
|
|
97
121
|
status: res.status,
|
|
98
122
|
body: snippet(res.body),
|
|
99
123
|
});
|
|
100
124
|
}
|
|
125
|
+
if (!fromGoogleMetadata(res.headers)) {
|
|
126
|
+
throw new TransportError("GCP metadata identity endpoint answered without Metadata-Flavor: Google", "status", {
|
|
127
|
+
status: res.status,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
101
130
|
return res.body.trim();
|
|
102
131
|
}
|
|
103
132
|
}
|
|
@@ -114,6 +143,15 @@ async function pathProbe() {
|
|
|
114
143
|
return () => false;
|
|
115
144
|
}
|
|
116
145
|
}
|
|
146
|
+
/** Whether a response carries the header the GCP metadata server always sets. */
|
|
147
|
+
function fromGoogleMetadata(headers) {
|
|
148
|
+
return headers.get("Metadata-Flavor") === "Google";
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether the GCP metadata server answers: HTTP 200 with `Metadata-Flavor: Google`.
|
|
152
|
+
* `metadata.google.internal` is a DNS name, so whatever a search domain, LLMNR or a hostile resolver
|
|
153
|
+
* points it at could answer; the header is what google-auth checks too.
|
|
154
|
+
*/
|
|
117
155
|
async function gcpMetadataAvailable(base, timeoutMs = 300) {
|
|
118
156
|
try {
|
|
119
157
|
const res = await request(base + "/computeMetadata/v1/", {
|
|
@@ -121,7 +159,7 @@ async function gcpMetadataAvailable(base, timeoutMs = 300) {
|
|
|
121
159
|
headers: { "Metadata-Flavor": "Google" },
|
|
122
160
|
timeoutMs,
|
|
123
161
|
});
|
|
124
|
-
return res.
|
|
162
|
+
return res.status === 200 && fromGoogleMetadata(res.headers);
|
|
125
163
|
}
|
|
126
164
|
catch {
|
|
127
165
|
return false;
|
package/dist/http.js
CHANGED
|
@@ -9,20 +9,50 @@
|
|
|
9
9
|
* - redirects are never followed — a 3xx is a {@link TransportError}, and the target is never contacted;
|
|
10
10
|
* - the response body is capped (default 1 MiB), by `content-length` and again while streaming;
|
|
11
11
|
* - {@link request} returns non-2xx responses as data; {@link jsonOk} turns them, and malformed JSON,
|
|
12
|
-
* into a {@link TransportError}
|
|
12
|
+
* into a {@link TransportError};
|
|
13
|
+
* - a URL with userinfo (`user:password@`) is refused, and errors name a URL without its userinfo, query or
|
|
14
|
+
* fragment.
|
|
15
|
+
*
|
|
16
|
+
* Proxies: `fetch` uses no proxy from the environment by default (Node, Deno, Bun without configuration,
|
|
17
|
+
* edge runtimes). Node started with `NODE_USE_ENV_PROXY=1` or `--use-env-proxy` sends every request,
|
|
18
|
+
* plain http included, to `HTTP_PROXY` / `HTTPS_PROXY`; the SDK can't pick a dispatcher per request
|
|
19
|
+
* without depending on undici, so the other ports' rule (a proxy only for https to a remote host) is
|
|
20
|
+
* then the operator's to keep, with `NO_PROXY`.
|
|
13
21
|
*/
|
|
14
|
-
import { TransportError } from "./errors.js";
|
|
22
|
+
import { ClientAttestationError, TransportError } from "./errors.js";
|
|
15
23
|
import { utf8Decode, utf8Encode } from "./runtime.js";
|
|
16
24
|
/** @internal */
|
|
17
25
|
export const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
18
26
|
/** @internal */
|
|
19
27
|
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
20
28
|
/**
|
|
21
|
-
* At most this many characters of a response body are carried in an error.
|
|
29
|
+
* At most this many characters of a non-2xx response body are carried in an error. A 2xx body never is:
|
|
30
|
+
* that is where access tokens, attestations and SVIDs travel.
|
|
22
31
|
*
|
|
23
32
|
* @internal
|
|
24
33
|
*/
|
|
25
34
|
export const ERROR_BODY_CHARS = 512;
|
|
35
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
36
|
+
/**
|
|
37
|
+
* Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. Shared by every SDK
|
|
38
|
+
* client that sends credentials.
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export function checkSecure(url, what, allowInsecure) {
|
|
43
|
+
if (allowInsecure)
|
|
44
|
+
return;
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = new URL(url);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new ClientAttestationError(`${what} is not a valid URL: ${url}`);
|
|
51
|
+
}
|
|
52
|
+
if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
|
|
53
|
+
return;
|
|
54
|
+
throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
|
|
55
|
+
}
|
|
26
56
|
/**
|
|
27
57
|
* The first {@link ERROR_BODY_CHARS} characters of a body, for an error message.
|
|
28
58
|
*
|
|
@@ -31,6 +61,30 @@ export const ERROR_BODY_CHARS = 512;
|
|
|
31
61
|
export function snippet(body) {
|
|
32
62
|
return body.length > ERROR_BODY_CHARS ? body.slice(0, ERROR_BODY_CHARS) : body;
|
|
33
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* `url` without its userinfo, query or fragment, for error messages: a query can carry a secret, and
|
|
66
|
+
* userinfo is one.
|
|
67
|
+
*
|
|
68
|
+
* @internal
|
|
69
|
+
*/
|
|
70
|
+
export function displayUrl(url) {
|
|
71
|
+
const beforeQuery = url.split(/[?#]/, 1)[0] ?? "";
|
|
72
|
+
return beforeQuery.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/\\]*@/, "$1");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether `url` carries userinfo, read as `fetch` would read it; an unparseable URL is checked by hand.
|
|
76
|
+
*
|
|
77
|
+
* @internal
|
|
78
|
+
*/
|
|
79
|
+
export function hasUserinfo(url) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(url);
|
|
82
|
+
return parsed.username !== "" || parsed.password !== "";
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#\\]*@/.test(url);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
34
88
|
function describe(err) {
|
|
35
89
|
return err instanceof Error ? err.message : String(err);
|
|
36
90
|
}
|
|
@@ -49,7 +103,11 @@ export async function request(url, opts = {}) {
|
|
|
49
103
|
const method = opts.method ?? "GET";
|
|
50
104
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
51
105
|
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
52
|
-
const where = `${method} ${url}`;
|
|
106
|
+
const where = `${method} ${displayUrl(url)}`;
|
|
107
|
+
if (hasUserinfo(url)) {
|
|
108
|
+
// fetch refuses these too, but its error would come after `where` held the userinfo
|
|
109
|
+
throw new TransportError(`${where}: URLs with userinfo (user:password@) are not supported`, "network");
|
|
110
|
+
}
|
|
53
111
|
const timedOut = (cause) => new TransportError(`${where} timed out after ${timeoutMs} ms`, "timeout", { cause });
|
|
54
112
|
let res;
|
|
55
113
|
try {
|
|
@@ -147,7 +205,7 @@ async function readCapped(res, maxBytes, tooLarge) {
|
|
|
147
205
|
export function jsonOk(res, opts = {}) {
|
|
148
206
|
if (!res.ok) {
|
|
149
207
|
const body = snippet(res.body);
|
|
150
|
-
throw new TransportError(`${res.method} ${res.url} returned HTTP ${res.status}: ${body}`, "status", {
|
|
208
|
+
throw new TransportError(`${res.method} ${displayUrl(res.url)} returned HTTP ${res.status}: ${body}`, "status", {
|
|
151
209
|
status: res.status,
|
|
152
210
|
body,
|
|
153
211
|
});
|
|
@@ -155,7 +213,9 @@ export function jsonOk(res, opts = {}) {
|
|
|
155
213
|
return parseJsonObject(res, opts);
|
|
156
214
|
}
|
|
157
215
|
/**
|
|
158
|
-
* Parse `res.body` as a JSON object regardless of status; a {@link TransportError} if it is not one.
|
|
216
|
+
* Parse `res.body` as a JSON object regardless of status; a {@link TransportError} if it is not one. The
|
|
217
|
+
* error carries no body, no cause and not the parser's message, which quotes part of the text: the body
|
|
218
|
+
* parsed here is a 2xx one, which carries tokens, attestations and SVIDs.
|
|
159
219
|
*
|
|
160
220
|
* @internal
|
|
161
221
|
*/
|
|
@@ -166,17 +226,12 @@ export function parseJsonObject(res, opts = {}) {
|
|
|
166
226
|
try {
|
|
167
227
|
parsed = JSON.parse(res.body);
|
|
168
228
|
}
|
|
169
|
-
catch
|
|
170
|
-
throw new TransportError(`${res.method} ${res.url} returned malformed JSON
|
|
171
|
-
status: res.status,
|
|
172
|
-
body: snippet(res.body),
|
|
173
|
-
cause: err,
|
|
174
|
-
});
|
|
229
|
+
catch {
|
|
230
|
+
throw new TransportError(`${res.method} ${displayUrl(res.url)} returned malformed JSON`, "json", { status: res.status });
|
|
175
231
|
}
|
|
176
232
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
177
|
-
throw new TransportError(`${res.method} ${res.url} did not return a JSON object`, "json", {
|
|
233
|
+
throw new TransportError(`${res.method} ${displayUrl(res.url)} did not return a JSON object`, "json", {
|
|
178
234
|
status: res.status,
|
|
179
|
-
body: snippet(res.body),
|
|
180
235
|
});
|
|
181
236
|
}
|
|
182
237
|
return parsed;
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,6 @@ export type { AttesterMetadata, AttesterProfile, AttesterTransport, MintResult }
|
|
|
21
21
|
export type { TransportFailure } from "./errors.js";
|
|
22
22
|
export { NOOP_LOGGER } from "./logging.js";
|
|
23
23
|
export type { Logger, LogAttributes, LogLevel } from "./logging.js";
|
|
24
|
-
export declare const VERSION = "1.0
|
|
24
|
+
export declare const VERSION = "1.2.0";
|
|
25
25
|
/** draft-ietf-oauth-attestation-based-client-auth revision this SDK implements. */
|
|
26
26
|
export declare const DRAFT_REVISION = "10";
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,6 @@ export { EvidenceNotFound, FileTokenSvidSource, GoogleMetadataSvidSource, autoDe
|
|
|
15
15
|
export { ClientAttestation, ClientAttestationError, TransportError, DEFAULT_EXPIRY_SKEW_SECONDS, } from "./token-source.js";
|
|
16
16
|
export { MintRequest, PingFederateProfile } from "./attester.js";
|
|
17
17
|
export { NOOP_LOGGER } from "./logging.js";
|
|
18
|
-
export const VERSION = "1.0
|
|
18
|
+
export const VERSION = "1.2.0";
|
|
19
19
|
/** draft-ietf-oauth-attestation-based-client-auth revision this SDK implements. */
|
|
20
20
|
export const DRAFT_REVISION = "10";
|
package/dist/issuance.d.ts
CHANGED
|
@@ -12,20 +12,26 @@ import type { SvidSource } from "./svid-source.js";
|
|
|
12
12
|
/** The instance-key proof typ the PingFederate profile presents (server InstanceKeyProofValidator.TYP). */
|
|
13
13
|
export { INSTANCE_PROOF_TYP } from "./attester.js";
|
|
14
14
|
/**
|
|
15
|
-
* Thrown when the issuance endpoint rejects the request (non-2xx)
|
|
16
|
-
*
|
|
15
|
+
* Thrown when the issuance endpoint rejects the request (non-2xx); `body` is at most the first 512
|
|
16
|
+
* characters of the response. Transport failures — unreachable, timeout, redirect, oversized or malformed
|
|
17
|
+
* response — are a {@link TransportError} instead.
|
|
17
18
|
*/
|
|
18
19
|
export declare class IssuanceError extends Error {
|
|
19
20
|
readonly status: number;
|
|
20
21
|
readonly body: string;
|
|
21
22
|
constructor(status: number, body: string);
|
|
22
23
|
}
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* A minted Client Attestation plus the instance key it is bound to. The attestation is a getter over a
|
|
26
|
+
* private field, so `JSON.stringify` and `console.log` of this object don't show it.
|
|
27
|
+
*/
|
|
24
28
|
export declare class IssuedAttestation {
|
|
25
|
-
|
|
29
|
+
#private;
|
|
26
30
|
readonly expiresIn: number | undefined;
|
|
27
31
|
readonly instanceKey: SigningKeyPair;
|
|
28
32
|
constructor(attestation: string, expiresIn: number | undefined, instanceKey: SigningKeyPair);
|
|
33
|
+
/** The Client Attestation (a compact JWT). */
|
|
34
|
+
get attestation(): string;
|
|
29
35
|
/** A fresh Client Attestation PoP for the token endpoint, signed by the bound instance key. */
|
|
30
36
|
pop(audience: string, clientId?: string, challenge?: string): Promise<string>;
|
|
31
37
|
/**
|
|
@@ -39,6 +45,9 @@ export declare class IssuedAttestation {
|
|
|
39
45
|
*
|
|
40
46
|
* `opts.profile` sets the mint protocol (default {@link PingFederateProfile}; see
|
|
41
47
|
* docs/attester-profiles.md). The endpoint is given directly, so the profile's discovery is not used.
|
|
48
|
+
*
|
|
49
|
+
* The SVID is a bearer credential to the attester, so the endpoint, and every URL the profile calls, must be
|
|
50
|
+
* https unless its host is loopback (`localhost`, `127.0.0.1`, `::1`) or `opts.allowInsecure` is set.
|
|
42
51
|
*
|
|
43
52
|
* @experimental See docs/api.md: it calls a PingFederate demo issuance servlet (/federation/attestation)
|
|
44
53
|
* whose protocol is not standardised.
|
|
@@ -50,11 +59,16 @@ export declare class AttestationIssuanceClient {
|
|
|
50
59
|
private readonly source;
|
|
51
60
|
readonly instanceKey: SigningKeyPair;
|
|
52
61
|
readonly profile: AttesterProfile;
|
|
62
|
+
private readonly transport;
|
|
53
63
|
private constructor();
|
|
54
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Generate a fresh ES256 instance key and return a ready client. `opts.allowInsecure` permits plain-http
|
|
66
|
+
* URLs on hosts that are not loopback (default `false`).
|
|
67
|
+
*/
|
|
55
68
|
static create(endpoint: string, clientId: string, attesterIssuer: string, source: SvidSource, opts?: {
|
|
56
69
|
instanceKey?: SigningKeyPair;
|
|
57
70
|
profile?: AttesterProfile;
|
|
71
|
+
allowInsecure?: boolean;
|
|
58
72
|
}): Promise<AttestationIssuanceClient>;
|
|
59
73
|
/** Fetch the SVID, prove possession of the instance key, and exchange them for a minted attestation. */
|
|
60
74
|
bootstrap(authorizationDetails?: Array<Record<string, unknown>>, challenge?: string): Promise<IssuedAttestation>;
|
package/dist/issuance.js
CHANGED
|
@@ -8,15 +8,17 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { MintRequest, PingFederateProfile } from "./attester.js";
|
|
10
10
|
import { DpopProofBuilder, PopBuilder } from "./builders.js";
|
|
11
|
-
import {
|
|
11
|
+
import { ClientAttestationError } from "./errors.js";
|
|
12
|
+
import { checkSecure, jsonOk, request, snippet } from "./http.js";
|
|
12
13
|
import { SigningKeyPair } from "./signing-key-pair.js";
|
|
13
14
|
/** The instance-key proof typ the PingFederate profile presents (server InstanceKeyProofValidator.TYP). */
|
|
14
15
|
export { INSTANCE_PROOF_TYP } from "./attester.js";
|
|
15
16
|
/** Per-call timeout for the issuance endpoint. */
|
|
16
17
|
const ISSUANCE_TIMEOUT_MS = 20_000;
|
|
17
18
|
/**
|
|
18
|
-
* Thrown when the issuance endpoint rejects the request (non-2xx)
|
|
19
|
-
*
|
|
19
|
+
* Thrown when the issuance endpoint rejects the request (non-2xx); `body` is at most the first 512
|
|
20
|
+
* characters of the response. Transport failures — unreachable, timeout, redirect, oversized or malformed
|
|
21
|
+
* response — are a {@link TransportError} instead.
|
|
20
22
|
*/
|
|
21
23
|
export class IssuanceError extends Error {
|
|
22
24
|
status;
|
|
@@ -28,15 +30,22 @@ export class IssuanceError extends Error {
|
|
|
28
30
|
this.name = "IssuanceError";
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* A minted Client Attestation plus the instance key it is bound to. The attestation is a getter over a
|
|
35
|
+
* private field, so `JSON.stringify` and `console.log` of this object don't show it.
|
|
36
|
+
*/
|
|
32
37
|
export class IssuedAttestation {
|
|
33
|
-
attestation;
|
|
34
38
|
expiresIn;
|
|
35
39
|
instanceKey;
|
|
40
|
+
#attestation;
|
|
36
41
|
constructor(attestation, expiresIn, instanceKey) {
|
|
37
|
-
this.attestation = attestation;
|
|
38
42
|
this.expiresIn = expiresIn;
|
|
39
43
|
this.instanceKey = instanceKey;
|
|
44
|
+
this.#attestation = attestation;
|
|
45
|
+
}
|
|
46
|
+
/** The Client Attestation (a compact JWT). */
|
|
47
|
+
get attestation() {
|
|
48
|
+
return this.#attestation;
|
|
40
49
|
}
|
|
41
50
|
/** A fresh Client Attestation PoP for the token endpoint, signed by the bound instance key. */
|
|
42
51
|
async pop(audience, clientId, challenge) {
|
|
@@ -63,6 +72,9 @@ export class IssuedAttestation {
|
|
|
63
72
|
*
|
|
64
73
|
* `opts.profile` sets the mint protocol (default {@link PingFederateProfile}; see
|
|
65
74
|
* docs/attester-profiles.md). The endpoint is given directly, so the profile's discovery is not used.
|
|
75
|
+
*
|
|
76
|
+
* The SVID is a bearer credential to the attester, so the endpoint, and every URL the profile calls, must be
|
|
77
|
+
* https unless its host is loopback (`localhost`, `127.0.0.1`, `::1`) or `opts.allowInsecure` is set.
|
|
66
78
|
*
|
|
67
79
|
* @experimental See docs/api.md: it calls a PingFederate demo issuance servlet (/federation/attestation)
|
|
68
80
|
* whose protocol is not standardised.
|
|
@@ -74,36 +86,47 @@ export class AttestationIssuanceClient {
|
|
|
74
86
|
source;
|
|
75
87
|
instanceKey;
|
|
76
88
|
profile;
|
|
77
|
-
|
|
89
|
+
transport;
|
|
90
|
+
constructor(endpoint, clientId, attesterIssuer, source, instanceKey, profile, transport) {
|
|
78
91
|
this.endpoint = endpoint;
|
|
79
92
|
this.clientId = clientId;
|
|
80
93
|
this.attesterIssuer = attesterIssuer;
|
|
81
94
|
this.source = source;
|
|
82
95
|
this.instanceKey = instanceKey;
|
|
83
96
|
this.profile = profile;
|
|
97
|
+
this.transport = transport;
|
|
84
98
|
}
|
|
85
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Generate a fresh ES256 instance key and return a ready client. `opts.allowInsecure` permits plain-http
|
|
101
|
+
* URLs on hosts that are not loopback (default `false`).
|
|
102
|
+
*/
|
|
86
103
|
static async create(endpoint, clientId, attesterIssuer, source, opts) {
|
|
104
|
+
const allowInsecure = opts?.allowInsecure ?? false;
|
|
105
|
+
const trimmed = endpoint.replace(/\/$/, "");
|
|
106
|
+
checkSecure(trimmed, "endpoint", allowInsecure);
|
|
87
107
|
const key = opts?.instanceKey ?? (await SigningKeyPair.generate("ES256"));
|
|
88
108
|
const profile = opts?.profile ?? new PingFederateProfile();
|
|
89
|
-
return new AttestationIssuanceClient(
|
|
109
|
+
return new AttestationIssuanceClient(trimmed, clientId, attesterIssuer, source, key, profile, issuanceTransport(allowInsecure));
|
|
90
110
|
}
|
|
91
111
|
/** Fetch the SVID, prove possession of the instance key, and exchange them for a minted attestation. */
|
|
92
112
|
async bootstrap(authorizationDetails, challenge) {
|
|
93
113
|
const svid = await this.source.fetch(this.attesterIssuer);
|
|
94
|
-
const minted = await this.profile.mint(
|
|
114
|
+
const minted = await this.profile.mint(this.transport, { evidenceAudience: this.attesterIssuer, attestationEndpoint: this.endpoint }, new MintRequest({
|
|
95
115
|
instanceKey: this.instanceKey,
|
|
96
116
|
evidence: svid,
|
|
97
117
|
challenge: challenge || undefined,
|
|
98
118
|
clientId: this.clientId,
|
|
99
119
|
authorizationDetails,
|
|
100
120
|
}));
|
|
121
|
+
if (!minted?.attestation)
|
|
122
|
+
throw new ClientAttestationError("attester response contained no attestation");
|
|
101
123
|
return new IssuedAttestation(minted.attestation, minted.expiresIn, this.instanceKey);
|
|
102
124
|
}
|
|
103
125
|
}
|
|
104
|
-
/** The issuance endpoint's transport: a non-2xx answer is an {@link IssuanceError}. */
|
|
105
|
-
const issuanceTransport = {
|
|
106
|
-
async requestJson(
|
|
126
|
+
/** The issuance endpoint's transport: the https rule, and a non-2xx answer is an {@link IssuanceError}. */
|
|
127
|
+
const issuanceTransport = (allowInsecure) => ({
|
|
128
|
+
async requestJson(step, method, url, body) {
|
|
129
|
+
checkSecure(url, `${step} URL`, allowInsecure);
|
|
107
130
|
const res = await request(url, {
|
|
108
131
|
method,
|
|
109
132
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
@@ -111,7 +134,7 @@ const issuanceTransport = {
|
|
|
111
134
|
timeoutMs: ISSUANCE_TIMEOUT_MS,
|
|
112
135
|
});
|
|
113
136
|
if (!res.ok)
|
|
114
|
-
throw new IssuanceError(res.status, res.body);
|
|
137
|
+
throw new IssuanceError(res.status, snippet(res.body));
|
|
115
138
|
return jsonOk(res);
|
|
116
139
|
},
|
|
117
|
-
};
|
|
140
|
+
});
|
package/dist/logging.js
CHANGED
|
@@ -4,25 +4,59 @@
|
|
|
4
4
|
* other ports; see docs/logging.md.
|
|
5
5
|
*
|
|
6
6
|
* Never logged: tokens, attestations, proofs, SVIDs, challenge values, key material, header values, or
|
|
7
|
-
* URLs with their query string. As a backstop,
|
|
8
|
-
*
|
|
7
|
+
* URLs with their query string. As a backstop, an attribute under a credential key (`token`, `*_token`,
|
|
8
|
+
* `auth_req_id`, `code`, ...), or a string attribute that carries a credential (contains `eyJ`, how a
|
|
9
|
+
* JWT's base64url-encoded header begins; a Vault/OpenBao token; an `Authorization` header value), is
|
|
10
|
+
* replaced with `<redacted:N bytes>` before it reaches the logger.
|
|
9
11
|
*/
|
|
10
12
|
import { TransportError } from "./errors.js";
|
|
11
13
|
import { utf8Encode } from "./runtime.js";
|
|
12
14
|
const noop = () => undefined;
|
|
13
15
|
/** The default: drops everything. */
|
|
14
16
|
export const NOOP_LOGGER = { debug: noop, info: noop, warn: noop, error: noop };
|
|
15
|
-
|
|
17
|
+
// The backstop (docs/logging.md, the same rule in every port): an attribute is redacted when its key names
|
|
18
|
+
// a credential, or its value carries one this SDK handles. Keys are matched in lower case.
|
|
19
|
+
const SECRET_KEYS = new Set([
|
|
20
|
+
"svid", "proof", "assertion", "client_assertion", "subject_token", "actor_token", "client_secret",
|
|
21
|
+
"attestation", "access_token", "refresh_token", "id_token", "authorization", "dpop",
|
|
22
|
+
"oauth-client-attestation", "oauth-client-attestation-pop", "auth_req_id", "code", "handle", "token",
|
|
23
|
+
"secret", "password", "private_key", "vault_token", "x-vault-token",
|
|
24
|
+
]);
|
|
25
|
+
const SECRET_KEY_SUFFIXES = ["_token", "-token", "_secret", "-secret", "_password"];
|
|
26
|
+
// Vault/OpenBao tokens: hvs./hvb./hvr. and the older s./b./r. forms, standing alone.
|
|
27
|
+
const VAULT_TOKEN = /(?:^|[^A-Za-z0-9._-])(?:hv[sbr]\.[A-Za-z0-9_-]{20,}|[sbr]\.[A-Za-z0-9_-]{24,}(?:$|[^A-Za-z0-9._-]))/;
|
|
28
|
+
// An Authorization header value anywhere in the string: a scheme this SDK sends, standing alone, then
|
|
29
|
+
// whitespace and the first character of a credential ("Bearer x", " Bearer x", "Authorization: Bearer x").
|
|
30
|
+
// A bare scheme name ("Bearer", "token_type=Bearer", "dpop_combined") is not one.
|
|
31
|
+
const AUTH_SCHEME = /(?:^|[^A-Za-z0-9_-])(?:bearer|dpop|basic)[ \t]+[A-Za-z0-9._~+/-]/i;
|
|
32
|
+
function secretKey(key) {
|
|
33
|
+
const k = key.toLowerCase();
|
|
34
|
+
return SECRET_KEYS.has(k) || SECRET_KEY_SUFFIXES.some((suffix) => k.endsWith(suffix));
|
|
35
|
+
}
|
|
36
|
+
function redacted(value) {
|
|
37
|
+
return `<redacted:${utf8Encode(value).length} bytes>`;
|
|
38
|
+
}
|
|
39
|
+
function guard(key, value) {
|
|
40
|
+
if (secretKey(key)) {
|
|
41
|
+
try {
|
|
42
|
+
return redacted(typeof value === "string" ? value : String(value));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// a value whose toString throws (or an object with no prototype) must not throw out of logging
|
|
46
|
+
return "<redacted>";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
16
49
|
if (typeof value !== "string")
|
|
17
50
|
return value;
|
|
18
51
|
// A JWT's header and claims (any base64url-encoded JSON object) begin with "eyJ".
|
|
19
|
-
if (value.includes("eyJ")) {
|
|
20
|
-
return
|
|
52
|
+
if (value.includes("eyJ") || VAULT_TOKEN.test(value) || AUTH_SCHEME.test(value)) {
|
|
53
|
+
return redacted(value);
|
|
21
54
|
}
|
|
22
55
|
return value;
|
|
23
56
|
}
|
|
24
57
|
/**
|
|
25
|
-
* Emit one event: undefined attributes dropped,
|
|
58
|
+
* Emit one event: undefined attributes dropped, credential keys and credential-looking values redacted.
|
|
59
|
+
* Never throws.
|
|
26
60
|
*
|
|
27
61
|
* @internal
|
|
28
62
|
*/
|
|
@@ -32,7 +66,7 @@ export function emit(logger, level, event, attrs = {}) {
|
|
|
32
66
|
const clean = {};
|
|
33
67
|
for (const [key, value] of Object.entries(attrs)) {
|
|
34
68
|
if (value !== undefined && value !== null)
|
|
35
|
-
clean[key] = guard(value);
|
|
69
|
+
clean[key] = guard(key, value);
|
|
36
70
|
}
|
|
37
71
|
try {
|
|
38
72
|
logger[level](event, clean);
|
package/dist/runtime.js
CHANGED
|
@@ -35,6 +35,15 @@ export function base64Encode(bytes) {
|
|
|
35
35
|
}
|
|
36
36
|
return btoa(binary);
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Milliseconds on a monotonic clock (`performance.now()`, which Node, Deno, Bun and workerd provide), for
|
|
40
|
+
* measuring how long something has been cached: unlike `Date.now()`, a wall-clock step does not move it.
|
|
41
|
+
*
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export function monotonicMs() {
|
|
45
|
+
return performance.now();
|
|
46
|
+
}
|
|
38
47
|
/**
|
|
39
48
|
* An environment variable, or `undefined` on a runtime with no `process.env` (most edge runtimes).
|
|
40
49
|
*
|
package/dist/signer.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface JwsSigner {
|
|
|
19
19
|
* fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
|
|
20
20
|
* latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
|
|
21
21
|
*
|
|
22
|
+
* `baoAddr` must be https unless its host is loopback (`localhost`, `127.0.0.1`, `::1`) or
|
|
23
|
+
* `allowInsecure: true` is passed: the vault token rides on every call, and the public key read from the
|
|
24
|
+
* vault becomes the attester's published JWK. `keyName` may contain only letters, digits, `_`, `.` and `-`
|
|
25
|
+
* (and is not `.` or `..`).
|
|
26
|
+
*
|
|
22
27
|
* @experimental See docs/api.md: a client for a third-party API that authenticates with a static token only; its constructor will change when other auth methods are added.
|
|
23
28
|
*/
|
|
24
29
|
export declare class OpenBaoTransitSigner implements JwsSigner {
|
|
@@ -26,7 +31,9 @@ export declare class OpenBaoTransitSigner implements JwsSigner {
|
|
|
26
31
|
readonly algorithm: string;
|
|
27
32
|
readonly keyId: string;
|
|
28
33
|
private constructor();
|
|
29
|
-
static create(baoAddr: string, token: string, keyName: string
|
|
34
|
+
static create(baoAddr: string, token: string, keyName: string, opts?: {
|
|
35
|
+
allowInsecure?: boolean;
|
|
36
|
+
}): Promise<OpenBaoTransitSigner>;
|
|
30
37
|
publicJwk(): JWK;
|
|
31
38
|
sign(signingInput: Uint8Array): Promise<Uint8Array>;
|
|
32
39
|
}
|