@serafort/core 0.1.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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/index.cjs +651 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +331 -0
- package/dist/index.d.ts +331 -0
- package/dist/index.js +634 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var SerafortError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
details;
|
|
6
|
+
constructor(message, code = "SERAFORT_ERROR", status = 500, details = []) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "SerafortError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.details = details;
|
|
12
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var AuthenticationError = class extends SerafortError {
|
|
16
|
+
constructor(message = "Authentication failed or session is invalid.", code = "AUTHENTICATION_ERROR", details = []) {
|
|
17
|
+
super(message, code, 401, details);
|
|
18
|
+
this.name = "AuthenticationError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var MfaRequiredError = class extends SerafortError {
|
|
22
|
+
challengeId;
|
|
23
|
+
supportedMethods;
|
|
24
|
+
constructor(message = "Multi-factor authentication required.", challengeId, supportedMethods, details = []) {
|
|
25
|
+
super(message, "MFA_REQUIRED", 403, details);
|
|
26
|
+
this.name = "MfaRequiredError";
|
|
27
|
+
this.challengeId = challengeId;
|
|
28
|
+
this.supportedMethods = supportedMethods;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var ValidationError = class extends SerafortError {
|
|
32
|
+
constructor(message = "Validation failed on input parameters.", details = []) {
|
|
33
|
+
super(message, "VALIDATION_ERROR", 422, details);
|
|
34
|
+
this.name = "ValidationError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var RateLimitError = class extends SerafortError {
|
|
38
|
+
retryAfterSeconds;
|
|
39
|
+
constructor(message = "Rate limit exceeded. Please slow down requests.", retryAfterSeconds, details = []) {
|
|
40
|
+
super(message, "RATE_LIMIT_EXCEEDED", 429, details);
|
|
41
|
+
this.name = "RateLimitError";
|
|
42
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var NotFoundError = class extends SerafortError {
|
|
46
|
+
constructor(message = "Resource not found.", code = "NOT_FOUND", details = []) {
|
|
47
|
+
super(message, code, 404, details);
|
|
48
|
+
this.name = "NotFoundError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var NetworkError = class extends SerafortError {
|
|
52
|
+
constructor(message = "Failed to connect to Serafort IAM service.", cause) {
|
|
53
|
+
super(message, "NETWORK_ERROR", 0);
|
|
54
|
+
this.name = "NetworkError";
|
|
55
|
+
if (cause) {
|
|
56
|
+
this.cause = cause;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/utils/jwt.ts
|
|
62
|
+
function base64UrlToBytes(str) {
|
|
63
|
+
if (typeof Buffer !== "undefined") {
|
|
64
|
+
return new Uint8Array(Buffer.from(str, "base64url"));
|
|
65
|
+
}
|
|
66
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
67
|
+
const pad = base64.length % 4;
|
|
68
|
+
if (pad === 2) {
|
|
69
|
+
base64 += "==";
|
|
70
|
+
} else if (pad === 3) {
|
|
71
|
+
base64 += "=";
|
|
72
|
+
}
|
|
73
|
+
const binaryStr = atob(base64);
|
|
74
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
75
|
+
for (let i = 0; i < binaryStr.length; i++) {
|
|
76
|
+
bytes[i] = binaryStr.charCodeAt(i);
|
|
77
|
+
}
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|
|
80
|
+
function base64UrlDecode(str) {
|
|
81
|
+
if (typeof Buffer !== "undefined") {
|
|
82
|
+
return Buffer.from(str, "base64url").toString("utf8");
|
|
83
|
+
}
|
|
84
|
+
const bytes = base64UrlToBytes(str);
|
|
85
|
+
return new TextDecoder().decode(bytes);
|
|
86
|
+
}
|
|
87
|
+
function parseJwt(token) {
|
|
88
|
+
if (!token || typeof token !== "string") {
|
|
89
|
+
throw new AuthenticationError("JWT token must be a non-empty string.");
|
|
90
|
+
}
|
|
91
|
+
const parts = token.split(".");
|
|
92
|
+
if (parts.length !== 3) {
|
|
93
|
+
throw new AuthenticationError("Invalid JWT format: token must contain exactly 3 dot-separated parts.");
|
|
94
|
+
}
|
|
95
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
96
|
+
try {
|
|
97
|
+
const headerJson = base64UrlDecode(headerB64);
|
|
98
|
+
const header = JSON.parse(headerJson);
|
|
99
|
+
const payloadJson = base64UrlDecode(payloadB64);
|
|
100
|
+
const payload = JSON.parse(payloadJson);
|
|
101
|
+
const signature = base64UrlToBytes(signatureB64);
|
|
102
|
+
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
103
|
+
return {
|
|
104
|
+
header,
|
|
105
|
+
payload,
|
|
106
|
+
signature,
|
|
107
|
+
signingInput
|
|
108
|
+
};
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err instanceof AuthenticationError) throw err;
|
|
111
|
+
throw new AuthenticationError(`Failed to parse JWT token: ${err instanceof Error ? err.message : String(err)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/b2b/jwks.ts
|
|
116
|
+
var JwksClient = class {
|
|
117
|
+
jwksUrl;
|
|
118
|
+
fetchImpl;
|
|
119
|
+
cachedKeys = /* @__PURE__ */ new Map();
|
|
120
|
+
lastFetchedAt = 0;
|
|
121
|
+
cacheTtlMs;
|
|
122
|
+
constructor(config, cacheTtlMs = 36e5) {
|
|
123
|
+
const baseUrl = (config.endpoint || config.baseUrl || "https://api.serafort.com").replace(/\/+$/, "");
|
|
124
|
+
this.jwksUrl = `${baseUrl}/.well-known/jwks.json`;
|
|
125
|
+
this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
|
|
126
|
+
this.cacheTtlMs = cacheTtlMs;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Retrieves the CryptoKey matching the key ID (kid) from cache, or fetches from the JWKS endpoint.
|
|
130
|
+
*/
|
|
131
|
+
async getVerificationKey(kid, alg = "RS256") {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const isCacheExpired = now - this.lastFetchedAt > this.cacheTtlMs;
|
|
134
|
+
if (this.cachedKeys.size > 0 && !isCacheExpired) {
|
|
135
|
+
if (kid && this.cachedKeys.has(kid)) {
|
|
136
|
+
return this.cachedKeys.get(kid);
|
|
137
|
+
}
|
|
138
|
+
if (!kid && this.cachedKeys.size === 1) {
|
|
139
|
+
return Array.from(this.cachedKeys.values())[0];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
await this.refreshJwks();
|
|
143
|
+
if (kid && this.cachedKeys.has(kid)) {
|
|
144
|
+
return this.cachedKeys.get(kid);
|
|
145
|
+
}
|
|
146
|
+
if (!kid && this.cachedKeys.size > 0) {
|
|
147
|
+
return Array.from(this.cachedKeys.values())[0];
|
|
148
|
+
}
|
|
149
|
+
throw new AuthenticationError(
|
|
150
|
+
`No matching public key found in JWKS for kid "${kid || "unspecified"}" and alg "${alg}".`,
|
|
151
|
+
"KEY_NOT_FOUND"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Refreshes public keys from the JWKS endpoint and imports them as CryptoKeys.
|
|
156
|
+
*/
|
|
157
|
+
async refreshJwks() {
|
|
158
|
+
let response;
|
|
159
|
+
try {
|
|
160
|
+
response = await this.fetchImpl(this.jwksUrl, {
|
|
161
|
+
headers: { Accept: "application/json" }
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
throw new NetworkError(`Failed to fetch JWKS from ${this.jwksUrl}`, err);
|
|
165
|
+
}
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new AuthenticationError(`Failed to fetch JWKS (status ${response.status})`, "JWKS_FETCH_FAILED");
|
|
168
|
+
}
|
|
169
|
+
const jwks = await response.json();
|
|
170
|
+
if (!jwks.keys || !Array.isArray(jwks.keys)) {
|
|
171
|
+
throw new AuthenticationError('Invalid JWKS response format: "keys" array missing', "INVALID_JWKS");
|
|
172
|
+
}
|
|
173
|
+
const newKeys = /* @__PURE__ */ new Map();
|
|
174
|
+
for (const key of jwks.keys) {
|
|
175
|
+
if (!key.kid) continue;
|
|
176
|
+
try {
|
|
177
|
+
const cryptoKey = await this.importJwk(key);
|
|
178
|
+
newKeys.set(key.kid, cryptoKey);
|
|
179
|
+
} catch {
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.cachedKeys = newKeys;
|
|
183
|
+
this.lastFetchedAt = Date.now();
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Imports a raw JWK into a Web Crypto CryptoKey.
|
|
187
|
+
*/
|
|
188
|
+
async importJwk(key) {
|
|
189
|
+
const subtle = globalThis.crypto?.subtle;
|
|
190
|
+
if (!subtle) {
|
|
191
|
+
throw new AuthenticationError("Web Crypto API (crypto.subtle) is not available in this environment.");
|
|
192
|
+
}
|
|
193
|
+
const alg = key.alg || (key.kty === "RSA" ? "RS256" : "ES256");
|
|
194
|
+
let importAlgorithm;
|
|
195
|
+
if (alg.startsWith("RS")) {
|
|
196
|
+
const hashName = alg === "RS384" ? "SHA-384" : alg === "RS512" ? "SHA-512" : "SHA-256";
|
|
197
|
+
importAlgorithm = {
|
|
198
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
199
|
+
hash: { name: hashName }
|
|
200
|
+
};
|
|
201
|
+
} else if (alg.startsWith("ES")) {
|
|
202
|
+
const namedCurve = alg === "ES384" ? "P-384" : alg === "ES512" ? "P-521" : "P-256";
|
|
203
|
+
importAlgorithm = {
|
|
204
|
+
name: "ECDSA",
|
|
205
|
+
namedCurve
|
|
206
|
+
};
|
|
207
|
+
} else {
|
|
208
|
+
throw new AuthenticationError(`Unsupported key algorithm: ${alg}`);
|
|
209
|
+
}
|
|
210
|
+
return subtle.importKey(
|
|
211
|
+
"jwk",
|
|
212
|
+
key,
|
|
213
|
+
importAlgorithm,
|
|
214
|
+
false,
|
|
215
|
+
// non-extractable
|
|
216
|
+
["verify"]
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// src/b2b/b2b.ts
|
|
222
|
+
var B2BModule = class {
|
|
223
|
+
config;
|
|
224
|
+
jwksClient;
|
|
225
|
+
baseUrl;
|
|
226
|
+
constructor(config, jwksClient) {
|
|
227
|
+
this.config = config;
|
|
228
|
+
this.jwksClient = jwksClient ?? new JwksClient(config);
|
|
229
|
+
this.baseUrl = (config.endpoint || config.baseUrl || "https://api.serafort.com").replace(/\/+$/, "");
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Validates a JWT token locally: checks signature against cached JWKS, verifies expiration,
|
|
233
|
+
* issuer, and audience, and decodes claims into a strongly-typed UserContext.
|
|
234
|
+
*/
|
|
235
|
+
async validateToken(token, options = {}) {
|
|
236
|
+
const decoded = parseJwt(token);
|
|
237
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
238
|
+
const clockTolerance = options.clockToleranceSeconds ?? 60;
|
|
239
|
+
const payload = decoded.payload;
|
|
240
|
+
if (typeof payload["exp"] === "number") {
|
|
241
|
+
if (payload["exp"] + clockTolerance < now) {
|
|
242
|
+
throw new AuthenticationError("Token has expired.", "TOKEN_EXPIRED");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (typeof payload["nbf"] === "number") {
|
|
246
|
+
if (payload["nbf"] - clockTolerance > now) {
|
|
247
|
+
throw new AuthenticationError("Token not yet valid.", "TOKEN_NOT_YET_VALID");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const expectedIssuer = options.expectedIssuer || this.baseUrl;
|
|
251
|
+
if (expectedIssuer && typeof payload["iss"] === "string") {
|
|
252
|
+
if (payload["iss"] !== expectedIssuer) {
|
|
253
|
+
throw new AuthenticationError(
|
|
254
|
+
`Invalid token issuer. Expected "${expectedIssuer}", received "${payload["iss"]}".`,
|
|
255
|
+
"INVALID_ISSUER"
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (options.expectedAudience && payload["aud"]) {
|
|
260
|
+
const aud = Array.isArray(payload["aud"]) ? payload["aud"] : [payload["aud"]];
|
|
261
|
+
if (!aud.includes(options.expectedAudience)) {
|
|
262
|
+
throw new AuthenticationError(
|
|
263
|
+
`Invalid token audience. Expected "${options.expectedAudience}".`,
|
|
264
|
+
"INVALID_AUDIENCE"
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!options.skipSignatureCheck) {
|
|
269
|
+
const subtle = globalThis.crypto?.subtle;
|
|
270
|
+
if (!subtle) {
|
|
271
|
+
throw new AuthenticationError("Web Crypto API (crypto.subtle) is not available to verify JWT signature.");
|
|
272
|
+
}
|
|
273
|
+
const kid = decoded.header.kid;
|
|
274
|
+
const alg = decoded.header.alg || "RS256";
|
|
275
|
+
const cryptoKey = await this.jwksClient.getVerificationKey(kid, alg);
|
|
276
|
+
let verifyAlgorithm;
|
|
277
|
+
if (alg.startsWith("RS")) {
|
|
278
|
+
verifyAlgorithm = { name: "RSASSA-PKCS1-v1_5" };
|
|
279
|
+
} else if (alg.startsWith("ES")) {
|
|
280
|
+
verifyAlgorithm = { name: "ECDSA", hash: { name: alg === "ES384" ? "SHA-384" : alg === "ES512" ? "SHA-512" : "SHA-256" } };
|
|
281
|
+
} else {
|
|
282
|
+
throw new AuthenticationError(`Unsupported JWT algorithm for signature verification: ${alg}`);
|
|
283
|
+
}
|
|
284
|
+
const isValid = await subtle.verify(
|
|
285
|
+
verifyAlgorithm,
|
|
286
|
+
cryptoKey,
|
|
287
|
+
decoded.signature,
|
|
288
|
+
decoded.signingInput
|
|
289
|
+
);
|
|
290
|
+
if (!isValid) {
|
|
291
|
+
throw new AuthenticationError("JWT signature verification failed.", "INVALID_SIGNATURE");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return this.mapClaimsToUserContext(payload);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Checks if the user context contains a specific granular permission.
|
|
298
|
+
* Supports wildcard matching (e.g. "org:*" matches "org:read").
|
|
299
|
+
*/
|
|
300
|
+
hasPermission(userContext, requiredPermission) {
|
|
301
|
+
if (!userContext.permissions || userContext.permissions.length === 0) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
if (userContext.permissions.includes("*") || userContext.permissions.includes(requiredPermission)) {
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
return userContext.permissions.some((perm) => {
|
|
308
|
+
if (perm.endsWith(":*")) {
|
|
309
|
+
const prefix = perm.slice(0, -2);
|
|
310
|
+
return requiredPermission.startsWith(prefix);
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Checks if the user context contains a specific role.
|
|
317
|
+
*/
|
|
318
|
+
hasRole(userContext, requiredRole) {
|
|
319
|
+
return Array.isArray(userContext.roles) && userContext.roles.includes(requiredRole);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Constructs the Enterprise SSO Login URL for a specific tenant/organization.
|
|
323
|
+
*/
|
|
324
|
+
getLoginUrl(tenantId, redirectUri, options = {}) {
|
|
325
|
+
const url = new URL(`${this.baseUrl}/api/auth/sso/login`);
|
|
326
|
+
url.searchParams.set("tenant_id", tenantId);
|
|
327
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
328
|
+
if (options.state) {
|
|
329
|
+
url.searchParams.set("state", options.state);
|
|
330
|
+
}
|
|
331
|
+
if (options.connection) {
|
|
332
|
+
url.searchParams.set("connection", options.connection);
|
|
333
|
+
}
|
|
334
|
+
return url.toString();
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Maps un-enveloped JWT claims to standard UserContext.
|
|
338
|
+
*/
|
|
339
|
+
mapClaimsToUserContext(claims) {
|
|
340
|
+
const userId = String(claims["sub"] || claims["id"] || claims["user_id"] || "");
|
|
341
|
+
const tenantId = String(
|
|
342
|
+
claims["tenant_id"] || claims["org_id"] || claims["tid"] || claims["app_metadata"]?.["tenant_id"] || ""
|
|
343
|
+
);
|
|
344
|
+
const email = claims["email"] ? String(claims["email"]) : void 0;
|
|
345
|
+
let roles = [];
|
|
346
|
+
if (Array.isArray(claims["roles"])) {
|
|
347
|
+
roles = claims["roles"].map(String);
|
|
348
|
+
} else if (typeof claims["role"] === "string") {
|
|
349
|
+
roles = [claims["role"]];
|
|
350
|
+
} else if (typeof claims["roles"] === "string") {
|
|
351
|
+
roles = claims["roles"].split(",").map((r) => r.trim());
|
|
352
|
+
}
|
|
353
|
+
let permissions = [];
|
|
354
|
+
if (Array.isArray(claims["permissions"])) {
|
|
355
|
+
permissions = claims["permissions"].map(String);
|
|
356
|
+
} else if (typeof claims["scope"] === "string") {
|
|
357
|
+
permissions = claims["scope"].split(" ").map((s) => s.trim()).filter(Boolean);
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
userId,
|
|
361
|
+
tenantId,
|
|
362
|
+
email,
|
|
363
|
+
roles,
|
|
364
|
+
permissions,
|
|
365
|
+
claims
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// src/utils/retry.ts
|
|
371
|
+
async function executeWithRetry(fn, options = {}) {
|
|
372
|
+
const maxRetries = options.retryConfig?.maxRetries ?? 3;
|
|
373
|
+
const initialDelay = options.retryConfig?.initialDelayMs ?? 500;
|
|
374
|
+
const maxDelay = options.retryConfig?.maxDelayMs ?? 5e3;
|
|
375
|
+
let attempt = 0;
|
|
376
|
+
while (true) {
|
|
377
|
+
try {
|
|
378
|
+
return await fn();
|
|
379
|
+
} catch (err) {
|
|
380
|
+
attempt++;
|
|
381
|
+
if (attempt > maxRetries) {
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
const isRetryable = isErrorRetryable(err);
|
|
385
|
+
if (!isRetryable) {
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
if (options.signal?.aborted) {
|
|
389
|
+
throw new NetworkError("Request aborted by caller", options.signal.reason);
|
|
390
|
+
}
|
|
391
|
+
let delay = initialDelay * Math.pow(2, attempt - 1);
|
|
392
|
+
if (err instanceof RateLimitError && err.retryAfterSeconds) {
|
|
393
|
+
delay = Math.max(delay, err.retryAfterSeconds * 1e3);
|
|
394
|
+
}
|
|
395
|
+
const jitter = delay * (0.8 + Math.random() * 0.4);
|
|
396
|
+
const cappedDelay = Math.min(jitter, maxDelay);
|
|
397
|
+
await sleep(cappedDelay, options.signal);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function isErrorRetryable(err) {
|
|
402
|
+
if (err instanceof RateLimitError) {
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
if (err instanceof NetworkError) {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
if (err instanceof Error) {
|
|
409
|
+
const msg = err.message.toLowerCase();
|
|
410
|
+
if (msg.includes("network") || msg.includes("fetch failed") || msg.includes("econnreset") || msg.includes("econnrefused") || msg.includes("etimedout")) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
function sleep(ms, signal) {
|
|
417
|
+
return new Promise((resolve, reject) => {
|
|
418
|
+
if (signal?.aborted) {
|
|
419
|
+
return reject(new NetworkError("Request aborted while sleeping", signal.reason));
|
|
420
|
+
}
|
|
421
|
+
const timer = setTimeout(() => {
|
|
422
|
+
resolve();
|
|
423
|
+
}, ms);
|
|
424
|
+
if (signal) {
|
|
425
|
+
signal.addEventListener(
|
|
426
|
+
"abort",
|
|
427
|
+
() => {
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
reject(new NetworkError("Request aborted", signal.reason));
|
|
430
|
+
},
|
|
431
|
+
{ once: true }
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/m2m/cache.ts
|
|
438
|
+
var M2MTokenCache = class {
|
|
439
|
+
cache = /* @__PURE__ */ new Map();
|
|
440
|
+
inFlightRequests = /* @__PURE__ */ new Map();
|
|
441
|
+
/**
|
|
442
|
+
* Refresh window buffer in seconds (default: 300s = 5 minutes).
|
|
443
|
+
* A token expiring within this window will be refreshed proactively.
|
|
444
|
+
*/
|
|
445
|
+
refreshBufferSeconds;
|
|
446
|
+
constructor(refreshBufferSeconds = 300) {
|
|
447
|
+
this.refreshBufferSeconds = refreshBufferSeconds;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Retrieves a cached token if valid, or invokes the refresher function.
|
|
451
|
+
* If a fetch is already in flight for the given cache key, coalesces into that existing promise.
|
|
452
|
+
*/
|
|
453
|
+
async getOrFetch(cacheKey, refresher, force = false) {
|
|
454
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
455
|
+
if (!force) {
|
|
456
|
+
const cached = this.cache.get(cacheKey);
|
|
457
|
+
if (cached && cached.expiresAt - this.refreshBufferSeconds > now) {
|
|
458
|
+
return cached.accessToken;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const existingInFlight = this.inFlightRequests.get(cacheKey);
|
|
462
|
+
if (existingInFlight) {
|
|
463
|
+
return existingInFlight;
|
|
464
|
+
}
|
|
465
|
+
const fetchPromise = (async () => {
|
|
466
|
+
try {
|
|
467
|
+
const response = await refresher();
|
|
468
|
+
const expiresAt = now + response.expiresIn;
|
|
469
|
+
this.cache.set(cacheKey, {
|
|
470
|
+
accessToken: response.accessToken,
|
|
471
|
+
expiresAt,
|
|
472
|
+
scope: response.scope
|
|
473
|
+
});
|
|
474
|
+
return response.accessToken;
|
|
475
|
+
} finally {
|
|
476
|
+
this.inFlightRequests.delete(cacheKey);
|
|
477
|
+
}
|
|
478
|
+
})();
|
|
479
|
+
this.inFlightRequests.set(cacheKey, fetchPromise);
|
|
480
|
+
return fetchPromise;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Invalidates cached token for a specific cache key or clears all.
|
|
484
|
+
*/
|
|
485
|
+
invalidate(cacheKey) {
|
|
486
|
+
if (cacheKey) {
|
|
487
|
+
this.cache.delete(cacheKey);
|
|
488
|
+
} else {
|
|
489
|
+
this.cache.clear();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Returns current cached item metadata (for diagnostics and testing).
|
|
494
|
+
*/
|
|
495
|
+
get(cacheKey) {
|
|
496
|
+
return this.cache.get(cacheKey);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// src/m2m/m2m.ts
|
|
501
|
+
var M2MModule = class {
|
|
502
|
+
config;
|
|
503
|
+
cache;
|
|
504
|
+
baseUrl;
|
|
505
|
+
fetchImpl;
|
|
506
|
+
constructor(config, cache) {
|
|
507
|
+
this.config = config;
|
|
508
|
+
this.cache = cache ?? new M2MTokenCache();
|
|
509
|
+
this.baseUrl = (config.endpoint || config.baseUrl || "https://api.serafort.com").replace(/\/+$/, "");
|
|
510
|
+
this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Retrieves a valid M2M access token.
|
|
514
|
+
* Pulls from memory cache if valid and outside the refresh buffer.
|
|
515
|
+
* If nearing expiration or missing, transparently requests a new token.
|
|
516
|
+
*/
|
|
517
|
+
async getAccessToken(scopes) {
|
|
518
|
+
const scopeStr = scopes && scopes.length > 0 ? scopes.sort().join(" ") : "";
|
|
519
|
+
const cacheKey = `m2m:${this.config.clientId || "default"}:${scopeStr}`;
|
|
520
|
+
return this.cache.getOrFetch(cacheKey, () => this.fetchTokenNetwork(scopeStr), false);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Forces a refresh of the access token, bypassing the cache.
|
|
524
|
+
* Useful when an API endpoint returns 401 due to early revocation.
|
|
525
|
+
*/
|
|
526
|
+
async forceRefreshToken(scopes) {
|
|
527
|
+
const scopeStr = scopes && scopes.length > 0 ? scopes.sort().join(" ") : "";
|
|
528
|
+
const cacheKey = `m2m:${this.config.clientId || "default"}:${scopeStr}`;
|
|
529
|
+
return this.cache.getOrFetch(cacheKey, () => this.fetchTokenNetwork(scopeStr), true);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Clears the in-memory token cache.
|
|
533
|
+
*/
|
|
534
|
+
clearCache() {
|
|
535
|
+
this.cache.invalidate();
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Performs the HTTP request to the OAuth2 token endpoint using Client Credentials grant.
|
|
539
|
+
*/
|
|
540
|
+
async fetchTokenNetwork(scope) {
|
|
541
|
+
if (!this.config.clientId || !this.config.clientSecret) {
|
|
542
|
+
throw new AuthenticationError("M2M authentication requires clientId and clientSecret to be configured.");
|
|
543
|
+
}
|
|
544
|
+
const tokenUrl = `${this.baseUrl}/oauth/token`;
|
|
545
|
+
const bodyParams = new URLSearchParams({
|
|
546
|
+
grant_type: "client_credentials",
|
|
547
|
+
client_id: this.config.clientId,
|
|
548
|
+
client_secret: this.config.clientSecret
|
|
549
|
+
});
|
|
550
|
+
if (scope) {
|
|
551
|
+
bodyParams.set("scope", scope);
|
|
552
|
+
}
|
|
553
|
+
return executeWithRetry(
|
|
554
|
+
async () => {
|
|
555
|
+
let response;
|
|
556
|
+
try {
|
|
557
|
+
const controller = new AbortController();
|
|
558
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout ?? 1e4);
|
|
559
|
+
response = await this.fetchImpl(tokenUrl, {
|
|
560
|
+
method: "POST",
|
|
561
|
+
headers: {
|
|
562
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
563
|
+
Accept: "application/json"
|
|
564
|
+
},
|
|
565
|
+
body: bodyParams.toString(),
|
|
566
|
+
signal: controller.signal
|
|
567
|
+
});
|
|
568
|
+
clearTimeout(timeoutId);
|
|
569
|
+
} catch (err) {
|
|
570
|
+
throw new NetworkError(`Failed to connect to token endpoint at ${tokenUrl}`, err);
|
|
571
|
+
}
|
|
572
|
+
if (response.status === 429) {
|
|
573
|
+
const retryAfterHeader = response.headers.get("Retry-After");
|
|
574
|
+
const retryAfterSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : void 0;
|
|
575
|
+
throw new RateLimitError("Rate limit exceeded on token endpoint", isNaN(retryAfterSec) ? void 0 : retryAfterSec);
|
|
576
|
+
}
|
|
577
|
+
if (!response.ok) {
|
|
578
|
+
let errorPayload;
|
|
579
|
+
try {
|
|
580
|
+
errorPayload = await response.json();
|
|
581
|
+
} catch {
|
|
582
|
+
}
|
|
583
|
+
const errorMessage = errorPayload?.error?.message || `OAuth token request failed with status ${response.status}`;
|
|
584
|
+
const errorCode = errorPayload?.error?.code || "TOKEN_REQUEST_FAILED";
|
|
585
|
+
if (response.status === 401 || response.status === 400) {
|
|
586
|
+
throw new AuthenticationError(errorMessage, errorCode);
|
|
587
|
+
}
|
|
588
|
+
throw new SerafortError(errorMessage, errorCode, response.status);
|
|
589
|
+
}
|
|
590
|
+
const data = await response.json();
|
|
591
|
+
if (!data.access_token) {
|
|
592
|
+
throw new SerafortError("OAuth token endpoint response did not contain access_token", "INVALID_RESPONSE");
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
accessToken: data.access_token,
|
|
596
|
+
expiresIn: data.expires_in || 3600,
|
|
597
|
+
scope: data.scope
|
|
598
|
+
};
|
|
599
|
+
},
|
|
600
|
+
{ retryConfig: this.config.retryPolicy }
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// src/client.ts
|
|
606
|
+
var SerafortClient = class {
|
|
607
|
+
config;
|
|
608
|
+
m2m;
|
|
609
|
+
b2b;
|
|
610
|
+
constructor(config) {
|
|
611
|
+
this.config = {
|
|
612
|
+
endpoint: config.endpoint || config.baseUrl || "https://api.serafort.com",
|
|
613
|
+
...config
|
|
614
|
+
};
|
|
615
|
+
this.m2m = new M2MModule(this.config);
|
|
616
|
+
this.b2b = new B2BModule(this.config);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Helper shortcut to retrieve an M2M access token.
|
|
620
|
+
*/
|
|
621
|
+
async getAccessToken(scopes) {
|
|
622
|
+
return this.m2m.getAccessToken(scopes);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Helper shortcut to validate a JWT token and decode its UserContext.
|
|
626
|
+
*/
|
|
627
|
+
async validateToken(token) {
|
|
628
|
+
return this.b2b.validateToken(token);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
export { AuthenticationError, B2BModule, JwksClient, M2MModule, M2MTokenCache, MfaRequiredError, NetworkError, NotFoundError, RateLimitError, SerafortClient, SerafortError, ValidationError, base64UrlDecode, base64UrlToBytes, executeWithRetry, parseJwt };
|
|
633
|
+
//# sourceMappingURL=index.js.map
|
|
634
|
+
//# sourceMappingURL=index.js.map
|