@insureco/bio 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/dist/index.d.mts +385 -0
- package/dist/index.d.ts +385 -0
- package/dist/index.js +654 -0
- package/dist/index.mjs +611 -0
- package/package.json +48 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
// src/auth.ts
|
|
2
|
+
import crypto2 from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
var BioError = class extends Error {
|
|
6
|
+
/** HTTP status code (if from an API response) */
|
|
7
|
+
statusCode;
|
|
8
|
+
/** Machine-readable error code (e.g. 'invalid_grant', 'token_expired') */
|
|
9
|
+
code;
|
|
10
|
+
/** Additional error details from the API */
|
|
11
|
+
details;
|
|
12
|
+
constructor(message, code, statusCode, details) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "BioError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.statusCode = statusCode;
|
|
17
|
+
this.details = details;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/pkce.ts
|
|
22
|
+
import crypto from "crypto";
|
|
23
|
+
function generatePKCE() {
|
|
24
|
+
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
25
|
+
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
|
|
26
|
+
return { codeVerifier, codeChallenge };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/utils.ts
|
|
30
|
+
function retryDelay(attempt) {
|
|
31
|
+
const baseDelay = Math.min(1e3 * 2 ** attempt, 5e3);
|
|
32
|
+
return baseDelay * (0.5 + Math.random() * 0.5);
|
|
33
|
+
}
|
|
34
|
+
function sleep(ms) {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
}
|
|
37
|
+
async function parseJsonResponse(response) {
|
|
38
|
+
try {
|
|
39
|
+
return await response.json();
|
|
40
|
+
} catch {
|
|
41
|
+
throw new BioError(
|
|
42
|
+
`Bio-ID returned ${response.status} with non-JSON body`,
|
|
43
|
+
"parse_error",
|
|
44
|
+
response.status
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/auth.ts
|
|
50
|
+
var DEFAULT_ISSUER = "https://bio.tawa.insureco.io";
|
|
51
|
+
var DEFAULT_SCOPES = ["openid", "profile", "email"];
|
|
52
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
53
|
+
var BioAuth = class _BioAuth {
|
|
54
|
+
clientId;
|
|
55
|
+
clientSecret;
|
|
56
|
+
issuer;
|
|
57
|
+
retries;
|
|
58
|
+
timeoutMs;
|
|
59
|
+
constructor(config) {
|
|
60
|
+
if (!config.clientId) {
|
|
61
|
+
throw new BioError("clientId is required", "config_error");
|
|
62
|
+
}
|
|
63
|
+
if (!config.clientSecret) {
|
|
64
|
+
throw new BioError("clientSecret is required", "config_error");
|
|
65
|
+
}
|
|
66
|
+
this.clientId = config.clientId;
|
|
67
|
+
this.clientSecret = config.clientSecret;
|
|
68
|
+
this.issuer = (config.issuer ?? DEFAULT_ISSUER).replace(/\/$/, "");
|
|
69
|
+
this.retries = config.retries ?? 2;
|
|
70
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create a BioAuth from environment variables.
|
|
74
|
+
*
|
|
75
|
+
* Reads: BIO_CLIENT_ID, BIO_CLIENT_SECRET, BIO_ID_URL
|
|
76
|
+
*/
|
|
77
|
+
static fromEnv() {
|
|
78
|
+
const clientId = process.env.BIO_CLIENT_ID;
|
|
79
|
+
const clientSecret = process.env.BIO_CLIENT_SECRET;
|
|
80
|
+
if (!clientId) {
|
|
81
|
+
throw new BioError(
|
|
82
|
+
"BIO_CLIENT_ID environment variable is required",
|
|
83
|
+
"config_error"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!clientSecret) {
|
|
87
|
+
throw new BioError(
|
|
88
|
+
"BIO_CLIENT_SECRET environment variable is required",
|
|
89
|
+
"config_error"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return new _BioAuth({
|
|
93
|
+
clientId,
|
|
94
|
+
clientSecret,
|
|
95
|
+
issuer: process.env.BIO_ID_URL
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Build an authorization URL with PKCE for redirecting the user to Bio-ID.
|
|
100
|
+
*
|
|
101
|
+
* Returns the URL, state, and PKCE verifier/challenge.
|
|
102
|
+
* Store the state and codeVerifier securely (e.g. in a cookie) for the callback.
|
|
103
|
+
*/
|
|
104
|
+
getAuthorizationUrl(opts) {
|
|
105
|
+
if (!opts.redirectUri) {
|
|
106
|
+
throw new BioError("redirectUri is required", "validation_error");
|
|
107
|
+
}
|
|
108
|
+
const state = opts.state ?? crypto2.randomBytes(16).toString("hex");
|
|
109
|
+
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
110
|
+
const scopes = opts.scopes ?? DEFAULT_SCOPES;
|
|
111
|
+
const params = new URLSearchParams({
|
|
112
|
+
client_id: this.clientId,
|
|
113
|
+
redirect_uri: opts.redirectUri,
|
|
114
|
+
response_type: "code",
|
|
115
|
+
scope: scopes.join(" "),
|
|
116
|
+
state,
|
|
117
|
+
code_challenge: codeChallenge,
|
|
118
|
+
code_challenge_method: "S256"
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
url: `${this.issuer}/oauth/authorize?${params.toString()}`,
|
|
122
|
+
state,
|
|
123
|
+
codeVerifier,
|
|
124
|
+
codeChallenge
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Exchange an authorization code for tokens.
|
|
129
|
+
*
|
|
130
|
+
* Called in your OAuth callback handler after the user authorizes.
|
|
131
|
+
*/
|
|
132
|
+
async exchangeCode(code, codeVerifier, redirectUri) {
|
|
133
|
+
if (!code) throw new BioError("code is required", "validation_error");
|
|
134
|
+
if (!codeVerifier) throw new BioError("codeVerifier is required", "validation_error");
|
|
135
|
+
if (!redirectUri) throw new BioError("redirectUri is required", "validation_error");
|
|
136
|
+
return this.tokenRequest({
|
|
137
|
+
grant_type: "authorization_code",
|
|
138
|
+
code,
|
|
139
|
+
redirect_uri: redirectUri,
|
|
140
|
+
client_id: this.clientId,
|
|
141
|
+
client_secret: this.clientSecret,
|
|
142
|
+
code_verifier: codeVerifier
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Refresh an expired access token using a refresh token.
|
|
147
|
+
*
|
|
148
|
+
* Returns new access_token and a rotated refresh_token.
|
|
149
|
+
*/
|
|
150
|
+
async refreshToken(refreshToken) {
|
|
151
|
+
if (!refreshToken) throw new BioError("refreshToken is required", "validation_error");
|
|
152
|
+
return this.tokenRequest({
|
|
153
|
+
grant_type: "refresh_token",
|
|
154
|
+
refresh_token: refreshToken,
|
|
155
|
+
client_id: this.clientId,
|
|
156
|
+
client_secret: this.clientSecret
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Get a client credentials token for service-to-service auth.
|
|
161
|
+
*
|
|
162
|
+
* No user context — the token identifies your OAuth client only.
|
|
163
|
+
*/
|
|
164
|
+
async getClientCredentialsToken(scopes) {
|
|
165
|
+
const params = {
|
|
166
|
+
grant_type: "client_credentials",
|
|
167
|
+
client_id: this.clientId,
|
|
168
|
+
client_secret: this.clientSecret
|
|
169
|
+
};
|
|
170
|
+
if (scopes?.length) {
|
|
171
|
+
params.scope = scopes.join(" ");
|
|
172
|
+
}
|
|
173
|
+
return this.tokenRequest(params);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Fetch the authenticated user's profile from the userinfo endpoint.
|
|
177
|
+
*/
|
|
178
|
+
async getUserInfo(accessToken) {
|
|
179
|
+
const raw = await this.request(
|
|
180
|
+
"GET",
|
|
181
|
+
"/api/oauth/userinfo",
|
|
182
|
+
void 0,
|
|
183
|
+
{ Authorization: `Bearer ${accessToken}` }
|
|
184
|
+
);
|
|
185
|
+
return mapUserInfoResponse(raw);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Revoke a token (typically the refresh token on logout).
|
|
189
|
+
*/
|
|
190
|
+
async revokeToken(token, hint) {
|
|
191
|
+
const body = new URLSearchParams({
|
|
192
|
+
token,
|
|
193
|
+
client_id: this.clientId,
|
|
194
|
+
client_secret: this.clientSecret
|
|
195
|
+
});
|
|
196
|
+
if (hint) {
|
|
197
|
+
body.set("token_type_hint", hint);
|
|
198
|
+
}
|
|
199
|
+
const response = await this.fetchWithRetry("POST", `${this.issuer}/api/oauth/revoke`, {
|
|
200
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
201
|
+
body: body.toString()
|
|
202
|
+
});
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
const json = await parseJsonResponse(response);
|
|
205
|
+
throw new BioError(
|
|
206
|
+
json.error_description ?? json.error ?? `Revocation failed (${response.status})`,
|
|
207
|
+
"revocation_error",
|
|
208
|
+
response.status
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Introspect a token to check if it's active and get user/client context.
|
|
214
|
+
*
|
|
215
|
+
* Does not require JWT_SECRET — validates against Bio-ID server.
|
|
216
|
+
*/
|
|
217
|
+
async introspect(token) {
|
|
218
|
+
const raw = await this.request("POST", "/api/auth/introspect", { token });
|
|
219
|
+
return mapIntrospectResponse(raw);
|
|
220
|
+
}
|
|
221
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
222
|
+
async tokenRequest(params) {
|
|
223
|
+
const response = await this.fetchWithRetry(
|
|
224
|
+
"POST",
|
|
225
|
+
`${this.issuer}/api/oauth/token`,
|
|
226
|
+
{
|
|
227
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
228
|
+
body: new URLSearchParams(params).toString()
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
const json = await parseJsonResponse(response);
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
throw new BioError(
|
|
234
|
+
json.error_description ?? json.error ?? `Token request failed (${response.status})`,
|
|
235
|
+
json.error ?? "token_error",
|
|
236
|
+
response.status,
|
|
237
|
+
json
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return json;
|
|
241
|
+
}
|
|
242
|
+
async request(method, path, body, extraHeaders) {
|
|
243
|
+
const headers = {
|
|
244
|
+
...extraHeaders
|
|
245
|
+
};
|
|
246
|
+
if (body) {
|
|
247
|
+
headers["Content-Type"] = "application/json";
|
|
248
|
+
}
|
|
249
|
+
const response = await this.fetchWithRetry(
|
|
250
|
+
method,
|
|
251
|
+
`${this.issuer}${path}`,
|
|
252
|
+
{
|
|
253
|
+
headers,
|
|
254
|
+
body: body ? JSON.stringify(body) : void 0
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
const json = await parseJsonResponse(response);
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
throw new BioError(
|
|
260
|
+
json.error ?? `Bio-ID returned ${response.status}`,
|
|
261
|
+
json.error ?? "api_error",
|
|
262
|
+
response.status,
|
|
263
|
+
json
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return json;
|
|
267
|
+
}
|
|
268
|
+
async fetchWithRetry(method, url, init, attempt = 0) {
|
|
269
|
+
try {
|
|
270
|
+
const response = await fetch(url, {
|
|
271
|
+
method,
|
|
272
|
+
headers: init.headers,
|
|
273
|
+
body: init.body,
|
|
274
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
275
|
+
});
|
|
276
|
+
if (response.status >= 500 && attempt < this.retries) {
|
|
277
|
+
await sleep(retryDelay(attempt));
|
|
278
|
+
return this.fetchWithRetry(method, url, init, attempt + 1);
|
|
279
|
+
}
|
|
280
|
+
return response;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (attempt < this.retries) {
|
|
283
|
+
await sleep(retryDelay(attempt));
|
|
284
|
+
return this.fetchWithRetry(method, url, init, attempt + 1);
|
|
285
|
+
}
|
|
286
|
+
const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
|
|
287
|
+
throw new BioError(
|
|
288
|
+
isTimeout ? `Request timed out after ${this.timeoutMs}ms` : err instanceof Error ? err.message : "Network error",
|
|
289
|
+
isTimeout ? "timeout" : "network_error"
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
function mapUserInfoResponse(raw) {
|
|
295
|
+
return {
|
|
296
|
+
sub: raw.sub,
|
|
297
|
+
bioId: raw.bio_id ?? raw.sub,
|
|
298
|
+
email: raw.email,
|
|
299
|
+
emailVerified: raw.email_verified ?? false,
|
|
300
|
+
name: raw.name,
|
|
301
|
+
firstName: raw.given_name,
|
|
302
|
+
lastName: raw.family_name,
|
|
303
|
+
userType: raw.user_type ?? "user",
|
|
304
|
+
roles: raw.roles ?? [],
|
|
305
|
+
permissions: raw.permissions ?? [],
|
|
306
|
+
status: raw.status ?? "active",
|
|
307
|
+
orgId: raw.org_id,
|
|
308
|
+
orgSlug: raw.org_slug,
|
|
309
|
+
organizationId: raw.organization_id,
|
|
310
|
+
organizationName: raw.organization_name,
|
|
311
|
+
departmentId: raw.department_id,
|
|
312
|
+
departmentName: raw.department_name,
|
|
313
|
+
managerId: raw.manager_id,
|
|
314
|
+
enabledModules: raw.enabled_modules,
|
|
315
|
+
jobTitle: raw.job_title,
|
|
316
|
+
phoneHome: raw.phone_home,
|
|
317
|
+
phoneWork: raw.phone_work,
|
|
318
|
+
phoneCell: raw.phone_cell,
|
|
319
|
+
phone: raw.phone_number,
|
|
320
|
+
addressHome: raw.address_home,
|
|
321
|
+
addressWork: raw.address_work,
|
|
322
|
+
messaging: raw.messaging,
|
|
323
|
+
preferences: raw.preferences,
|
|
324
|
+
lastLoginAt: raw.last_login_at
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function mapIntrospectResponse(raw) {
|
|
328
|
+
return {
|
|
329
|
+
active: raw.active,
|
|
330
|
+
user: raw.user,
|
|
331
|
+
tokenType: raw.token_type,
|
|
332
|
+
clientId: raw.client_id,
|
|
333
|
+
scopes: raw.scopes,
|
|
334
|
+
orgId: raw.org_id ?? raw.orgId,
|
|
335
|
+
orgSlug: raw.org_slug ?? raw.orgSlug,
|
|
336
|
+
organizationName: raw.organization_name ?? raw.organizationName
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/admin.ts
|
|
341
|
+
var DEFAULT_BASE_URL = "https://bio.tawa.insureco.io";
|
|
342
|
+
var DEFAULT_TIMEOUT_MS2 = 1e4;
|
|
343
|
+
var BioAdmin = class _BioAdmin {
|
|
344
|
+
baseUrl;
|
|
345
|
+
internalKey;
|
|
346
|
+
accessTokenFn;
|
|
347
|
+
retries;
|
|
348
|
+
timeoutMs;
|
|
349
|
+
constructor(config) {
|
|
350
|
+
if (!config.internalKey && !config.accessTokenFn) {
|
|
351
|
+
throw new BioError(
|
|
352
|
+
"Either internalKey or accessTokenFn is required",
|
|
353
|
+
"config_error"
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
357
|
+
this.internalKey = config.internalKey;
|
|
358
|
+
this.accessTokenFn = config.accessTokenFn;
|
|
359
|
+
this.retries = config.retries ?? 2;
|
|
360
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Create a BioAdmin from environment variables.
|
|
364
|
+
*
|
|
365
|
+
* Reads: BIO_ID_URL, INTERNAL_API_KEY
|
|
366
|
+
*/
|
|
367
|
+
static fromEnv() {
|
|
368
|
+
const internalKey = process.env.INTERNAL_API_KEY;
|
|
369
|
+
if (!internalKey) {
|
|
370
|
+
throw new BioError(
|
|
371
|
+
"INTERNAL_API_KEY environment variable is required",
|
|
372
|
+
"config_error"
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return new _BioAdmin({
|
|
376
|
+
baseUrl: process.env.BIO_ID_URL,
|
|
377
|
+
internalKey
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
// ── Users ────────────────────────────────────────────────────────────────
|
|
381
|
+
/** List users with optional filters */
|
|
382
|
+
async listUsers(filters) {
|
|
383
|
+
const params = new URLSearchParams();
|
|
384
|
+
if (filters?.search) params.set("search", filters.search);
|
|
385
|
+
if (filters?.status) params.set("status", filters.status);
|
|
386
|
+
if (filters?.userType) params.set("userType", filters.userType);
|
|
387
|
+
if (filters?.organizationId) params.set("organizationId", filters.organizationId);
|
|
388
|
+
if (filters?.page) params.set("page", String(filters.page));
|
|
389
|
+
if (filters?.limit) params.set("limit", String(filters.limit));
|
|
390
|
+
const qs = params.toString();
|
|
391
|
+
const path = qs ? `/api/admin/users?${qs}` : "/api/admin/users";
|
|
392
|
+
const result = await this.request("GET", path);
|
|
393
|
+
return result.data ?? [];
|
|
394
|
+
}
|
|
395
|
+
/** Get a single user by bioId */
|
|
396
|
+
async getUser(bioId) {
|
|
397
|
+
const result = await this.request(
|
|
398
|
+
"GET",
|
|
399
|
+
`/api/admin/users/${encodeURIComponent(bioId)}`
|
|
400
|
+
);
|
|
401
|
+
if (!result.data) {
|
|
402
|
+
throw new BioError(`User ${bioId} not found`, "not_found", 404);
|
|
403
|
+
}
|
|
404
|
+
return result.data;
|
|
405
|
+
}
|
|
406
|
+
/** Update a user's profile, roles, or status */
|
|
407
|
+
async updateUser(bioId, data) {
|
|
408
|
+
const result = await this.request(
|
|
409
|
+
"PATCH",
|
|
410
|
+
`/api/admin/users/${encodeURIComponent(bioId)}`,
|
|
411
|
+
data
|
|
412
|
+
);
|
|
413
|
+
if (!result.data) {
|
|
414
|
+
throw new BioError(`Failed to update user ${bioId}`, "update_failed");
|
|
415
|
+
}
|
|
416
|
+
return result.data;
|
|
417
|
+
}
|
|
418
|
+
// ── Departments ──────────────────────────────────────────────────────────
|
|
419
|
+
/** List all departments */
|
|
420
|
+
async listDepartments() {
|
|
421
|
+
const result = await this.request(
|
|
422
|
+
"GET",
|
|
423
|
+
"/api/admin/departments"
|
|
424
|
+
);
|
|
425
|
+
return result.data ?? [];
|
|
426
|
+
}
|
|
427
|
+
/** Create a new department */
|
|
428
|
+
async createDepartment(data) {
|
|
429
|
+
const result = await this.request(
|
|
430
|
+
"POST",
|
|
431
|
+
"/api/admin/departments",
|
|
432
|
+
data
|
|
433
|
+
);
|
|
434
|
+
if (!result.data) {
|
|
435
|
+
throw new BioError("Failed to create department", "create_failed");
|
|
436
|
+
}
|
|
437
|
+
return result.data;
|
|
438
|
+
}
|
|
439
|
+
// ── Roles ────────────────────────────────────────────────────────────────
|
|
440
|
+
/** List all roles */
|
|
441
|
+
async listRoles() {
|
|
442
|
+
const result = await this.request(
|
|
443
|
+
"GET",
|
|
444
|
+
"/api/admin/roles"
|
|
445
|
+
);
|
|
446
|
+
return result.data ?? [];
|
|
447
|
+
}
|
|
448
|
+
/** Create a new role */
|
|
449
|
+
async createRole(data) {
|
|
450
|
+
const result = await this.request(
|
|
451
|
+
"POST",
|
|
452
|
+
"/api/admin/roles",
|
|
453
|
+
data
|
|
454
|
+
);
|
|
455
|
+
if (!result.data) {
|
|
456
|
+
throw new BioError("Failed to create role", "create_failed");
|
|
457
|
+
}
|
|
458
|
+
return result.data;
|
|
459
|
+
}
|
|
460
|
+
// ── OAuth Clients ────────────────────────────────────────────────────────
|
|
461
|
+
/** List all OAuth clients */
|
|
462
|
+
async listClients() {
|
|
463
|
+
const result = await this.request(
|
|
464
|
+
"GET",
|
|
465
|
+
"/api/admin/oauth-clients"
|
|
466
|
+
);
|
|
467
|
+
return result.data ?? [];
|
|
468
|
+
}
|
|
469
|
+
/** Create a new OAuth client */
|
|
470
|
+
async createClient(data) {
|
|
471
|
+
const result = await this.request(
|
|
472
|
+
"POST",
|
|
473
|
+
"/api/admin/oauth-clients",
|
|
474
|
+
data
|
|
475
|
+
);
|
|
476
|
+
if (!result.data) {
|
|
477
|
+
throw new BioError("Failed to create OAuth client", "create_failed");
|
|
478
|
+
}
|
|
479
|
+
return result.data;
|
|
480
|
+
}
|
|
481
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
482
|
+
async request(method, path, body, attempt = 0) {
|
|
483
|
+
const headers = {};
|
|
484
|
+
if (this.internalKey) {
|
|
485
|
+
headers["x-internal-key"] = this.internalKey;
|
|
486
|
+
} else if (this.accessTokenFn) {
|
|
487
|
+
headers["Authorization"] = `Bearer ${await this.accessTokenFn()}`;
|
|
488
|
+
}
|
|
489
|
+
if (body) {
|
|
490
|
+
headers["Content-Type"] = "application/json";
|
|
491
|
+
}
|
|
492
|
+
let response;
|
|
493
|
+
try {
|
|
494
|
+
response = await fetch(`${this.baseUrl}${path}`, {
|
|
495
|
+
method,
|
|
496
|
+
headers,
|
|
497
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
498
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
499
|
+
});
|
|
500
|
+
} catch (err) {
|
|
501
|
+
if (attempt < this.retries) {
|
|
502
|
+
await sleep(retryDelay(attempt));
|
|
503
|
+
return this.request(method, path, body, attempt + 1);
|
|
504
|
+
}
|
|
505
|
+
const isTimeout = err instanceof DOMException && err.name === "TimeoutError";
|
|
506
|
+
throw new BioError(
|
|
507
|
+
isTimeout ? `Request timed out after ${this.timeoutMs}ms` : err instanceof Error ? err.message : "Network error",
|
|
508
|
+
isTimeout ? "timeout" : "network_error"
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
let json;
|
|
512
|
+
try {
|
|
513
|
+
json = await response.json();
|
|
514
|
+
} catch {
|
|
515
|
+
if (response.status >= 500 && attempt < this.retries) {
|
|
516
|
+
await sleep(retryDelay(attempt));
|
|
517
|
+
return this.request(method, path, body, attempt + 1);
|
|
518
|
+
}
|
|
519
|
+
throw new BioError(
|
|
520
|
+
`Bio-ID returned ${response.status} with non-JSON body`,
|
|
521
|
+
"parse_error",
|
|
522
|
+
response.status
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
if (response.status >= 500 && attempt < this.retries) {
|
|
527
|
+
await sleep(retryDelay(attempt));
|
|
528
|
+
return this.request(method, path, body, attempt + 1);
|
|
529
|
+
}
|
|
530
|
+
const errorBody = json;
|
|
531
|
+
throw new BioError(
|
|
532
|
+
errorBody.error ?? `Bio-ID returned ${response.status}`,
|
|
533
|
+
errorBody.error ?? "api_error",
|
|
534
|
+
response.status,
|
|
535
|
+
errorBody
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return json;
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// src/jwt.ts
|
|
543
|
+
import crypto3 from "crypto";
|
|
544
|
+
var DEFAULT_ISSUERS = [
|
|
545
|
+
"https://bio.insureco.io",
|
|
546
|
+
"https://bio.tawa.insureco.io",
|
|
547
|
+
"http://localhost:6100"
|
|
548
|
+
];
|
|
549
|
+
function base64UrlDecode(str) {
|
|
550
|
+
return Buffer.from(str, "base64url").toString("utf8");
|
|
551
|
+
}
|
|
552
|
+
function verifyToken(token, secret, options) {
|
|
553
|
+
const parts = token.split(".");
|
|
554
|
+
if (parts.length !== 3) {
|
|
555
|
+
throw new BioError("Malformed JWT: expected 3 parts", "invalid_token");
|
|
556
|
+
}
|
|
557
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
558
|
+
const header = JSON.parse(base64UrlDecode(headerB64));
|
|
559
|
+
if (header.alg !== "HS256") {
|
|
560
|
+
throw new BioError(`Unsupported algorithm: ${header.alg}`, "unsupported_alg");
|
|
561
|
+
}
|
|
562
|
+
const data = `${headerB64}.${payloadB64}`;
|
|
563
|
+
const expectedBuf = crypto3.createHmac("sha256", secret).update(data).digest();
|
|
564
|
+
const actualBuf = Buffer.from(signatureB64, "base64url");
|
|
565
|
+
if (expectedBuf.length !== actualBuf.length || !crypto3.timingSafeEqual(expectedBuf, actualBuf)) {
|
|
566
|
+
throw new BioError("Invalid JWT signature", "invalid_signature");
|
|
567
|
+
}
|
|
568
|
+
const payload = JSON.parse(base64UrlDecode(payloadB64));
|
|
569
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
570
|
+
if (!payload.exp) {
|
|
571
|
+
throw new BioError("Token missing expiration claim", "invalid_token");
|
|
572
|
+
}
|
|
573
|
+
if (payload.exp < now) {
|
|
574
|
+
throw new BioError("Token has expired", "token_expired");
|
|
575
|
+
}
|
|
576
|
+
if (options?.issuer) {
|
|
577
|
+
if (payload.iss !== options.issuer) {
|
|
578
|
+
throw new BioError(`Invalid issuer: ${payload.iss}`, "invalid_issuer");
|
|
579
|
+
}
|
|
580
|
+
} else if (!DEFAULT_ISSUERS.includes(payload.iss)) {
|
|
581
|
+
throw new BioError(`Unknown issuer: ${payload.iss}`, "invalid_issuer");
|
|
582
|
+
}
|
|
583
|
+
if (options?.audience && payload.aud !== options.audience) {
|
|
584
|
+
throw new BioError(`Invalid audience: ${payload.aud}`, "invalid_audience");
|
|
585
|
+
}
|
|
586
|
+
return payload;
|
|
587
|
+
}
|
|
588
|
+
function decodeToken(token) {
|
|
589
|
+
try {
|
|
590
|
+
const parts = token.split(".");
|
|
591
|
+
if (parts.length !== 3) return null;
|
|
592
|
+
return JSON.parse(base64UrlDecode(parts[1]));
|
|
593
|
+
} catch {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function isTokenExpired(token, bufferSeconds = 30) {
|
|
598
|
+
const payload = decodeToken(token);
|
|
599
|
+
if (!payload?.exp) return true;
|
|
600
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
601
|
+
return payload.exp < now + bufferSeconds;
|
|
602
|
+
}
|
|
603
|
+
export {
|
|
604
|
+
BioAdmin,
|
|
605
|
+
BioAuth,
|
|
606
|
+
BioError,
|
|
607
|
+
decodeToken,
|
|
608
|
+
generatePKCE,
|
|
609
|
+
isTokenExpired,
|
|
610
|
+
verifyToken
|
|
611
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@insureco/bio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SDK for Bio-ID SSO integration on the Tawa platform",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"lint": "tsc --noEmit",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"insureco",
|
|
27
|
+
"tawa",
|
|
28
|
+
"bio-id",
|
|
29
|
+
"oauth",
|
|
30
|
+
"oidc",
|
|
31
|
+
"sso",
|
|
32
|
+
"authentication"
|
|
33
|
+
],
|
|
34
|
+
"author": "InsurEco",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
41
|
+
"tsup": "^8.0.0",
|
|
42
|
+
"typescript": "^5.4.0",
|
|
43
|
+
"vitest": "^2.0.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|