@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,240 @@
1
+ /**
2
+ * Access-token validation: JWT signature + claim checks, and optional RFC 7662 introspection.
3
+ *
4
+ * `validate` does local JWT validation in a fixed order — algorithm accepted, key resolvable,
5
+ * signature, `typ` (when present), `iss`, `exp` present, `exp`, `nbf`, `iat` not in the future, audience,
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
+ * `validateActive` instead uses RFC 7662 introspection.
8
+ */
9
+ import { compactVerify } from "jose";
10
+ import { base64Encode, utf8Encode } from "../runtime.js";
11
+ import * as errors from "./errors.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";
17
+ import { JwksProvider } from "./jwks.js";
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;
25
+ const defaultPost = async (url, body, headers) => {
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
+ });
35
+ if (!resp.ok) {
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
+ });
45
+ }
46
+ };
47
+ /** Validates access tokens for a resource server. */
48
+ export class AccessTokenValidator {
49
+ config;
50
+ #jwks;
51
+ #httpPost;
52
+ #logger;
53
+ #clock;
54
+ constructor(config, opts = {}) {
55
+ this.config = resolveConfig(config);
56
+ this.#jwks = new JwksProvider({
57
+ jwks: this.config.jwks,
58
+ jwksUri: this.config.jwksUri,
59
+ httpGet: opts.httpGet,
60
+ logger: opts.logger,
61
+ });
62
+ this.#httpPost = opts.httpPost ?? defaultPost;
63
+ this.#logger = opts.logger ?? NOOP_LOGGER;
64
+ this.#clock = opts.clock ?? Date.now;
65
+ }
66
+ /**
67
+ * Locally validate a JWT access token. Returns the first failure in the fixed contract order, or a
68
+ * success result exposing subject / scopes / audience / claims.
69
+ */
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) {
79
+ const required = requiredScopes ?? this.config.requiredScopes;
80
+ let parts;
81
+ try {
82
+ parts = strict.splitCompact(token);
83
+ }
84
+ catch {
85
+ // never echo token data to the caller: the description lands in WWW-Authenticate
86
+ return failure(errors.INVALID_TOKEN, "malformed token");
87
+ }
88
+ const { header } = parts;
89
+ const alg = header.alg;
90
+ if (typeof alg !== "string" || isForbiddenAlgorithm(alg) || !this.config.acceptedAlgorithms.includes(alg)) {
91
+ // never echo the unverified header alg back to the caller (it lands in WWW-Authenticate)
92
+ return failure(errors.UNSUPPORTED_ALGORITHM, "algorithm not accepted");
93
+ }
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");
98
+ }
99
+ try {
100
+ await compactVerify(token, key, { algorithms: [alg] });
101
+ }
102
+ catch {
103
+ return failure(errors.INVALID_SIGNATURE, "signature verification failed");
104
+ }
105
+ let claims;
106
+ try {
107
+ claims = strict.parseObject(parts.payload);
108
+ }
109
+ catch (exc) {
110
+ return failure(errors.INVALID_TOKEN, errMessage(exc));
111
+ }
112
+ const types = this.config.acceptedTokenTypes;
113
+ const typ = header.typ;
114
+ if (typ != null && types !== null && (typeof typ !== "string" || !types.includes(normaliseType(typ)))) {
115
+ return failure(errors.INVALID_TOKEN_TYPE, "token is not a JWT access token");
116
+ }
117
+ if (claims.iss !== this.config.issuer) {
118
+ return failure(errors.INVALID_ISSUER, "unexpected issuer");
119
+ }
120
+ const now = Math.floor(this.#clock() / 1000);
121
+ const leeway = this.config.leewaySeconds;
122
+ let exp;
123
+ let nbf;
124
+ let iat;
125
+ try {
126
+ exp = strict.numericDate(claims, "exp");
127
+ nbf = strict.numericDate(claims, "nbf");
128
+ iat = strict.numericDate(claims, "iat");
129
+ }
130
+ catch (exc) {
131
+ return failure(errors.INVALID_TOKEN, errMessage(exc));
132
+ }
133
+ if (exp === undefined) {
134
+ return failure(errors.MISSING_EXPIRY, "token has no exp claim");
135
+ }
136
+ if (now > exp + leeway) {
137
+ return failure(errors.EXPIRED, "token has expired");
138
+ }
139
+ if (nbf !== undefined && now + leeway < nbf) {
140
+ return failure(errors.NOT_YET_VALID, "token is not yet valid");
141
+ }
142
+ if (iat !== undefined && now + leeway < iat) {
143
+ return failure(errors.ISSUED_IN_FUTURE, "token was issued in the future");
144
+ }
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);
157
+ }
158
+ catch (exc) {
159
+ return failure(errors.INVALID_TOKEN, errMessage(exc));
160
+ }
161
+ }
162
+ /**
163
+ * RFC 7662: POST the token to the AS introspection endpoint and return the parsed response.
164
+ */
165
+ async introspect(token, tokenTypeHint = "access_token") {
166
+ const cfg = this.config.introspection;
167
+ if (cfg == null) {
168
+ throw new Error("no introspection endpoint configured");
169
+ }
170
+ const form = new URLSearchParams();
171
+ form.set("token", token);
172
+ form.set("token_type_hint", tokenTypeHint);
173
+ const headers = {
174
+ "Content-Type": "application/x-www-form-urlencoded",
175
+ Accept: "application/json",
176
+ };
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 {
183
+ form.set("client_id", cfg.clientId);
184
+ form.set("client_secret", cfg.clientSecret);
185
+ }
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
+ }
203
+ }
204
+ /**
205
+ * Introspect the token and enforce `active` plus scope/audience from the response.
206
+ */
207
+ async validateActive(token, requiredScopes) {
208
+ return this.#verdict("introspection", await this.#validateActive(token, requiredScopes));
209
+ }
210
+ async #validateActive(token, requiredScopes) {
211
+ const data = await this.introspect(token);
212
+ // RFC 7662 section 2.2: active is a boolean, so only JSON true counts
213
+ if (data.active !== true) {
214
+ return failure(errors.INACTIVE, "token is not active");
215
+ }
216
+ const required = requiredScopes ?? this.config.requiredScopes;
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
+ }
233
+ }
234
+ }
235
+ function base64(value) {
236
+ return base64Encode(utf8Encode(value));
237
+ }
238
+ function errMessage(exc) {
239
+ return exc instanceof Error ? exc.message : String(exc);
240
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@idpartners/client-attestation-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Client-side builder SDK for OAuth Attestation-Based Client Authentication",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./validator": {
14
+ "types": "./dist/validator/index.d.ts",
15
+ "import": "./dist/validator/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "prepack": "npm run build",
29
+ "test": "vitest run",
30
+ "coverage": "vitest run --coverage",
31
+ "test:edge": "node test/edge/run-workerd.mjs",
32
+ "emit": "tsx emit.ts",
33
+ "validate-emit": "tsx validate-emit.ts"
34
+ },
35
+ "license": "Apache-2.0",
36
+ "author": "ID Partners Pty Ltd",
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/ID-Partners/client-attestation-sdk.git",
43
+ "directory": "typescript"
44
+ },
45
+ "homepage": "https://github.com/ID-Partners/client-attestation-sdk/tree/main/typescript",
46
+ "dependencies": {
47
+ "jose": "^6.2.12"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.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"
57
+ }
58
+ }