@idpartners/client-attestation-sdk 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,16 +3,17 @@
3
3
  * cross-language error-precedence order, plus optional RFC 7662 introspection.
4
4
  *
5
5
  * This is the validator counterpart to the client-side builder SDK, and mirrors the Python
6
- * `token_validator` package so every port reports the same verdict for the shared vectors.
6
+ * `client_attestation_sdk.token_validator` package so every port reports the same verdict for the shared vectors.
7
7
  */
8
8
  export { AccessTokenValidator } from "./validator.js";
9
9
  export type { HttpPost, Claims, ValidatorOptions } from "./validator.js";
10
10
  export type { HttpGet } from "./jwks.js";
11
- export { resolveConfig, DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
11
+ export type { Logger, LogAttributes, LogLevel } from "../logging.js";
12
+ export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
12
13
  export type { ValidatorConfig, ResolvedConfig, IntrospectionConfig, IntrospectionAuthMethod, } from "./config.js";
13
14
  export type { ValidationResult } from "./result.js";
14
15
  export type { ValidationError } from "./errors.js";
15
16
  export * as errors from "./errors.js";
16
17
  export { ProtectedResource, bearerToken } from "./resource.js";
17
18
  export type { ProtectedResourceMetadata, AuthDecision } from "./resource.js";
18
- export declare const VALIDATOR_VERSION = "0.2.0";
19
+ export declare const VALIDATOR_VERSION = "1.0.0";
@@ -3,10 +3,10 @@
3
3
  * cross-language error-precedence order, plus optional RFC 7662 introspection.
4
4
  *
5
5
  * This is the validator counterpart to the client-side builder SDK, and mirrors the Python
6
- * `token_validator` package so every port reports the same verdict for the shared vectors.
6
+ * `client_attestation_sdk.token_validator` package so every port reports the same verdict for the shared vectors.
7
7
  */
8
8
  export { AccessTokenValidator } from "./validator.js";
9
- export { resolveConfig, DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
9
+ export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
10
10
  export * as errors from "./errors.js";
11
11
  export { ProtectedResource, bearerToken } from "./resource.js";
12
- export const VALIDATOR_VERSION = "0.2.0";
12
+ export const VALIDATOR_VERSION = "1.0.0";
@@ -1,39 +1,2 @@
1
- /**
2
- * Resolves issuer signing keys — from a static JWKS or a `jwksUri` (fetched and cached, refreshed on an
3
- * unknown `kid` and when the cached set grows old).
4
- *
5
- * A `jwksUri` provider never fetches more than once per `minRefreshSeconds`, however many unknown `kid`
6
- * values it is shown, and concurrent misses share one fetch. Without that, a stream of tokens with made-up
7
- * `kid` values turns every request into a request to the issuer.
8
- */
9
- import { type CryptoKey, type JSONWebKeySet } from "jose";
10
- /** An imported verification key, as returned by jose's `importJWK`. */
11
- export type VerificationKey = CryptoKey | Uint8Array;
12
1
  /** Fetch a URL and return its body text. Injectable so tests can avoid real network I/O. */
13
2
  export type HttpGet = (url: string) => Promise<string>;
14
- export declare const MAX_JWKS_BYTES: number;
15
- export declare const DEFAULT_MIN_REFRESH_SECONDS = 30;
16
- export declare const DEFAULT_MAX_AGE_SECONDS = 3600;
17
- export interface JwksProviderOptions {
18
- jwks?: JSONWebKeySet;
19
- jwksUri?: string;
20
- httpGet?: HttpGet;
21
- /** Minimum time between fetches. Defaults to {@link DEFAULT_MIN_REFRESH_SECONDS}. */
22
- minRefreshSeconds?: number;
23
- /** Refresh a fetched set once it is this old. Defaults to {@link DEFAULT_MAX_AGE_SECONDS}. */
24
- maxAgeSeconds?: number;
25
- /** Milliseconds clock, injectable for tests. Defaults to `Date.now`. */
26
- clock?: () => number;
27
- }
28
- export declare class JwksProvider {
29
- #private;
30
- /** Why the last fetch failed, or `null` after a successful one. */
31
- lastError: string | null;
32
- constructor(opts: JwksProviderOptions);
33
- /**
34
- * Resolve the verification key for a header `kid` (and `alg`). Falls back to the single configured key
35
- * when the header carries no `kid`. Returns `undefined` when no key can be found; never rejects — a
36
- * failed fetch leaves its reason in {@link lastError}.
37
- */
38
- resolve(kid: string | undefined, alg?: string): Promise<VerificationKey | undefined>;
39
- }
@@ -8,16 +8,21 @@ var _a;
8
8
  * `kid` values turns every request into a request to the issuer.
9
9
  */
10
10
  import { importJWK } from "jose";
11
+ import { TransportError } from "../errors.js";
11
12
  import { request } from "../http.js";
13
+ import { emit, failureReason, hostOf, NOOP_LOGGER } from "../logging.js";
14
+ /** @internal */
12
15
  export const MAX_JWKS_BYTES = 512 * 1024;
16
+ /** @internal */
13
17
  export const DEFAULT_MIN_REFRESH_SECONDS = 30;
18
+ /** @internal */
14
19
  export const DEFAULT_MAX_AGE_SECONDS = 3600;
15
20
  const FETCH_TIMEOUT_MS = 10_000;
16
21
  const defaultGet = async (url) => {
17
22
  // the shared transport refuses redirects and caps the body at MAX_JWKS_BYTES while reading it
18
23
  const resp = await request(url, { method: "GET", timeoutMs: FETCH_TIMEOUT_MS, maxBytes: MAX_JWKS_BYTES });
19
24
  if (!resp.ok) {
20
- throw new Error(`GET ${url} failed: ${resp.status}`);
25
+ throw new TransportError(`GET ${url} failed: ${resp.status}`, "status", { status: resp.status });
21
26
  }
22
27
  return resp.body;
23
28
  };
@@ -34,6 +39,7 @@ function algorithmFor(jwk) {
34
39
  }
35
40
  return "RS256";
36
41
  }
42
+ /** @internal */
37
43
  export class JwksProvider {
38
44
  #static;
39
45
  #jwksUri;
@@ -41,6 +47,7 @@ export class JwksProvider {
41
47
  #minRefreshMs;
42
48
  #maxAgeMs;
43
49
  #clock;
50
+ #logger;
44
51
  /** kid -> key, plus the JWK's declared `alg` (a JWK that declares one is only used for that alg). */
45
52
  #keys = new Map();
46
53
  #loaded = null;
@@ -56,6 +63,7 @@ export class JwksProvider {
56
63
  this.#minRefreshMs = (opts.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS) * 1000;
57
64
  this.#maxAgeMs = (opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS) * 1000;
58
65
  this.#clock = opts.clock ?? Date.now;
66
+ this.#logger = opts.logger ?? NOOP_LOGGER;
59
67
  if (opts.jwks != null) {
60
68
  this.#loaded = _a.#parse(opts.jwks).then((keys) => {
61
69
  this.#keys = keys;
@@ -108,6 +116,7 @@ export class JwksProvider {
108
116
  }
109
117
  const now = this.#clock();
110
118
  if (this.#lastAttempt !== null && now - this.#lastAttempt < this.#minRefreshMs) {
119
+ emit(this.#logger, "debug", "jwks.refetch_throttled", { host: hostOf(this.#jwksUri) });
111
120
  return Promise.resolve();
112
121
  }
113
122
  this.#lastAttempt = now;
@@ -115,13 +124,19 @@ export class JwksProvider {
115
124
  try {
116
125
  const body = await this.#httpGet(this.#jwksUri);
117
126
  if (body.length > MAX_JWKS_BYTES) {
118
- throw new Error(`JWKS is larger than ${MAX_JWKS_BYTES} bytes`);
127
+ throw new TransportError(`JWKS is larger than ${MAX_JWKS_BYTES} bytes`, "too_large");
119
128
  }
120
129
  this.#keys = await _a.#parse(JSON.parse(body));
121
130
  this.#loadedAt = this.#clock();
122
131
  this.lastError = null;
132
+ emit(this.#logger, "debug", "jwks.fetched", { host: hostOf(this.#jwksUri), keys: this.#keys.size });
123
133
  }
124
134
  catch (exc) {
135
+ emit(this.#logger, "warn", "jwks.fetch_failed", {
136
+ host: hostOf(this.#jwksUri),
137
+ reason: failureReason(exc),
138
+ status: exc instanceof TransportError ? exc.status : undefined,
139
+ });
125
140
  // keep serving the keys already held
126
141
  this.lastError = `JWKS fetch failed: ${exc instanceof Error ? exc.message : String(exc)}`;
127
142
  }
@@ -35,8 +35,3 @@ export declare class ProtectedResource {
35
35
  challenge(error?: string, errorDescription?: string | null): string;
36
36
  authenticate(authorizationHeader?: string | null, requiredScopes?: string[]): Promise<AuthDecision>;
37
37
  }
38
- /**
39
- * Make a value safe inside a quoted-string header parameter: drop CR, LF and every other control
40
- * character (0x00-0x1F, 0x7F) so nothing can split or forge a header, then escape backslash and quote.
41
- */
42
- export declare function quote(value: string): string;
@@ -89,6 +89,8 @@ function httpError(error) {
89
89
  /**
90
90
  * Make a value safe inside a quoted-string header parameter: drop CR, LF and every other control
91
91
  * character (0x00-0x1F, 0x7F) so nothing can split or forge a header, then escape backslash and quote.
92
+ *
93
+ * @internal
92
94
  */
93
95
  export function quote(value) {
94
96
  // eslint-disable-next-line no-control-regex
@@ -15,5 +15,3 @@ export interface ValidationResult {
15
15
  /** The `exp` claim (integer epoch seconds) when present. */
16
16
  expiresAt: number | null;
17
17
  }
18
- export declare function success(claims: Record<string, unknown>, scopes: string[], audience: string[]): ValidationResult;
19
- export declare function failure(error: ValidationError, description?: string): ValidationResult;
@@ -1,3 +1,4 @@
1
+ /** @internal */
1
2
  export function success(claims, scopes, audience) {
2
3
  const sub = claims.sub;
3
4
  const exp = claims.exp;
@@ -12,6 +13,7 @@ export function success(claims, scopes, audience) {
12
13
  expiresAt: exp == null ? null : Number(exp),
13
14
  };
14
15
  }
16
+ /** @internal */
15
17
  export function failure(error, description) {
16
18
  return {
17
19
  valid: false,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The strict parsing every port applies to a token before trusting any of it (validation/SEMANTICS.md):
3
+ * compact serialisation, strict JSON objects, NumericDates and claim types. A failure throws
4
+ * {@link Malformed}; its message is generic and never quotes token bytes.
5
+ */
6
+ import { base64url } from "jose";
7
+ /**
8
+ * Deepest object/array nesting a header, payload or introspection response may have.
9
+ *
10
+ * @internal
11
+ */
12
+ export const MAX_DEPTH = 64;
13
+ const MAX_SAFE = Number.MAX_SAFE_INTEGER;
14
+ const SEGMENT = /^[A-Za-z0-9_-]*$/;
15
+ /**
16
+ * The input is not well-formed; the token is invalid.
17
+ *
18
+ * @internal
19
+ */
20
+ export class Malformed extends Error {
21
+ }
22
+ function decodeSegment(segment) {
23
+ if (!SEGMENT.test(segment) || segment.length % 4 === 1) {
24
+ throw new Malformed("malformed token");
25
+ }
26
+ const data = base64url.decode(segment);
27
+ if (base64url.encode(data) !== segment) {
28
+ throw new Malformed("malformed token"); // non-zero padding bits: not the canonical encoding
29
+ }
30
+ return data;
31
+ }
32
+ /**
33
+ * The header object and payload bytes of a compact JWS. The payload is returned undecoded: it is only
34
+ * parsed once the signature over it verifies.
35
+ *
36
+ * @internal
37
+ */
38
+ export function splitCompact(token) {
39
+ if (typeof token !== "string") {
40
+ throw new Malformed("malformed token");
41
+ }
42
+ const parts = token.split(".");
43
+ if (parts.length !== 3) {
44
+ throw new Malformed("malformed token");
45
+ }
46
+ const [headerBytes, payload] = parts.map(decodeSegment);
47
+ const header = parseObject(headerBytes, "header");
48
+ if ("crit" in header) {
49
+ // we implement no JWS extension, so RFC 7515 section 4.1.11 says reject any crit
50
+ throw new Malformed("header has crit");
51
+ }
52
+ return { header, payload };
53
+ }
54
+ /**
55
+ * `data` as a strict RFC 8259 JSON object: UTF-8 without a BOM, no duplicate member names at any depth,
56
+ * at most {@link MAX_DEPTH} levels deep. (`JSON.parse` already refuses NaN, trailing commas and the like.)
57
+ *
58
+ * @internal
59
+ */
60
+ export function parseObject(data, what = "payload") {
61
+ let text;
62
+ try {
63
+ // fatal: invalid UTF-8 is an error, not U+FFFD; ignoreBOM: keep a BOM so JSON.parse refuses it
64
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data);
65
+ }
66
+ catch {
67
+ throw new Malformed(`${what} is not UTF-8`);
68
+ }
69
+ let value;
70
+ try {
71
+ value = JSON.parse(text);
72
+ }
73
+ catch {
74
+ throw new Malformed(`${what} is not JSON`);
75
+ }
76
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
77
+ throw new Malformed(`${what} is not a JSON object`);
78
+ }
79
+ checkStructure(text, what);
80
+ return value;
81
+ }
82
+ /**
83
+ * Walk JSON text that `JSON.parse` has already accepted, refusing a duplicate member name in any object
84
+ * (names compared after unescaping) and nesting deeper than {@link MAX_DEPTH}. Iterative, so depth can't
85
+ * exhaust the stack.
86
+ */
87
+ function checkStructure(text, what) {
88
+ // one frame per open container: a Set of member names for an object, null for an array
89
+ const stack = [];
90
+ let expectName = false;
91
+ for (let i = 0; i < text.length; i++) {
92
+ const ch = text[i];
93
+ if (ch === '"') {
94
+ let j = i + 1;
95
+ while (text[j] !== '"') {
96
+ j += text[j] === "\\" ? 2 : 1;
97
+ }
98
+ const top = stack[stack.length - 1];
99
+ if (expectName && top) {
100
+ const name = JSON.parse(text.slice(i, j + 1));
101
+ if (top.has(name)) {
102
+ throw new Malformed(`${what} is not a strict JSON object (duplicate member name)`);
103
+ }
104
+ top.add(name);
105
+ expectName = false;
106
+ }
107
+ i = j;
108
+ }
109
+ else if (ch === "{" || ch === "[") {
110
+ stack.push(ch === "{" ? new Set() : null);
111
+ if (stack.length > MAX_DEPTH) {
112
+ throw new Malformed(`${what} is nested too deeply`);
113
+ }
114
+ expectName = ch === "{";
115
+ }
116
+ else if (ch === "}" || ch === "]") {
117
+ stack.pop();
118
+ }
119
+ else if (ch === ",") {
120
+ expectName = stack[stack.length - 1] != null;
121
+ }
122
+ }
123
+ }
124
+ /**
125
+ * A NumericDate claim as an integer (fractions truncate toward zero), `undefined` when absent or null.
126
+ * Anything but a finite JSON number within ±(2^53 − 1) is malformed.
127
+ *
128
+ * @internal
129
+ */
130
+ export function numericDate(claims, name) {
131
+ const value = claims[name];
132
+ if (value == null) {
133
+ return undefined;
134
+ }
135
+ if (typeof value !== "number" || !Number.isFinite(value)) {
136
+ throw new Malformed(`${name} is not a NumericDate`);
137
+ }
138
+ if (Math.abs(value) > MAX_SAFE) {
139
+ throw new Malformed(`${name} is out of range`);
140
+ }
141
+ return Math.trunc(value);
142
+ }
143
+ function isStringArray(value) {
144
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
145
+ }
146
+ /**
147
+ * `aud` as a list: absent or null is empty, a string is one entry, an array must hold only strings.
148
+ *
149
+ * @internal
150
+ */
151
+ export function audience(claims) {
152
+ const value = claims.aud;
153
+ if (value == null) {
154
+ return [];
155
+ }
156
+ if (typeof value === "string") {
157
+ return [value];
158
+ }
159
+ if (isStringArray(value)) {
160
+ return [...value];
161
+ }
162
+ throw new Malformed("aud is not a string or an array of strings");
163
+ }
164
+ /** Split on U+0020 only, as RFC 6749 section 3.3 says; runs of spaces make no empty scopes. */
165
+ function split(text) {
166
+ return text.split(" ").filter((s) => s.length > 0);
167
+ }
168
+ /**
169
+ * Granted scopes: from `scope` (a space-delimited string) when present, else `scp` (a space-delimited string,
170
+ * as Microsoft Entra ID issues it, or an array of strings). Either one present with any other shape is
171
+ * malformed.
172
+ *
173
+ * @internal
174
+ */
175
+ export function scopes(claims) {
176
+ const { scope, scp } = claims;
177
+ if (scope != null && typeof scope !== "string") {
178
+ throw new Malformed("scope is not a string");
179
+ }
180
+ if (scp != null && typeof scp !== "string" && !isStringArray(scp)) {
181
+ throw new Malformed("scp is not a string or an array of strings");
182
+ }
183
+ if (scope != null) {
184
+ return split(scope);
185
+ }
186
+ if (typeof scp === "string") {
187
+ return split(scp);
188
+ }
189
+ return scp != null ? [...scp] : [];
190
+ }
191
+ /**
192
+ * `sub`, `undefined` when absent or null; any other non-string is malformed.
193
+ *
194
+ * @internal
195
+ */
196
+ export function subject(claims) {
197
+ const value = claims.sub;
198
+ if (value != null && typeof value !== "string") {
199
+ throw new Malformed("sub is not a string");
200
+ }
201
+ return value ?? undefined;
202
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ResolvedConfig, type ValidatorConfig } from "./config.js";
2
+ import { type Logger } from "../logging.js";
2
3
  import { type HttpGet } from "./jwks.js";
3
4
  import { type ValidationResult } from "./result.js";
4
5
  /** Claims as decoded from a JWT payload (or an introspection response). */
@@ -11,6 +12,13 @@ export type HttpPost = (url: string, body: string, headers: Record<string, strin
11
12
  export interface ValidatorOptions {
12
13
  httpPost?: HttpPost;
13
14
  httpGet?: HttpGet;
15
+ /**
16
+ * Where to send log events (`token.validated`, `jwks.*`, `http.failed`; see docs/logging.md). Unset,
17
+ * nothing is logged. Tokens and client secrets are never logged.
18
+ */
19
+ logger?: Logger;
20
+ /** Epoch-millisecond clock that `exp` / `nbf` / `iat` are checked against. Defaults to `Date.now`. */
21
+ clock?: () => number;
14
22
  }
15
23
  /** Validates access tokens for a resource server. */
16
24
  export declare class AccessTokenValidator {