@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.
@@ -6,83 +6,126 @@
6
6
  * then scope — returning the first failure's stable error code. That order is part of the cross-language contract so every port reports the same verdict.
7
7
  * `validateActive` instead uses RFC 7662 introspection.
8
8
  */
9
- import { compactVerify, decodeProtectedHeader } from "jose";
9
+ import { compactVerify } from "jose";
10
+ import { base64Encode, utf8Encode } from "../runtime.js";
10
11
  import * as errors from "./errors.js";
11
- import { normaliseType, resolveConfig, } from "./config.js";
12
- import { jsonOk, request } from "../http.js";
12
+ import * as strict from "./strict.js";
13
+ import { isForbiddenAlgorithm, normaliseType, resolveConfig, } from "./config.js";
14
+ import { TransportError } from "../errors.js";
15
+ import { request } from "../http.js";
16
+ import { emit, failureReason, NOOP_LOGGER, urlParts } from "../logging.js";
13
17
  import { JwksProvider } from "./jwks.js";
14
18
  import { failure, success } from "./result.js";
19
+ /**
20
+ * An introspection response larger than this is refused.
21
+ *
22
+ * @internal
23
+ */
24
+ export const MAX_INTROSPECTION_BYTES = 1024 * 1024;
15
25
  const defaultPost = async (url, body, headers) => {
16
- // the shared transport never follows a redirect: it would carry the resource server's credentials
17
- const resp = await request(url, { method: "POST", body, headers, timeoutMs: 10_000 });
26
+ // the shared transport never follows a redirect (it would carry the resource server's credentials) and
27
+ // refuses a body over the cap
28
+ const resp = await request(url, {
29
+ method: "POST",
30
+ body,
31
+ headers,
32
+ timeoutMs: 10_000,
33
+ maxBytes: MAX_INTROSPECTION_BYTES,
34
+ });
18
35
  if (!resp.ok) {
19
- throw new Error(`introspection failed: ${resp.status}`);
36
+ throw new TransportError(`introspection failed: ${resp.status}`, "status", { status: resp.status });
37
+ }
38
+ try {
39
+ return strict.parseObject(utf8Encode(resp.body), "introspection response");
40
+ }
41
+ catch (err) {
42
+ throw new TransportError(`introspection endpoint returned ${errMessage(err)}`, "json", {
43
+ status: resp.status,
44
+ });
20
45
  }
21
- return jsonOk(resp);
22
46
  };
23
47
  /** Validates access tokens for a resource server. */
24
48
  export class AccessTokenValidator {
25
49
  config;
26
50
  #jwks;
27
51
  #httpPost;
52
+ #logger;
53
+ #clock;
28
54
  constructor(config, opts = {}) {
29
55
  this.config = resolveConfig(config);
30
56
  this.#jwks = new JwksProvider({
31
57
  jwks: this.config.jwks,
32
58
  jwksUri: this.config.jwksUri,
33
59
  httpGet: opts.httpGet,
60
+ logger: opts.logger,
34
61
  });
35
62
  this.#httpPost = opts.httpPost ?? defaultPost;
63
+ this.#logger = opts.logger ?? NOOP_LOGGER;
64
+ this.#clock = opts.clock ?? Date.now;
36
65
  }
37
66
  /**
38
67
  * Locally validate a JWT access token. Returns the first failure in the fixed contract order, or a
39
68
  * success result exposing subject / scopes / audience / claims.
40
69
  */
41
70
  async validate(token, requiredScopes) {
71
+ return this.#verdict("jwt", await this.#validate(token, requiredScopes));
72
+ }
73
+ /** Log a verdict (its error code only, never the token) and return it. */
74
+ #verdict(mode, result) {
75
+ emit(this.#logger, result.valid ? "debug" : "info", "token.validated", { result: result.valid ? "valid" : result.error, mode });
76
+ return result;
77
+ }
78
+ async #validate(token, requiredScopes) {
42
79
  const required = requiredScopes ?? this.config.requiredScopes;
43
- let header;
80
+ let parts;
44
81
  try {
45
- header = decodeProtectedHeader(token);
82
+ parts = strict.splitCompact(token);
46
83
  }
47
- catch (exc) {
48
- return failure(errors.INVALID_TOKEN, `malformed token: ${errMessage(exc)}`);
84
+ catch {
85
+ // never echo token data to the caller: the description lands in WWW-Authenticate
86
+ return failure(errors.INVALID_TOKEN, "malformed token");
49
87
  }
88
+ const { header } = parts;
50
89
  const alg = header.alg;
51
- if (alg == null || !this.config.acceptedAlgorithms.includes(alg)) {
90
+ if (typeof alg !== "string" || isForbiddenAlgorithm(alg) || !this.config.acceptedAlgorithms.includes(alg)) {
52
91
  // never echo the unverified header alg back to the caller (it lands in WWW-Authenticate)
53
92
  return failure(errors.UNSUPPORTED_ALGORITHM, "algorithm not accepted");
54
93
  }
55
- const key = await this.#jwks.resolve(header.kid, alg);
94
+ const kid = header.kid;
95
+ const key = kid == null || typeof kid === "string" ? await this.#jwks.resolve(kid ?? undefined, alg) : null;
56
96
  if (key == null) {
57
97
  return failure(errors.KEY_NOT_FOUND, this.#jwks.lastError ?? "no signing key for this kid");
58
98
  }
99
+ try {
100
+ await compactVerify(token, key, { algorithms: [alg] });
101
+ }
102
+ catch {
103
+ return failure(errors.INVALID_SIGNATURE, "signature verification failed");
104
+ }
59
105
  let claims;
60
106
  try {
61
- const { payload } = await compactVerify(token, key, { algorithms: [alg] });
62
- claims = JSON.parse(new TextDecoder().decode(payload));
107
+ claims = strict.parseObject(parts.payload);
63
108
  }
64
109
  catch (exc) {
65
- return failure(errors.INVALID_SIGNATURE, errMessage(exc));
66
- }
67
- if (claims === null || typeof claims !== "object" || Array.isArray(claims)) {
68
- return failure(errors.INVALID_TOKEN, "payload is not a JSON object");
110
+ return failure(errors.INVALID_TOKEN, errMessage(exc));
69
111
  }
70
112
  const types = this.config.acceptedTokenTypes;
71
- if (header.typ != null && types !== null && !types.includes(normaliseType(String(header.typ)))) {
113
+ const typ = header.typ;
114
+ if (typ != null && types !== null && (typeof typ !== "string" || !types.includes(normaliseType(typ)))) {
72
115
  return failure(errors.INVALID_TOKEN_TYPE, "token is not a JWT access token");
73
116
  }
74
117
  if (claims.iss !== this.config.issuer) {
75
118
  return failure(errors.INVALID_ISSUER, "unexpected issuer");
76
119
  }
77
- const now = Math.floor(Date.now() / 1000);
120
+ const now = Math.floor(this.#clock() / 1000);
78
121
  const leeway = this.config.leewaySeconds;
79
122
  let exp;
80
123
  let nbf;
81
124
  let iat;
82
125
  try {
83
- exp = numericDate(claims, "exp");
84
- nbf = numericDate(claims, "nbf");
85
- iat = numericDate(claims, "iat");
126
+ exp = strict.numericDate(claims, "exp");
127
+ nbf = strict.numericDate(claims, "nbf");
128
+ iat = strict.numericDate(claims, "iat");
86
129
  }
87
130
  catch (exc) {
88
131
  return failure(errors.INVALID_TOKEN, errMessage(exc));
@@ -99,16 +142,22 @@ export class AccessTokenValidator {
99
142
  if (iat !== undefined && now + leeway < iat) {
100
143
  return failure(errors.ISSUED_IN_FUTURE, "token was issued in the future");
101
144
  }
102
- const audience = asList(claims.aud);
103
- if (!this.config.audiences.some((a) => audience.includes(a))) {
104
- return failure(errors.INVALID_AUDIENCE, "token audience is not accepted");
145
+ try {
146
+ const audience = strict.audience(claims);
147
+ if (!this.config.audiences.some((a) => audience.includes(a))) {
148
+ return failure(errors.INVALID_AUDIENCE, "token audience is not accepted");
149
+ }
150
+ const granted = strict.scopes(claims);
151
+ const missing = required.filter((s) => !granted.includes(s));
152
+ if (missing.length > 0) {
153
+ return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
154
+ }
155
+ strict.subject(claims);
156
+ return success(claims, granted, audience);
105
157
  }
106
- const granted = scopesOf(claims);
107
- const missing = required.filter((s) => !granted.includes(s));
108
- if (missing.length > 0) {
109
- return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
158
+ catch (exc) {
159
+ return failure(errors.INVALID_TOKEN, errMessage(exc));
110
160
  }
111
- return success(claims, granted, audience);
112
161
  }
113
162
  /**
114
163
  * RFC 7662: POST the token to the AS introspection endpoint and return the parsed response.
@@ -134,63 +183,57 @@ export class AccessTokenValidator {
134
183
  form.set("client_id", cfg.clientId);
135
184
  form.set("client_secret", cfg.clientSecret);
136
185
  }
137
- return this.#httpPost(cfg.endpoint, form.toString(), headers);
186
+ try {
187
+ const data = await this.#httpPost(cfg.endpoint, form.toString(), headers);
188
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
189
+ throw new TransportError("introspection endpoint returned JSON that is not an object", "json");
190
+ }
191
+ return data;
192
+ }
193
+ catch (err) {
194
+ emit(this.#logger, "warn", "http.failed", {
195
+ step: "introspection",
196
+ method: "POST",
197
+ ...urlParts(cfg.endpoint),
198
+ status: err instanceof TransportError ? err.status : undefined,
199
+ reason: failureReason(err),
200
+ });
201
+ throw err;
202
+ }
138
203
  }
139
204
  /**
140
205
  * Introspect the token and enforce `active` plus scope/audience from the response.
141
206
  */
142
207
  async validateActive(token, requiredScopes) {
208
+ return this.#verdict("introspection", await this.#validateActive(token, requiredScopes));
209
+ }
210
+ async #validateActive(token, requiredScopes) {
143
211
  const data = await this.introspect(token);
144
- if (!data.active) {
212
+ // RFC 7662 section 2.2: active is a boolean, so only JSON true counts
213
+ if (data.active !== true) {
145
214
  return failure(errors.INACTIVE, "token is not active");
146
215
  }
147
216
  const required = requiredScopes ?? this.config.requiredScopes;
148
- const granted = scopesOf(data);
149
- const missing = required.filter((s) => !granted.includes(s));
150
- if (missing.length > 0) {
151
- return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
152
- }
153
- const audience = asList(data.aud);
154
- if (this.config.audiences.length > 0 &&
155
- audience.length > 0 &&
156
- !this.config.audiences.some((a) => audience.includes(a))) {
157
- return failure(errors.INVALID_AUDIENCE, "token audience is not accepted");
158
- }
159
- return success(data, granted, audience);
160
- }
161
- }
162
- /** A NumericDate claim as an integer, `undefined` when absent. Anything but a finite number is malformed. */
163
- function numericDate(claims, name) {
164
- const value = claims[name];
165
- if (value == null) {
166
- return undefined;
167
- }
168
- if (typeof value !== "number" || !Number.isFinite(value)) {
169
- throw new Error(`${name} is not a NumericDate`);
170
- }
171
- return Math.trunc(value);
172
- }
173
- /** Normalize an `aud` claim (string | string[] | absent) to a list of strings. */
174
- function asList(value) {
175
- if (value == null) {
176
- return [];
177
- }
178
- return Array.isArray(value) ? value.map(String) : [String(value)];
179
- }
180
- /** Granted scopes from `scope` (space-delimited string) or `scp` (array). */
181
- function scopesOf(claims) {
182
- const scope = claims.scope;
183
- if (typeof scope === "string") {
184
- return scope.split(/\s+/).filter((s) => s.length > 0);
185
- }
186
- const scp = claims.scp;
187
- if (Array.isArray(scp)) {
188
- return scp.map(String);
217
+ try {
218
+ const granted = strict.scopes(data);
219
+ const missing = required.filter((s) => !granted.includes(s));
220
+ if (missing.length > 0) {
221
+ return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
222
+ }
223
+ const audience = strict.audience(data);
224
+ if (audience.length > 0 && !this.config.audiences.some((a) => audience.includes(a))) {
225
+ return failure(errors.INVALID_AUDIENCE, "token audience is not accepted");
226
+ }
227
+ strict.subject(data);
228
+ return success(data, granted, audience);
229
+ }
230
+ catch (exc) {
231
+ return failure(errors.INVALID_TOKEN, errMessage(exc));
232
+ }
189
233
  }
190
- return [];
191
234
  }
192
235
  function base64(value) {
193
- return Buffer.from(value, "utf8").toString("base64");
236
+ return base64Encode(utf8Encode(value));
194
237
  }
195
238
  function errMessage(exc) {
196
239
  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": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "Client-side builder SDK for OAuth Attestation-Based Client Authentication",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,6 +28,7 @@
28
28
  "prepack": "npm run build",
29
29
  "test": "vitest run",
30
30
  "coverage": "vitest run --coverage",
31
+ "test:edge": "node test/edge/run-workerd.mjs",
31
32
  "emit": "tsx emit.ts",
32
33
  "validate-emit": "tsx validate-emit.ts"
33
34
  },
@@ -43,13 +44,15 @@
43
44
  },
44
45
  "homepage": "https://github.com/ID-Partners/client-attestation-sdk/tree/main/typescript",
45
46
  "dependencies": {
46
- "jose": "^6.0.0"
47
+ "jose": "^6.2.12"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^20.0.0",
50
- "@vitest/coverage-v8": "^2.1.9",
51
- "tsx": "^4.0.0",
52
- "typescript": "^5.4.0",
53
- "vitest": "^2.0.0"
51
+ "@vitest/coverage-v8": "^4.1.11",
52
+ "esbuild": "^0.28.1",
53
+ "tsx": "^4.23.15",
54
+ "typescript": "^7.0.2",
55
+ "vitest": "^4.1.11",
56
+ "workerd": "1.20261007.1"
54
57
  }
55
58
  }