@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/validator/jwks.js
CHANGED
|
@@ -26,6 +26,25 @@ const defaultGet = async (url) => {
|
|
|
26
26
|
}
|
|
27
27
|
return resp.body;
|
|
28
28
|
};
|
|
29
|
+
/** The JWK members only a private or symmetric key carries (RFC 7518 section 6). */
|
|
30
|
+
const PRIVATE_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "k"];
|
|
31
|
+
/**
|
|
32
|
+
* Whether a JWK may verify a signature: not when its `use` is present and not `sig`, its `key_ops` is present
|
|
33
|
+
* and lacks `verify`, or it carries private members. Checked on the JWK as published, before jose's
|
|
34
|
+
* `importJWK` (which drops `use`) sees it.
|
|
35
|
+
*/
|
|
36
|
+
function verifiesSignatures(jwk) {
|
|
37
|
+
if (typeof jwk !== "object" || jwk === null || Array.isArray(jwk)) {
|
|
38
|
+
return false; // not a JWK at all: skipped like any key that can't be read
|
|
39
|
+
}
|
|
40
|
+
if ("use" in jwk && jwk.use !== "sig") {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if ("key_ops" in jwk && !(Array.isArray(jwk.key_ops) && jwk.key_ops.includes("verify"))) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return !PRIVATE_MEMBERS.some((member) => member in jwk);
|
|
47
|
+
}
|
|
29
48
|
/** Best-effort JWS `alg` for a JWK that omits `alg`, from its key type / curve. */
|
|
30
49
|
function algorithmFor(jwk) {
|
|
31
50
|
if (jwk.kty === "EC") {
|
|
@@ -48,8 +67,8 @@ export class JwksProvider {
|
|
|
48
67
|
#maxAgeMs;
|
|
49
68
|
#clock;
|
|
50
69
|
#logger;
|
|
51
|
-
/**
|
|
52
|
-
#keys =
|
|
70
|
+
/** The usable keys, in document order: kid-less JWKs are distinct keys, and the first of a duplicate kid wins. */
|
|
71
|
+
#keys = [];
|
|
53
72
|
#loaded = null;
|
|
54
73
|
#inFlight = null;
|
|
55
74
|
#loadedAt = null;
|
|
@@ -62,7 +81,7 @@ export class JwksProvider {
|
|
|
62
81
|
this.#httpGet = opts.httpGet ?? defaultGet;
|
|
63
82
|
this.#minRefreshMs = (opts.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS) * 1000;
|
|
64
83
|
this.#maxAgeMs = (opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS) * 1000;
|
|
65
|
-
this.#clock = opts.clock ??
|
|
84
|
+
this.#clock = opts.clock ?? (() => performance.now());
|
|
66
85
|
this.#logger = opts.logger ?? NOOP_LOGGER;
|
|
67
86
|
if (opts.jwks != null) {
|
|
68
87
|
this.#loaded = _a.#parse(opts.jwks).then((keys) => {
|
|
@@ -71,11 +90,20 @@ export class JwksProvider {
|
|
|
71
90
|
}
|
|
72
91
|
}
|
|
73
92
|
static async #parse(jwks) {
|
|
74
|
-
const keys =
|
|
75
|
-
for (const
|
|
76
|
-
|
|
93
|
+
const keys = [];
|
|
94
|
+
for (const published of (jwks.keys ?? [])) {
|
|
95
|
+
if (!verifiesSignatures(published)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Imported without its key_ops: verifiesSignatures has checked them, and jose would hand them to
|
|
99
|
+
// WebCrypto as the key's usages, which refuses a public key with any usage but "verify" (a JWK exported
|
|
100
|
+
// from a key pair says ["sign","verify"]). Every later import of this entry (resolve) uses this copy.
|
|
101
|
+
const { key_ops: _keyOps, ...jwk } = published;
|
|
102
|
+
// a JWK jose can't read at all is skipped; its key type and curve say how to read it
|
|
103
|
+
const inferred = algorithmFor(jwk);
|
|
77
104
|
try {
|
|
78
|
-
|
|
105
|
+
const key = await importJWK(jwk, inferred);
|
|
106
|
+
keys.push({ kid: jwk.kid, jwk, alg: jwk.alg, imported: new Map([[inferred, key]]) });
|
|
79
107
|
}
|
|
80
108
|
catch {
|
|
81
109
|
continue;
|
|
@@ -85,34 +113,51 @@ export class JwksProvider {
|
|
|
85
113
|
}
|
|
86
114
|
/**
|
|
87
115
|
* Resolve the verification key for a header `kid` (and `alg`). Falls back to the single configured key
|
|
88
|
-
* when the header carries no `kid`. Returns `undefined` when no key can be found
|
|
89
|
-
*
|
|
116
|
+
* when the header carries no `kid`. Returns `undefined` when no key can be found, and `null` when the key
|
|
117
|
+
* found can't be used with `alg` (an EC key of another curve, an RSA key for an EC algorithm); never
|
|
118
|
+
* rejects — a failed fetch leaves its reason in {@link lastError}.
|
|
90
119
|
*/
|
|
91
120
|
async resolve(kid, alg) {
|
|
92
121
|
if (this.#loaded) {
|
|
93
122
|
await this.#loaded;
|
|
94
123
|
}
|
|
95
|
-
if (!this.#static && this.#jwksUri
|
|
96
|
-
|
|
124
|
+
if (!this.#static && this.#jwksUri) {
|
|
125
|
+
const held = this.#lookup(kid, alg) !== undefined;
|
|
126
|
+
if (!held || this.#stale()) {
|
|
127
|
+
await this.#refresh(held);
|
|
128
|
+
}
|
|
97
129
|
}
|
|
98
|
-
const entry = this.#lookup(kid);
|
|
99
|
-
if (entry === undefined
|
|
130
|
+
const entry = this.#lookup(kid, alg);
|
|
131
|
+
if (entry === undefined) {
|
|
100
132
|
return undefined;
|
|
101
133
|
}
|
|
102
|
-
|
|
134
|
+
const as = alg ?? entry.alg ?? algorithmFor(entry.jwk);
|
|
135
|
+
if (!entry.imported.has(as)) {
|
|
136
|
+
entry.imported.set(as, await importJWK(entry.jwk, as).catch(() => null));
|
|
137
|
+
}
|
|
138
|
+
return entry.imported.get(as) ?? null;
|
|
103
139
|
}
|
|
104
|
-
|
|
140
|
+
/**
|
|
141
|
+
* For a `kid`, the first key with that `kid` that may be used for `alg`; without one, the sole key when the
|
|
142
|
+
* set holds exactly one. An empty `kid` names no key.
|
|
143
|
+
*/
|
|
144
|
+
#lookup(kid, alg) {
|
|
145
|
+
const allowed = (entry) => alg == null || entry.alg == null || entry.alg === alg;
|
|
105
146
|
if (kid != null) {
|
|
106
|
-
return this.#keys.
|
|
147
|
+
return kid === "" ? undefined : this.#keys.find((entry) => entry.kid === kid && allowed(entry));
|
|
107
148
|
}
|
|
108
|
-
return this.#keys.
|
|
149
|
+
return this.#keys.length === 1 && allowed(this.#keys[0]) ? this.#keys[0] : undefined;
|
|
109
150
|
}
|
|
110
151
|
#stale() {
|
|
111
152
|
return this.#loadedAt === null || this.#clock() - this.#loadedAt >= this.#maxAgeMs;
|
|
112
153
|
}
|
|
113
|
-
|
|
154
|
+
/**
|
|
155
|
+
* A caller that already holds a usable (if stale) key never waits for another request's fetch: it goes on
|
|
156
|
+
* with the key it has. One without a key waits for a fetch in flight, which may bring its key.
|
|
157
|
+
*/
|
|
158
|
+
#refresh(haveKey) {
|
|
114
159
|
if (this.#inFlight) {
|
|
115
|
-
return this.#inFlight;
|
|
160
|
+
return haveKey ? Promise.resolve() : this.#inFlight;
|
|
116
161
|
}
|
|
117
162
|
const now = this.#clock();
|
|
118
163
|
if (this.#lastAttempt !== null && now - this.#lastAttempt < this.#minRefreshMs) {
|
|
@@ -129,7 +174,7 @@ export class JwksProvider {
|
|
|
129
174
|
this.#keys = await _a.#parse(JSON.parse(body));
|
|
130
175
|
this.#loadedAt = this.#clock();
|
|
131
176
|
this.lastError = null;
|
|
132
|
-
emit(this.#logger, "debug", "jwks.fetched", { host: hostOf(this.#jwksUri), keys: this.#keys.
|
|
177
|
+
emit(this.#logger, "debug", "jwks.fetched", { host: hostOf(this.#jwksUri), keys: this.#keys.length });
|
|
133
178
|
}
|
|
134
179
|
catch (exc) {
|
|
135
180
|
emit(this.#logger, "warn", "jwks.fetch_failed", {
|
|
@@ -34,4 +34,12 @@ export declare class ProtectedResource {
|
|
|
34
34
|
metadataUrl(): string;
|
|
35
35
|
challenge(error?: string, errorDescription?: string | null): string;
|
|
36
36
|
authenticate(authorizationHeader?: string | null, requiredScopes?: string[]): Promise<AuthDecision>;
|
|
37
|
+
/**
|
|
38
|
+
* {@link authenticate} for tokens checked by RFC 7662 introspection ({@link AccessTokenValidator.validateActive}),
|
|
39
|
+
* such as opaque tokens, with the same rules: the response's `aud` must name this resource, so a response
|
|
40
|
+
* without one is refused (401), and a sender-constrained token (`cnf`) is refused (401). A failed
|
|
41
|
+
* introspection call is not a verdict: it rejects, as `validateActive` does, and the caller answers it (503,
|
|
42
|
+
* say).
|
|
43
|
+
*/
|
|
44
|
+
authenticateActive(authorizationHeader?: string | null, requiredScopes?: string[]): Promise<AuthDecision>;
|
|
37
45
|
}
|
|
@@ -10,12 +10,46 @@
|
|
|
10
10
|
import * as errors from "./errors.js";
|
|
11
11
|
import { failure } from "./result.js";
|
|
12
12
|
const WELL_KNOWN = "/.well-known/oauth-protected-resource";
|
|
13
|
+
const BEARER = "bearer";
|
|
13
14
|
/** Extract the token from an `Authorization: Bearer <token>` header value, or null. */
|
|
14
15
|
export function bearerToken(authorizationHeader) {
|
|
15
16
|
if (!authorizationHeader)
|
|
16
17
|
return null;
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// One linear pass, no regex: a backtracking pattern here costs quadratic or cubic time on a hostile header.
|
|
19
|
+
// The result is what Go's RE2 (?i)^\s*Bearer\s+(.+?)\s*$ returns, so every port extracts the same token.
|
|
20
|
+
const h = authorizationHeader;
|
|
21
|
+
const n = h.length;
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < n && isSpace(h.charCodeAt(i)))
|
|
24
|
+
i++;
|
|
25
|
+
if (n - i <= BEARER.length)
|
|
26
|
+
return null;
|
|
27
|
+
for (let k = 0; k < BEARER.length; k++, i++) {
|
|
28
|
+
if ((h.charCodeAt(i) | 0x20) !== BEARER.charCodeAt(k))
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (!isSpace(h.charCodeAt(i)))
|
|
32
|
+
return null;
|
|
33
|
+
let end = n;
|
|
34
|
+
while (end > i && isSpace(h.charCodeAt(end - 1)))
|
|
35
|
+
end--;
|
|
36
|
+
if (end === i) {
|
|
37
|
+
// Only whitespace after the scheme: RE2 takes the last non-LF whitespace character after the first.
|
|
38
|
+
for (let j = n - 1; j > i; j--) {
|
|
39
|
+
if (h[j] !== "\n")
|
|
40
|
+
return h[j];
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
let start = i + 1;
|
|
45
|
+
while (isSpace(h.charCodeAt(start)))
|
|
46
|
+
start++;
|
|
47
|
+
const token = h.slice(start, end);
|
|
48
|
+
return token.includes("\n") ? null : token;
|
|
49
|
+
}
|
|
50
|
+
/** RE2's `\s`: space, tab, LF, FF and CR. */
|
|
51
|
+
function isSpace(c) {
|
|
52
|
+
return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0c || c === 0x0d;
|
|
19
53
|
}
|
|
20
54
|
/**
|
|
21
55
|
* An OAuth-protected resource server. The `validator` must accept `resource` as an audience — that binds
|
|
@@ -65,11 +99,24 @@ export class ProtectedResource {
|
|
|
65
99
|
return "Bearer " + params.join(", ");
|
|
66
100
|
}
|
|
67
101
|
async authenticate(authorizationHeader, requiredScopes) {
|
|
102
|
+
return this.#guard(authorizationHeader, (token) => this.#validator.validate(token, requiredScopes));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* {@link authenticate} for tokens checked by RFC 7662 introspection ({@link AccessTokenValidator.validateActive}),
|
|
106
|
+
* such as opaque tokens, with the same rules: the response's `aud` must name this resource, so a response
|
|
107
|
+
* without one is refused (401), and a sender-constrained token (`cnf`) is refused (401). A failed
|
|
108
|
+
* introspection call is not a verdict: it rejects, as `validateActive` does, and the caller answers it (503,
|
|
109
|
+
* say).
|
|
110
|
+
*/
|
|
111
|
+
async authenticateActive(authorizationHeader, requiredScopes) {
|
|
112
|
+
return this.#guard(authorizationHeader, (token) => this.#validator.validateActive(token, requiredScopes));
|
|
113
|
+
}
|
|
114
|
+
async #guard(authorizationHeader, validate) {
|
|
68
115
|
const token = bearerToken(authorizationHeader);
|
|
69
116
|
if (token == null) {
|
|
70
117
|
return { authorized: false, status: 401, wwwAuthenticate: this.challenge(), errorDescription: "authentication required" };
|
|
71
118
|
}
|
|
72
|
-
let result = await
|
|
119
|
+
let result = await validate(token);
|
|
73
120
|
if (result.valid && !result.audience.includes(this.resource)) {
|
|
74
121
|
result = failure(errors.INVALID_AUDIENCE, "token audience is not this resource");
|
|
75
122
|
}
|
package/dist/validator/strict.js
CHANGED
|
@@ -19,7 +19,12 @@ const SEGMENT = /^[A-Za-z0-9_-]*$/;
|
|
|
19
19
|
*/
|
|
20
20
|
export class Malformed extends Error {
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* One segment of a compact JWS: unpadded base64url in its canonical form (no non-zero padding bits).
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
export function decodeSegment(segment) {
|
|
23
28
|
if (!SEGMENT.test(segment) || segment.length % 4 === 1) {
|
|
24
29
|
throw new Malformed("malformed token");
|
|
25
30
|
}
|
|
@@ -92,11 +92,16 @@ export class AccessTokenValidator {
|
|
|
92
92
|
return failure(errors.UNSUPPORTED_ALGORITHM, "algorithm not accepted");
|
|
93
93
|
}
|
|
94
94
|
const kid = header.kid;
|
|
95
|
-
const key = kid == null || typeof kid === "string" ? await this.#jwks.resolve(kid ?? undefined, alg) :
|
|
96
|
-
if (key
|
|
97
|
-
|
|
95
|
+
const key = kid == null || typeof kid === "string" ? await this.#jwks.resolve(kid ?? undefined, alg) : undefined;
|
|
96
|
+
if (key === undefined) {
|
|
97
|
+
// a fetch failure's detail (internal host, dial error, IdP body) went to the logger as jwks.fetch_failed;
|
|
98
|
+
// the description lands in WWW-Authenticate, in front of anyone
|
|
99
|
+
return failure(errors.KEY_NOT_FOUND, this.#jwks.lastError !== null ? "signing keys unavailable" : "no signing key for this kid");
|
|
98
100
|
}
|
|
99
101
|
try {
|
|
102
|
+
if (key === null) {
|
|
103
|
+
throw new Error("the key can't be used with this algorithm");
|
|
104
|
+
}
|
|
100
105
|
await compactVerify(token, key, { algorithms: [alg] });
|
|
101
106
|
}
|
|
102
107
|
catch {
|
|
@@ -174,15 +179,14 @@ export class AccessTokenValidator {
|
|
|
174
179
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
175
180
|
Accept: "application/json",
|
|
176
181
|
};
|
|
177
|
-
|
|
178
|
-
if (authMethod === "client_secret_basic") {
|
|
179
|
-
const raw = `${cfg.clientId}:${cfg.clientSecret}`;
|
|
180
|
-
headers.Authorization = `Basic ${base64(raw)}`;
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
182
|
+
if (cfg.authMethod === "client_secret_post") {
|
|
183
183
|
form.set("client_id", cfg.clientId);
|
|
184
184
|
form.set("client_secret", cfg.clientSecret);
|
|
185
185
|
}
|
|
186
|
+
else {
|
|
187
|
+
// client_secret_basic: resolveConfig refuses anything but the two, or unset
|
|
188
|
+
headers.Authorization = basicAuthorization(cfg.clientId, cfg.clientSecret);
|
|
189
|
+
}
|
|
186
190
|
try {
|
|
187
191
|
const data = await this.#httpPost(cfg.endpoint, form.toString(), headers);
|
|
188
192
|
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
@@ -232,8 +236,35 @@ export class AccessTokenValidator {
|
|
|
232
236
|
}
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
/**
|
|
240
|
+
* The `client_secret_basic` Authorization header value (RFC 6749 section 2.3.1): the client id and the secret
|
|
241
|
+
* are each form-urlencoded (Appendix B), joined with `:` and base64'd. The encoding is the same bytes as
|
|
242
|
+
* Python's `urllib.parse.quote_plus(s, safe="")`: each UTF-8 byte of `A-Z a-z 0-9 - . _ ~` stays as it is, a
|
|
243
|
+
* space becomes `+`, and every other byte is `%XX` with uppercase hex (so `:` is `%3A`, `%` is `%25`, `+` is
|
|
244
|
+
* `%2B` and `*` is `%2A`). Not `encodeURIComponent`, which leaves `! ' ( ) *` alone, nor `URLSearchParams`,
|
|
245
|
+
* which leaves `*` alone and encodes `~`. validation/SEMANTICS.md, Introspection.
|
|
246
|
+
*
|
|
247
|
+
* @internal
|
|
248
|
+
*/
|
|
249
|
+
export function basicAuthorization(clientId, clientSecret) {
|
|
250
|
+
return `Basic ${base64Encode(utf8Encode(`${formEncode(clientId)}:${formEncode(clientSecret)}`))}`;
|
|
251
|
+
}
|
|
252
|
+
const HEX = "0123456789ABCDEF";
|
|
253
|
+
function formEncode(value) {
|
|
254
|
+
let out = "";
|
|
255
|
+
for (const b of utf8Encode(value)) {
|
|
256
|
+
const unreserved = (b >= 0x30 && b <= 0x39) || // 0-9
|
|
257
|
+
(b >= 0x41 && b <= 0x5a) || // A-Z
|
|
258
|
+
(b >= 0x61 && b <= 0x7a) || // a-z
|
|
259
|
+
b === 0x2d || b === 0x2e || b === 0x5f || b === 0x7e; // - . _ ~
|
|
260
|
+
if (unreserved)
|
|
261
|
+
out += String.fromCharCode(b);
|
|
262
|
+
else if (b === 0x20)
|
|
263
|
+
out += "+";
|
|
264
|
+
else
|
|
265
|
+
out += "%" + HEX[b >> 4] + HEX[b & 0x0f];
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
237
268
|
}
|
|
238
269
|
function errMessage(exc) {
|
|
239
270
|
return exc instanceof Error ? exc.message : String(exc);
|