@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.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +142 -2
  3. package/dist/attester.d.ts +93 -0
  4. package/dist/attester.js +139 -0
  5. package/dist/builders.d.ts +92 -0
  6. package/dist/builders.js +284 -0
  7. package/dist/credential.d.ts +22 -0
  8. package/dist/credential.js +44 -0
  9. package/dist/errors.d.ts +29 -0
  10. package/dist/errors.js +28 -0
  11. package/dist/evidence.d.ts +55 -0
  12. package/dist/evidence.js +168 -0
  13. package/dist/http.d.ts +1 -0
  14. package/dist/http.js +183 -0
  15. package/dist/index.d.ts +26 -0
  16. package/dist/index.js +20 -0
  17. package/dist/issuance.d.ts +61 -0
  18. package/dist/issuance.js +117 -0
  19. package/dist/logging.d.ts +12 -0
  20. package/dist/logging.js +77 -0
  21. package/dist/runtime.d.ts +1 -0
  22. package/dist/runtime.js +64 -0
  23. package/dist/signer.d.ts +32 -0
  24. package/dist/signer.js +121 -0
  25. package/dist/signing-key-pair.d.ts +44 -0
  26. package/dist/signing-key-pair.js +130 -0
  27. package/dist/svid-source.d.ts +41 -0
  28. package/dist/svid-source.js +77 -0
  29. package/dist/token-source.d.ts +146 -0
  30. package/dist/token-source.js +510 -0
  31. package/dist/validator/config.d.ts +61 -0
  32. package/dist/validator/config.js +61 -0
  33. package/dist/validator/errors.d.ts +20 -0
  34. package/dist/validator/errors.js +18 -0
  35. package/dist/validator/index.d.ts +19 -0
  36. package/dist/validator/index.js +12 -0
  37. package/dist/validator/jwks.d.ts +2 -0
  38. package/dist/validator/jwks.js +150 -0
  39. package/dist/validator/resource.d.ts +37 -0
  40. package/dist/validator/resource.js +98 -0
  41. package/dist/validator/result.d.ts +17 -0
  42. package/dist/validator/result.js +28 -0
  43. package/dist/validator/strict.d.ts +1 -0
  44. package/dist/validator/strict.js +202 -0
  45. package/dist/validator/validator.d.ts +41 -0
  46. package/dist/validator/validator.js +240 -0
  47. package/package.json +56 -4
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resource-server access-token validation: JWT signature + scope/audience checks in a fixed,
3
+ * cross-language error-precedence order, plus optional RFC 7662 introspection.
4
+ *
5
+ * This is the validator counterpart to the client-side builder SDK, and mirrors the Python
6
+ * `client_attestation_sdk.token_validator` package so every port reports the same verdict for the shared vectors.
7
+ */
8
+ export { AccessTokenValidator } from "./validator.js";
9
+ export type { HttpPost, Claims, ValidatorOptions } from "./validator.js";
10
+ export type { HttpGet } from "./jwks.js";
11
+ export type { Logger, LogAttributes, LogLevel } from "../logging.js";
12
+ export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
13
+ export type { ValidatorConfig, ResolvedConfig, IntrospectionConfig, IntrospectionAuthMethod, } from "./config.js";
14
+ export type { ValidationResult } from "./result.js";
15
+ export type { ValidationError } from "./errors.js";
16
+ export * as errors from "./errors.js";
17
+ export { ProtectedResource, bearerToken } from "./resource.js";
18
+ export type { ProtectedResourceMetadata, AuthDecision } from "./resource.js";
19
+ export declare const VALIDATOR_VERSION = "1.0.0";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Resource-server access-token validation: JWT signature + scope/audience checks in a fixed,
3
+ * cross-language error-precedence order, plus optional RFC 7662 introspection.
4
+ *
5
+ * This is the validator counterpart to the client-side builder SDK, and mirrors the Python
6
+ * `client_attestation_sdk.token_validator` package so every port reports the same verdict for the shared vectors.
7
+ */
8
+ export { AccessTokenValidator } from "./validator.js";
9
+ export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
10
+ export * as errors from "./errors.js";
11
+ export { ProtectedResource, bearerToken } from "./resource.js";
12
+ export const VALIDATOR_VERSION = "1.0.0";
@@ -0,0 +1,2 @@
1
+ /** Fetch a URL and return its body text. Injectable so tests can avoid real network I/O. */
2
+ export type HttpGet = (url: string) => Promise<string>;
@@ -0,0 +1,150 @@
1
+ var _a;
2
+ /**
3
+ * Resolves issuer signing keys — from a static JWKS or a `jwksUri` (fetched and cached, refreshed on an
4
+ * unknown `kid` and when the cached set grows old).
5
+ *
6
+ * A `jwksUri` provider never fetches more than once per `minRefreshSeconds`, however many unknown `kid`
7
+ * values it is shown, and concurrent misses share one fetch. Without that, a stream of tokens with made-up
8
+ * `kid` values turns every request into a request to the issuer.
9
+ */
10
+ import { importJWK } from "jose";
11
+ import { TransportError } from "../errors.js";
12
+ import { request } from "../http.js";
13
+ import { emit, failureReason, hostOf, NOOP_LOGGER } from "../logging.js";
14
+ /** @internal */
15
+ export const MAX_JWKS_BYTES = 512 * 1024;
16
+ /** @internal */
17
+ export const DEFAULT_MIN_REFRESH_SECONDS = 30;
18
+ /** @internal */
19
+ export const DEFAULT_MAX_AGE_SECONDS = 3600;
20
+ const FETCH_TIMEOUT_MS = 10_000;
21
+ const defaultGet = async (url) => {
22
+ // the shared transport refuses redirects and caps the body at MAX_JWKS_BYTES while reading it
23
+ const resp = await request(url, { method: "GET", timeoutMs: FETCH_TIMEOUT_MS, maxBytes: MAX_JWKS_BYTES });
24
+ if (!resp.ok) {
25
+ throw new TransportError(`GET ${url} failed: ${resp.status}`, "status", { status: resp.status });
26
+ }
27
+ return resp.body;
28
+ };
29
+ /** Best-effort JWS `alg` for a JWK that omits `alg`, from its key type / curve. */
30
+ function algorithmFor(jwk) {
31
+ if (jwk.kty === "EC") {
32
+ return { "P-256": "ES256", "P-384": "ES384", "P-521": "ES512" }[jwk.crv ?? ""] ?? "ES256";
33
+ }
34
+ if (jwk.kty === "RSA") {
35
+ return "RS256";
36
+ }
37
+ if (jwk.kty === "OKP") {
38
+ return "EdDSA";
39
+ }
40
+ return "RS256";
41
+ }
42
+ /** @internal */
43
+ export class JwksProvider {
44
+ #static;
45
+ #jwksUri;
46
+ #httpGet;
47
+ #minRefreshMs;
48
+ #maxAgeMs;
49
+ #clock;
50
+ #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();
53
+ #loaded = null;
54
+ #inFlight = null;
55
+ #loadedAt = null;
56
+ #lastAttempt = null;
57
+ /** Why the last fetch failed, or `null` after a successful one. */
58
+ lastError = null;
59
+ constructor(opts) {
60
+ this.#static = opts.jwks != null;
61
+ this.#jwksUri = opts.jwksUri;
62
+ this.#httpGet = opts.httpGet ?? defaultGet;
63
+ this.#minRefreshMs = (opts.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS) * 1000;
64
+ this.#maxAgeMs = (opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS) * 1000;
65
+ this.#clock = opts.clock ?? Date.now;
66
+ this.#logger = opts.logger ?? NOOP_LOGGER;
67
+ if (opts.jwks != null) {
68
+ this.#loaded = _a.#parse(opts.jwks).then((keys) => {
69
+ this.#keys = keys;
70
+ });
71
+ }
72
+ }
73
+ static async #parse(jwks) {
74
+ const keys = new Map();
75
+ for (const jwk of jwks.keys ?? []) {
76
+ const alg = jwk.alg ?? algorithmFor(jwk);
77
+ try {
78
+ keys.set(jwk.kid, { key: await importJWK(jwk, alg), alg: jwk.alg });
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ }
84
+ return keys;
85
+ }
86
+ /**
87
+ * 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}.
90
+ */
91
+ async resolve(kid, alg) {
92
+ if (this.#loaded) {
93
+ await this.#loaded;
94
+ }
95
+ if (!this.#static && this.#jwksUri && (this.#lookup(kid) === undefined || this.#stale())) {
96
+ await this.#refresh();
97
+ }
98
+ const entry = this.#lookup(kid);
99
+ if (entry === undefined || (alg != null && entry.alg != null && entry.alg !== alg)) {
100
+ return undefined;
101
+ }
102
+ return entry.key;
103
+ }
104
+ #lookup(kid) {
105
+ if (kid != null) {
106
+ return this.#keys.get(kid);
107
+ }
108
+ return this.#keys.size === 1 ? this.#keys.values().next().value : undefined;
109
+ }
110
+ #stale() {
111
+ return this.#loadedAt === null || this.#clock() - this.#loadedAt >= this.#maxAgeMs;
112
+ }
113
+ #refresh() {
114
+ if (this.#inFlight) {
115
+ return this.#inFlight;
116
+ }
117
+ const now = this.#clock();
118
+ if (this.#lastAttempt !== null && now - this.#lastAttempt < this.#minRefreshMs) {
119
+ emit(this.#logger, "debug", "jwks.refetch_throttled", { host: hostOf(this.#jwksUri) });
120
+ return Promise.resolve();
121
+ }
122
+ this.#lastAttempt = now;
123
+ this.#inFlight = (async () => {
124
+ try {
125
+ const body = await this.#httpGet(this.#jwksUri);
126
+ if (body.length > MAX_JWKS_BYTES) {
127
+ throw new TransportError(`JWKS is larger than ${MAX_JWKS_BYTES} bytes`, "too_large");
128
+ }
129
+ this.#keys = await _a.#parse(JSON.parse(body));
130
+ this.#loadedAt = this.#clock();
131
+ this.lastError = null;
132
+ emit(this.#logger, "debug", "jwks.fetched", { host: hostOf(this.#jwksUri), keys: this.#keys.size });
133
+ }
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
+ });
140
+ // keep serving the keys already held
141
+ this.lastError = `JWKS fetch failed: ${exc instanceof Error ? exc.message : String(exc)}`;
142
+ }
143
+ finally {
144
+ this.#inFlight = null;
145
+ }
146
+ })();
147
+ return this.#inFlight;
148
+ }
149
+ }
150
+ _a = JwksProvider;
@@ -0,0 +1,37 @@
1
+ import type { ValidationResult } from "./result.js";
2
+ import type { AccessTokenValidator } from "./validator.js";
3
+ /** Extract the token from an `Authorization: Bearer <token>` header value, or null. */
4
+ export declare function bearerToken(authorizationHeader?: string | null): string | null;
5
+ /** RFC 9728 protected-resource metadata document. */
6
+ export interface ProtectedResourceMetadata {
7
+ resource: string;
8
+ authorization_servers: string[];
9
+ bearer_methods_supported: string[];
10
+ scopes_supported?: string[];
11
+ }
12
+ /** The outcome of guarding a request. */
13
+ export interface AuthDecision {
14
+ authorized: boolean;
15
+ result?: ValidationResult;
16
+ status: number;
17
+ wwwAuthenticate?: string;
18
+ error?: string;
19
+ errorDescription?: string | null;
20
+ }
21
+ /**
22
+ * An OAuth-protected resource server. The `validator` must accept `resource` as an audience — that binds
23
+ * incoming tokens to this resource (RFC 8707), which MCP servers MUST enforce. The constructor refuses one
24
+ * that doesn't.
25
+ */
26
+ export declare class ProtectedResource {
27
+ #private;
28
+ readonly resource: string;
29
+ readonly authorizationServers: string[];
30
+ readonly scopesSupported: string[];
31
+ constructor(resource: string, authorizationServers: string[], validator: AccessTokenValidator, scopesSupported?: string[]);
32
+ metadata(): ProtectedResourceMetadata;
33
+ metadataPath(): string;
34
+ metadataUrl(): string;
35
+ challenge(error?: string, errorDescription?: string | null): string;
36
+ authenticate(authorizationHeader?: string | null, requiredScopes?: string[]): Promise<AuthDecision>;
37
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * OAuth 2.0/2.1 protected-resource conventions on top of {@link AccessTokenValidator}.
3
+ *
4
+ * A {@link ProtectedResource} is the resource-server side of any OAuth-protected HTTP service — an MCP
5
+ * server (OAuth 2.1 resource server per the MCP authorization spec), an A2A agent, or a plain REST API. It
6
+ * adds what the validator itself doesn't: RFC 9728 Protected Resource Metadata
7
+ * (`/.well-known/oauth-protected-resource`), RFC 6750 bearer extraction + `WWW-Authenticate` challenges, and
8
+ * a request guard that binds the token audience to this resource (RFC 8707). Protocol-neutral — not MCP-only.
9
+ */
10
+ import * as errors from "./errors.js";
11
+ const WELL_KNOWN = "/.well-known/oauth-protected-resource";
12
+ /** Extract the token from an `Authorization: Bearer <token>` header value, or null. */
13
+ export function bearerToken(authorizationHeader) {
14
+ if (!authorizationHeader)
15
+ return null;
16
+ const match = /^\s*Bearer\s+(.+?)\s*$/i.exec(authorizationHeader);
17
+ return match ? match[1] : null;
18
+ }
19
+ /**
20
+ * An OAuth-protected resource server. The `validator` must accept `resource` as an audience — that binds
21
+ * incoming tokens to this resource (RFC 8707), which MCP servers MUST enforce. The constructor refuses one
22
+ * that doesn't.
23
+ */
24
+ export class ProtectedResource {
25
+ resource;
26
+ authorizationServers;
27
+ scopesSupported;
28
+ #validator;
29
+ constructor(resource, authorizationServers, validator, scopesSupported = []) {
30
+ if (!validator.config.audiences.includes(resource)) {
31
+ throw new Error(`the validator does not accept this resource (${resource}) as an audience`);
32
+ }
33
+ this.resource = resource;
34
+ this.authorizationServers = authorizationServers;
35
+ this.#validator = validator;
36
+ this.scopesSupported = scopesSupported;
37
+ }
38
+ metadata() {
39
+ const md = {
40
+ resource: this.resource,
41
+ authorization_servers: this.authorizationServers,
42
+ bearer_methods_supported: ["header"],
43
+ };
44
+ if (this.scopesSupported.length > 0)
45
+ md.scopes_supported = this.scopesSupported;
46
+ return md;
47
+ }
48
+ metadataPath() {
49
+ const path = new URL(this.resource).pathname;
50
+ return path && path !== "/" ? WELL_KNOWN + path : WELL_KNOWN;
51
+ }
52
+ metadataUrl() {
53
+ const u = new URL(this.resource);
54
+ return `${u.protocol}//${u.host}${this.metadataPath()}`;
55
+ }
56
+ challenge(error, errorDescription) {
57
+ const params = [];
58
+ if (error) {
59
+ params.push(`error="${quote(error)}"`);
60
+ if (errorDescription)
61
+ params.push(`error_description="${quote(errorDescription)}"`);
62
+ }
63
+ params.push(`resource_metadata="${quote(this.metadataUrl())}"`);
64
+ return "Bearer " + params.join(", ");
65
+ }
66
+ async authenticate(authorizationHeader, requiredScopes) {
67
+ const token = bearerToken(authorizationHeader);
68
+ if (token == null) {
69
+ return { authorized: false, status: 401, wwwAuthenticate: this.challenge(), errorDescription: "authentication required" };
70
+ }
71
+ const result = await this.#validator.validate(token, requiredScopes);
72
+ if (result.valid) {
73
+ return { authorized: true, result, status: 200 };
74
+ }
75
+ const [status, oauthError] = httpError(result.error);
76
+ return {
77
+ authorized: false,
78
+ result,
79
+ status,
80
+ error: oauthError,
81
+ errorDescription: result.errorDescription,
82
+ wwwAuthenticate: this.challenge(oauthError, result.errorDescription),
83
+ };
84
+ }
85
+ }
86
+ function httpError(error) {
87
+ return error === errors.INSUFFICIENT_SCOPE ? [403, "insufficient_scope"] : [401, "invalid_token"];
88
+ }
89
+ /**
90
+ * Make a value safe inside a quoted-string header parameter: drop CR, LF and every other control
91
+ * character (0x00-0x1F, 0x7F) so nothing can split or forge a header, then escape backslash and quote.
92
+ *
93
+ * @internal
94
+ */
95
+ export function quote(value) {
96
+ // eslint-disable-next-line no-control-regex
97
+ return value.replace(/[\x00-\x1f\x7f]/g, "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
98
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The outcome of validating an access token: either a valid token (with its subject, granted scopes,
3
+ * audience and claims) or a failure carrying a stable `error` code (see {@link ./errors}).
4
+ */
5
+ import type { ValidationError } from "./errors.js";
6
+ export interface ValidationResult {
7
+ valid: boolean;
8
+ /** Stable error code when `valid` is false; `null` when valid. */
9
+ error: ValidationError | null;
10
+ errorDescription: string | null;
11
+ subject: string | null;
12
+ scopes: string[];
13
+ audience: string[];
14
+ claims: Record<string, unknown>;
15
+ /** The `exp` claim (integer epoch seconds) when present. */
16
+ expiresAt: number | null;
17
+ }
@@ -0,0 +1,28 @@
1
+ /** @internal */
2
+ export function success(claims, scopes, audience) {
3
+ const sub = claims.sub;
4
+ const exp = claims.exp;
5
+ return {
6
+ valid: true,
7
+ error: null,
8
+ errorDescription: null,
9
+ subject: typeof sub === "string" ? sub : null,
10
+ scopes,
11
+ audience,
12
+ claims,
13
+ expiresAt: exp == null ? null : Number(exp),
14
+ };
15
+ }
16
+ /** @internal */
17
+ export function failure(error, description) {
18
+ return {
19
+ valid: false,
20
+ error,
21
+ errorDescription: description ?? null,
22
+ subject: null,
23
+ scopes: [],
24
+ audience: [],
25
+ claims: {},
26
+ expiresAt: null,
27
+ };
28
+ }
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ import { type ResolvedConfig, type ValidatorConfig } from "./config.js";
2
+ import { type Logger } from "../logging.js";
3
+ import { type HttpGet } from "./jwks.js";
4
+ import { type ValidationResult } from "./result.js";
5
+ /** Claims as decoded from a JWT payload (or an introspection response). */
6
+ export type Claims = Record<string, unknown>;
7
+ /**
8
+ * Inject a POST transport (mirrors Python's `http_post`). Resolves to the parsed JSON response body.
9
+ */
10
+ export type HttpPost = (url: string, body: string, headers: Record<string, string>) => Promise<Claims>;
11
+ /** Optional injectable transports, primarily so tests can mock network I/O. */
12
+ export interface ValidatorOptions {
13
+ httpPost?: HttpPost;
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;
22
+ }
23
+ /** Validates access tokens for a resource server. */
24
+ export declare class AccessTokenValidator {
25
+ #private;
26
+ readonly config: ResolvedConfig;
27
+ constructor(config: ValidatorConfig, opts?: ValidatorOptions);
28
+ /**
29
+ * Locally validate a JWT access token. Returns the first failure in the fixed contract order, or a
30
+ * success result exposing subject / scopes / audience / claims.
31
+ */
32
+ validate(token: string, requiredScopes?: string[]): Promise<ValidationResult>;
33
+ /**
34
+ * RFC 7662: POST the token to the AS introspection endpoint and return the parsed response.
35
+ */
36
+ introspect(token: string, tokenTypeHint?: string): Promise<Claims>;
37
+ /**
38
+ * Introspect the token and enforce `active` plus scope/audience from the response.
39
+ */
40
+ validateActive(token: string, requiredScopes?: string[]): Promise<ValidationResult>;
41
+ }