@idpartners/client-attestation-sdk 1.0.0 → 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.
@@ -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
- /** kid -> key, plus the JWK's declared `alg` (a JWK that declares one is only used for that alg). */
52
- #keys = new Map();
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 ?? Date.now;
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 = new Map();
75
- for (const jwk of jwks.keys ?? []) {
76
- const alg = jwk.alg ?? algorithmFor(jwk);
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
- keys.set(jwk.kid, { key: await importJWK(jwk, alg), alg: jwk.alg });
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; never rejects — a
89
- * failed fetch leaves its reason in {@link lastError}.
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 && (this.#lookup(kid) === undefined || this.#stale())) {
96
- await this.#refresh();
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 || (alg != null && entry.alg != null && entry.alg !== alg)) {
130
+ const entry = this.#lookup(kid, alg);
131
+ if (entry === undefined) {
100
132
  return undefined;
101
133
  }
102
- return entry.key;
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
- #lookup(kid) {
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.get(kid);
147
+ return kid === "" ? undefined : this.#keys.find((entry) => entry.kid === kid && allowed(entry));
107
148
  }
108
- return this.#keys.size === 1 ? this.#keys.values().next().value : undefined;
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
- #refresh() {
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.size });
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", {
@@ -1,4 +1,4 @@
1
- import type { ValidationResult } from "./result.js";
1
+ import { type ValidationResult } from "./result.js";
2
2
  import type { AccessTokenValidator } from "./validator.js";
3
3
  /** Extract the token from an `Authorization: Bearer <token>` header value, or null. */
4
4
  export declare function bearerToken(authorizationHeader?: string | null): string | null;
@@ -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
  }
@@ -8,13 +8,48 @@
8
8
  * a request guard that binds the token audience to this resource (RFC 8707). Protocol-neutral — not MCP-only.
9
9
  */
10
10
  import * as errors from "./errors.js";
11
+ import { failure } from "./result.js";
11
12
  const WELL_KNOWN = "/.well-known/oauth-protected-resource";
13
+ const BEARER = "bearer";
12
14
  /** Extract the token from an `Authorization: Bearer <token>` header value, or null. */
13
15
  export function bearerToken(authorizationHeader) {
14
16
  if (!authorizationHeader)
15
17
  return null;
16
- const match = /^\s*Bearer\s+(.+?)\s*$/i.exec(authorizationHeader);
17
- return match ? match[1] : null;
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;
18
53
  }
19
54
  /**
20
55
  * An OAuth-protected resource server. The `validator` must accept `resource` as an audience — that binds
@@ -64,11 +99,30 @@ export class ProtectedResource {
64
99
  return "Bearer " + params.join(", ");
65
100
  }
66
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) {
67
115
  const token = bearerToken(authorizationHeader);
68
116
  if (token == null) {
69
117
  return { authorized: false, status: 401, wwwAuthenticate: this.challenge(), errorDescription: "authentication required" };
70
118
  }
71
- const result = await this.#validator.validate(token, requiredScopes);
119
+ let result = await validate(token);
120
+ if (result.valid && !result.audience.includes(this.resource)) {
121
+ result = failure(errors.INVALID_AUDIENCE, "token audience is not this resource");
122
+ }
123
+ if (result.valid && result.claims.cnf != null) {
124
+ result = failure(errors.INVALID_TOKEN, "sender-constrained token requires proof verification");
125
+ }
72
126
  if (result.valid) {
73
127
  return { authorized: true, result, status: 200 };
74
128
  }
@@ -19,7 +19,12 @@ const SEGMENT = /^[A-Za-z0-9_-]*$/;
19
19
  */
20
20
  export class Malformed extends Error {
21
21
  }
22
- function decodeSegment(segment) {
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) : null;
96
- if (key == null) {
97
- return failure(errors.KEY_NOT_FOUND, this.#jwks.lastError ?? "no signing key for this kid");
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
- const authMethod = cfg.authMethod ?? "client_secret_basic";
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
- function base64(value) {
236
- return base64Encode(utf8Encode(value));
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idpartners/client-attestation-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Client-side builder SDK for OAuth Attestation-Based Client Authentication",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",