@insureco/bio 0.1.0 → 0.3.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 CHANGED
@@ -4,7 +4,7 @@ interface BioAuthConfig {
4
4
  clientId: string;
5
5
  /** OAuth client secret (env: BIO_CLIENT_SECRET) */
6
6
  clientSecret: string;
7
- /** Bio-ID issuer URL (env: BIO_ID_URL, default: https://bio.tawa.insureco.io) */
7
+ /** Bio-ID issuer URL (env: BIO_ID_URL, default: https://bio.tawa.pro) */
8
8
  issuer?: string;
9
9
  /** Number of retry attempts on transient failures (default: 2) */
10
10
  retries?: number;
@@ -13,7 +13,7 @@ interface BioAuthConfig {
13
13
  }
14
14
  /** Configuration for BioAdmin (admin API client) */
15
15
  interface BioAdminConfig {
16
- /** Bio-ID base URL (env: BIO_ID_URL, default: https://bio.tawa.insureco.io) */
16
+ /** Bio-ID base URL (env: BIO_ID_URL, default: https://bio.tawa.pro) */
17
17
  baseUrl?: string;
18
18
  /** Internal API key for service-to-service auth (env: INTERNAL_API_KEY) */
19
19
  internalKey?: string;
@@ -32,6 +32,8 @@ interface AuthorizeOptions {
32
32
  scopes?: string[];
33
33
  /** CSRF state parameter (auto-generated if not provided) */
34
34
  state?: string;
35
+ /** Optional org slug to pre-select during authorization (for multi-org users) */
36
+ organization?: string;
35
37
  }
36
38
  /** Result from getAuthorizationUrl() */
37
39
  interface AuthorizeResult {
@@ -85,6 +87,12 @@ interface BioTokenPayload {
85
87
  permissions: string[];
86
88
  orgId?: string;
87
89
  orgSlug?: string;
90
+ /** Org-specific role slugs within the user's active org (from OrgMembership) */
91
+ orgRoles?: string[];
92
+ /** Job title at the active org (from OrgMembership.jobTitle) */
93
+ orgTitle?: string;
94
+ /** Additional modules granted by the org specifically (from OrgMembership.enabled_modules) */
95
+ orgModules?: string[];
88
96
  client_id: string;
89
97
  scope: string;
90
98
  enabled_modules?: string[];
@@ -107,13 +115,22 @@ interface BioClientTokenPayload {
107
115
  orgId?: string;
108
116
  orgSlug?: string;
109
117
  }
110
- /** Options for local JWT verification */
118
+ /** Options for local JWT verification (HS256) */
111
119
  interface VerifyOptions {
112
120
  /** Expected issuer (default: config issuer) */
113
121
  issuer?: string;
114
122
  /** Expected audience (client_id) */
115
123
  audience?: string;
116
124
  }
125
+ /** Options for JWKS-based JWT verification (RS256) */
126
+ interface JWKSVerifyOptions {
127
+ /** JWKS endpoint URL (default: https://bio.tawa.pro/.well-known/jwks.json) */
128
+ jwksUri?: string;
129
+ /** Expected issuer — defaults to accepting bio.insureco.io, bio.tawa.insureco.io, and bio.tawa.pro */
130
+ issuer?: string;
131
+ /** Expected audience (client_id) */
132
+ audience?: string;
133
+ }
117
134
  /** User profile from /api/oauth/userinfo or admin API */
118
135
  interface BioUser {
119
136
  sub: string;
@@ -367,8 +384,10 @@ declare function generatePKCE(): {
367
384
  };
368
385
 
369
386
  /**
370
- * Verify a Bio-ID JWT access token using HS256.
387
+ * Verify a Bio-ID JWT access token using HS256 (legacy / internal use).
371
388
  * Checks algorithm, signature (constant-time), expiration, issuer, and audience.
389
+ *
390
+ * @deprecated Prefer verifyTokenJWKS() for RS256 tokens issued by Bio-ID ≥ 0.2.
372
391
  */
373
392
  declare function verifyToken(token: string, secret: string, options?: VerifyOptions): BioTokenPayload;
374
393
  /**
@@ -381,5 +400,20 @@ declare function decodeToken(token: string): BioTokenPayload | null;
381
400
  * Returns true if expired or unparseable.
382
401
  */
383
402
  declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
403
+ /**
404
+ * Verify a Bio-ID JWT access token using RS256 and the public JWKS endpoint.
405
+ *
406
+ * No shared secret required — fetches Bio-ID's public key and verifies locally.
407
+ * The JWKS is cached in-process for 24 hours and auto-refreshed on key rotation.
408
+ *
409
+ * @example
410
+ * ```ts
411
+ * import { verifyTokenJWKS } from '@insureco/bio'
412
+ *
413
+ * const payload = await verifyTokenJWKS(req.headers.authorization.slice(7))
414
+ * console.log(payload.bioId, payload.orgSlug)
415
+ * ```
416
+ */
417
+ declare function verifyTokenJWKS(token: string, options?: JWKSVerifyOptions): Promise<BioTokenPayload>;
384
418
 
385
- export { type AdminResponse, type AuthorizeOptions, type AuthorizeResult, type BioAddress, BioAdmin, type BioAdminConfig, BioAuth, type BioAuthConfig, type BioClientTokenPayload, type BioDepartment, BioError, type BioMessaging, type BioOAuthClient, type BioRole, type BioTokenPayload, type BioUser, type CreateClientData, type CreateDepartmentData, type CreateRoleData, type IntrospectResult, type TokenResponse, type UpdateUserData, type UserFilters, type VerifyOptions, decodeToken, generatePKCE, isTokenExpired, verifyToken };
419
+ export { type AdminResponse, type AuthorizeOptions, type AuthorizeResult, type BioAddress, BioAdmin, type BioAdminConfig, BioAuth, type BioAuthConfig, type BioClientTokenPayload, type BioDepartment, BioError, type BioMessaging, type BioOAuthClient, type BioRole, type BioTokenPayload, type BioUser, type CreateClientData, type CreateDepartmentData, type CreateRoleData, type IntrospectResult, type JWKSVerifyOptions, type TokenResponse, type UpdateUserData, type UserFilters, type VerifyOptions, decodeToken, generatePKCE, isTokenExpired, verifyToken, verifyTokenJWKS };
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ interface BioAuthConfig {
4
4
  clientId: string;
5
5
  /** OAuth client secret (env: BIO_CLIENT_SECRET) */
6
6
  clientSecret: string;
7
- /** Bio-ID issuer URL (env: BIO_ID_URL, default: https://bio.tawa.insureco.io) */
7
+ /** Bio-ID issuer URL (env: BIO_ID_URL, default: https://bio.tawa.pro) */
8
8
  issuer?: string;
9
9
  /** Number of retry attempts on transient failures (default: 2) */
10
10
  retries?: number;
@@ -13,7 +13,7 @@ interface BioAuthConfig {
13
13
  }
14
14
  /** Configuration for BioAdmin (admin API client) */
15
15
  interface BioAdminConfig {
16
- /** Bio-ID base URL (env: BIO_ID_URL, default: https://bio.tawa.insureco.io) */
16
+ /** Bio-ID base URL (env: BIO_ID_URL, default: https://bio.tawa.pro) */
17
17
  baseUrl?: string;
18
18
  /** Internal API key for service-to-service auth (env: INTERNAL_API_KEY) */
19
19
  internalKey?: string;
@@ -32,6 +32,8 @@ interface AuthorizeOptions {
32
32
  scopes?: string[];
33
33
  /** CSRF state parameter (auto-generated if not provided) */
34
34
  state?: string;
35
+ /** Optional org slug to pre-select during authorization (for multi-org users) */
36
+ organization?: string;
35
37
  }
36
38
  /** Result from getAuthorizationUrl() */
37
39
  interface AuthorizeResult {
@@ -85,6 +87,12 @@ interface BioTokenPayload {
85
87
  permissions: string[];
86
88
  orgId?: string;
87
89
  orgSlug?: string;
90
+ /** Org-specific role slugs within the user's active org (from OrgMembership) */
91
+ orgRoles?: string[];
92
+ /** Job title at the active org (from OrgMembership.jobTitle) */
93
+ orgTitle?: string;
94
+ /** Additional modules granted by the org specifically (from OrgMembership.enabled_modules) */
95
+ orgModules?: string[];
88
96
  client_id: string;
89
97
  scope: string;
90
98
  enabled_modules?: string[];
@@ -107,13 +115,22 @@ interface BioClientTokenPayload {
107
115
  orgId?: string;
108
116
  orgSlug?: string;
109
117
  }
110
- /** Options for local JWT verification */
118
+ /** Options for local JWT verification (HS256) */
111
119
  interface VerifyOptions {
112
120
  /** Expected issuer (default: config issuer) */
113
121
  issuer?: string;
114
122
  /** Expected audience (client_id) */
115
123
  audience?: string;
116
124
  }
125
+ /** Options for JWKS-based JWT verification (RS256) */
126
+ interface JWKSVerifyOptions {
127
+ /** JWKS endpoint URL (default: https://bio.tawa.pro/.well-known/jwks.json) */
128
+ jwksUri?: string;
129
+ /** Expected issuer — defaults to accepting bio.insureco.io, bio.tawa.insureco.io, and bio.tawa.pro */
130
+ issuer?: string;
131
+ /** Expected audience (client_id) */
132
+ audience?: string;
133
+ }
117
134
  /** User profile from /api/oauth/userinfo or admin API */
118
135
  interface BioUser {
119
136
  sub: string;
@@ -367,8 +384,10 @@ declare function generatePKCE(): {
367
384
  };
368
385
 
369
386
  /**
370
- * Verify a Bio-ID JWT access token using HS256.
387
+ * Verify a Bio-ID JWT access token using HS256 (legacy / internal use).
371
388
  * Checks algorithm, signature (constant-time), expiration, issuer, and audience.
389
+ *
390
+ * @deprecated Prefer verifyTokenJWKS() for RS256 tokens issued by Bio-ID ≥ 0.2.
372
391
  */
373
392
  declare function verifyToken(token: string, secret: string, options?: VerifyOptions): BioTokenPayload;
374
393
  /**
@@ -381,5 +400,20 @@ declare function decodeToken(token: string): BioTokenPayload | null;
381
400
  * Returns true if expired or unparseable.
382
401
  */
383
402
  declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
403
+ /**
404
+ * Verify a Bio-ID JWT access token using RS256 and the public JWKS endpoint.
405
+ *
406
+ * No shared secret required — fetches Bio-ID's public key and verifies locally.
407
+ * The JWKS is cached in-process for 24 hours and auto-refreshed on key rotation.
408
+ *
409
+ * @example
410
+ * ```ts
411
+ * import { verifyTokenJWKS } from '@insureco/bio'
412
+ *
413
+ * const payload = await verifyTokenJWKS(req.headers.authorization.slice(7))
414
+ * console.log(payload.bioId, payload.orgSlug)
415
+ * ```
416
+ */
417
+ declare function verifyTokenJWKS(token: string, options?: JWKSVerifyOptions): Promise<BioTokenPayload>;
384
418
 
385
- export { type AdminResponse, type AuthorizeOptions, type AuthorizeResult, type BioAddress, BioAdmin, type BioAdminConfig, BioAuth, type BioAuthConfig, type BioClientTokenPayload, type BioDepartment, BioError, type BioMessaging, type BioOAuthClient, type BioRole, type BioTokenPayload, type BioUser, type CreateClientData, type CreateDepartmentData, type CreateRoleData, type IntrospectResult, type TokenResponse, type UpdateUserData, type UserFilters, type VerifyOptions, decodeToken, generatePKCE, isTokenExpired, verifyToken };
419
+ export { type AdminResponse, type AuthorizeOptions, type AuthorizeResult, type BioAddress, BioAdmin, type BioAdminConfig, BioAuth, type BioAuthConfig, type BioClientTokenPayload, type BioDepartment, BioError, type BioMessaging, type BioOAuthClient, type BioRole, type BioTokenPayload, type BioUser, type CreateClientData, type CreateDepartmentData, type CreateRoleData, type IntrospectResult, type JWKSVerifyOptions, type TokenResponse, type UpdateUserData, type UserFilters, type VerifyOptions, decodeToken, generatePKCE, isTokenExpired, verifyToken, verifyTokenJWKS };
package/dist/index.js CHANGED
@@ -36,7 +36,8 @@ __export(index_exports, {
36
36
  decodeToken: () => decodeToken,
37
37
  generatePKCE: () => generatePKCE,
38
38
  isTokenExpired: () => isTokenExpired,
39
- verifyToken: () => verifyToken
39
+ verifyToken: () => verifyToken,
40
+ verifyTokenJWKS: () => verifyTokenJWKS
40
41
  });
41
42
  module.exports = __toCommonJS(index_exports);
42
43
 
@@ -89,7 +90,7 @@ async function parseJsonResponse(response) {
89
90
  }
90
91
 
91
92
  // src/auth.ts
92
- var DEFAULT_ISSUER = "https://bio.tawa.insureco.io";
93
+ var DEFAULT_ISSUER = "https://bio.tawa.pro";
93
94
  var DEFAULT_SCOPES = ["openid", "profile", "email"];
94
95
  var DEFAULT_TIMEOUT_MS = 1e4;
95
96
  var BioAuth = class _BioAuth {
@@ -159,6 +160,9 @@ var BioAuth = class _BioAuth {
159
160
  code_challenge: codeChallenge,
160
161
  code_challenge_method: "S256"
161
162
  });
163
+ if (opts.organization) {
164
+ params.set("organization", opts.organization);
165
+ }
162
166
  return {
163
167
  url: `${this.issuer}/oauth/authorize?${params.toString()}`,
164
168
  state,
@@ -380,7 +384,7 @@ function mapIntrospectResponse(raw) {
380
384
  }
381
385
 
382
386
  // src/admin.ts
383
- var DEFAULT_BASE_URL = "https://bio.tawa.insureco.io";
387
+ var DEFAULT_BASE_URL = "https://bio.tawa.pro";
384
388
  var DEFAULT_TIMEOUT_MS2 = 1e4;
385
389
  var BioAdmin = class _BioAdmin {
386
390
  baseUrl;
@@ -586,8 +590,60 @@ var import_node_crypto3 = __toESM(require("crypto"));
586
590
  var DEFAULT_ISSUERS = [
587
591
  "https://bio.insureco.io",
588
592
  "https://bio.tawa.insureco.io",
593
+ "https://bio.tawa.pro",
589
594
  "http://localhost:6100"
590
595
  ];
596
+ var DEFAULT_JWKS_URI = "https://bio.tawa.pro/.well-known/jwks.json";
597
+ var JWKS_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
598
+ var jwksCache = /* @__PURE__ */ new Map();
599
+ async function fetchJWKS(uri) {
600
+ const cached = jwksCache.get(uri);
601
+ if (cached && Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS) {
602
+ return cached.keys;
603
+ }
604
+ const response = await fetch(uri, {
605
+ headers: { Accept: "application/json" },
606
+ signal: AbortSignal.timeout(5e3)
607
+ });
608
+ if (!response.ok) {
609
+ throw new BioError(`JWKS fetch failed: ${response.status}`, "jwks_fetch_failed");
610
+ }
611
+ const data = await response.json();
612
+ jwksCache.set(uri, { keys: data.keys, fetchedAt: Date.now() });
613
+ return data.keys;
614
+ }
615
+ async function importRS256Key(jwk) {
616
+ return globalThis.crypto.subtle.importKey(
617
+ "jwk",
618
+ jwk,
619
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
620
+ false,
621
+ ["verify"]
622
+ );
623
+ }
624
+ async function verifyRS256Signature(headerB64, payloadB64, signatureB64, jwksUri, kid, retrying = false) {
625
+ const keys = await fetchJWKS(jwksUri);
626
+ const targetKey = kid ? keys.find((k) => k.kid === kid) ?? keys[0] : keys[0];
627
+ if (!targetKey) {
628
+ throw new BioError("No matching key found in JWKS", "invalid_token");
629
+ }
630
+ const cryptoKey = await importRS256Key(targetKey);
631
+ const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
632
+ const signature = Buffer.from(signatureB64, "base64url");
633
+ const valid = await globalThis.crypto.subtle.verify(
634
+ "RSASSA-PKCS1-v1_5",
635
+ cryptoKey,
636
+ signature,
637
+ data
638
+ );
639
+ if (!valid) {
640
+ if (!retrying) {
641
+ jwksCache.delete(jwksUri);
642
+ return verifyRS256Signature(headerB64, payloadB64, signatureB64, jwksUri, kid, true);
643
+ }
644
+ throw new BioError("Invalid JWT signature", "invalid_signature");
645
+ }
646
+ }
591
647
  function base64UrlDecode(str) {
592
648
  return Buffer.from(str, "base64url").toString("utf8");
593
649
  }
@@ -598,6 +654,12 @@ function verifyToken(token, secret, options) {
598
654
  }
599
655
  const [headerB64, payloadB64, signatureB64] = parts;
600
656
  const header = JSON.parse(base64UrlDecode(headerB64));
657
+ if (header.alg === "RS256") {
658
+ throw new BioError(
659
+ "Token uses RS256 \u2014 use verifyTokenJWKS() instead of verifyToken()",
660
+ "unsupported_alg"
661
+ );
662
+ }
601
663
  if (header.alg !== "HS256") {
602
664
  throw new BioError(`Unsupported algorithm: ${header.alg}`, "unsupported_alg");
603
665
  }
@@ -642,6 +704,39 @@ function isTokenExpired(token, bufferSeconds = 30) {
642
704
  const now = Math.floor(Date.now() / 1e3);
643
705
  return payload.exp < now + bufferSeconds;
644
706
  }
707
+ async function verifyTokenJWKS(token, options) {
708
+ const parts = token.split(".");
709
+ if (parts.length !== 3) {
710
+ throw new BioError("Malformed JWT: expected 3 parts", "invalid_token");
711
+ }
712
+ const [headerB64, payloadB64, signatureB64] = parts;
713
+ let header;
714
+ try {
715
+ header = JSON.parse(base64UrlDecode(headerB64));
716
+ } catch {
717
+ throw new BioError("Malformed JWT: invalid header encoding", "invalid_token");
718
+ }
719
+ if (header.alg !== "RS256") {
720
+ throw new BioError(
721
+ `Expected RS256 token, got ${header.alg}. Use verifyToken() for HS256.`,
722
+ "unsupported_alg"
723
+ );
724
+ }
725
+ const jwksUri = options?.jwksUri ?? DEFAULT_JWKS_URI;
726
+ await verifyRS256Signature(headerB64, payloadB64, signatureB64, jwksUri, header.kid);
727
+ const payload = JSON.parse(base64UrlDecode(payloadB64));
728
+ const now = Math.floor(Date.now() / 1e3);
729
+ if (!payload.exp) throw new BioError("Token missing expiration claim", "invalid_token");
730
+ if (payload.exp < now) throw new BioError("Token has expired", "token_expired");
731
+ const allowedIssuers = options?.issuer ? [options.issuer] : DEFAULT_ISSUERS;
732
+ if (!allowedIssuers.includes(payload.iss)) {
733
+ throw new BioError(`Unknown issuer: ${payload.iss}`, "invalid_issuer");
734
+ }
735
+ if (options?.audience && payload.aud !== options.audience) {
736
+ throw new BioError(`Invalid audience: ${payload.aud}`, "invalid_audience");
737
+ }
738
+ return payload;
739
+ }
645
740
  // Annotate the CommonJS export names for ESM import in node:
646
741
  0 && (module.exports = {
647
742
  BioAdmin,
@@ -650,5 +745,6 @@ function isTokenExpired(token, bufferSeconds = 30) {
650
745
  decodeToken,
651
746
  generatePKCE,
652
747
  isTokenExpired,
653
- verifyToken
748
+ verifyToken,
749
+ verifyTokenJWKS
654
750
  });
package/dist/index.mjs CHANGED
@@ -47,7 +47,7 @@ async function parseJsonResponse(response) {
47
47
  }
48
48
 
49
49
  // src/auth.ts
50
- var DEFAULT_ISSUER = "https://bio.tawa.insureco.io";
50
+ var DEFAULT_ISSUER = "https://bio.tawa.pro";
51
51
  var DEFAULT_SCOPES = ["openid", "profile", "email"];
52
52
  var DEFAULT_TIMEOUT_MS = 1e4;
53
53
  var BioAuth = class _BioAuth {
@@ -117,6 +117,9 @@ var BioAuth = class _BioAuth {
117
117
  code_challenge: codeChallenge,
118
118
  code_challenge_method: "S256"
119
119
  });
120
+ if (opts.organization) {
121
+ params.set("organization", opts.organization);
122
+ }
120
123
  return {
121
124
  url: `${this.issuer}/oauth/authorize?${params.toString()}`,
122
125
  state,
@@ -338,7 +341,7 @@ function mapIntrospectResponse(raw) {
338
341
  }
339
342
 
340
343
  // src/admin.ts
341
- var DEFAULT_BASE_URL = "https://bio.tawa.insureco.io";
344
+ var DEFAULT_BASE_URL = "https://bio.tawa.pro";
342
345
  var DEFAULT_TIMEOUT_MS2 = 1e4;
343
346
  var BioAdmin = class _BioAdmin {
344
347
  baseUrl;
@@ -544,8 +547,60 @@ import crypto3 from "crypto";
544
547
  var DEFAULT_ISSUERS = [
545
548
  "https://bio.insureco.io",
546
549
  "https://bio.tawa.insureco.io",
550
+ "https://bio.tawa.pro",
547
551
  "http://localhost:6100"
548
552
  ];
553
+ var DEFAULT_JWKS_URI = "https://bio.tawa.pro/.well-known/jwks.json";
554
+ var JWKS_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
555
+ var jwksCache = /* @__PURE__ */ new Map();
556
+ async function fetchJWKS(uri) {
557
+ const cached = jwksCache.get(uri);
558
+ if (cached && Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS) {
559
+ return cached.keys;
560
+ }
561
+ const response = await fetch(uri, {
562
+ headers: { Accept: "application/json" },
563
+ signal: AbortSignal.timeout(5e3)
564
+ });
565
+ if (!response.ok) {
566
+ throw new BioError(`JWKS fetch failed: ${response.status}`, "jwks_fetch_failed");
567
+ }
568
+ const data = await response.json();
569
+ jwksCache.set(uri, { keys: data.keys, fetchedAt: Date.now() });
570
+ return data.keys;
571
+ }
572
+ async function importRS256Key(jwk) {
573
+ return globalThis.crypto.subtle.importKey(
574
+ "jwk",
575
+ jwk,
576
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
577
+ false,
578
+ ["verify"]
579
+ );
580
+ }
581
+ async function verifyRS256Signature(headerB64, payloadB64, signatureB64, jwksUri, kid, retrying = false) {
582
+ const keys = await fetchJWKS(jwksUri);
583
+ const targetKey = kid ? keys.find((k) => k.kid === kid) ?? keys[0] : keys[0];
584
+ if (!targetKey) {
585
+ throw new BioError("No matching key found in JWKS", "invalid_token");
586
+ }
587
+ const cryptoKey = await importRS256Key(targetKey);
588
+ const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
589
+ const signature = Buffer.from(signatureB64, "base64url");
590
+ const valid = await globalThis.crypto.subtle.verify(
591
+ "RSASSA-PKCS1-v1_5",
592
+ cryptoKey,
593
+ signature,
594
+ data
595
+ );
596
+ if (!valid) {
597
+ if (!retrying) {
598
+ jwksCache.delete(jwksUri);
599
+ return verifyRS256Signature(headerB64, payloadB64, signatureB64, jwksUri, kid, true);
600
+ }
601
+ throw new BioError("Invalid JWT signature", "invalid_signature");
602
+ }
603
+ }
549
604
  function base64UrlDecode(str) {
550
605
  return Buffer.from(str, "base64url").toString("utf8");
551
606
  }
@@ -556,6 +611,12 @@ function verifyToken(token, secret, options) {
556
611
  }
557
612
  const [headerB64, payloadB64, signatureB64] = parts;
558
613
  const header = JSON.parse(base64UrlDecode(headerB64));
614
+ if (header.alg === "RS256") {
615
+ throw new BioError(
616
+ "Token uses RS256 \u2014 use verifyTokenJWKS() instead of verifyToken()",
617
+ "unsupported_alg"
618
+ );
619
+ }
559
620
  if (header.alg !== "HS256") {
560
621
  throw new BioError(`Unsupported algorithm: ${header.alg}`, "unsupported_alg");
561
622
  }
@@ -600,6 +661,39 @@ function isTokenExpired(token, bufferSeconds = 30) {
600
661
  const now = Math.floor(Date.now() / 1e3);
601
662
  return payload.exp < now + bufferSeconds;
602
663
  }
664
+ async function verifyTokenJWKS(token, options) {
665
+ const parts = token.split(".");
666
+ if (parts.length !== 3) {
667
+ throw new BioError("Malformed JWT: expected 3 parts", "invalid_token");
668
+ }
669
+ const [headerB64, payloadB64, signatureB64] = parts;
670
+ let header;
671
+ try {
672
+ header = JSON.parse(base64UrlDecode(headerB64));
673
+ } catch {
674
+ throw new BioError("Malformed JWT: invalid header encoding", "invalid_token");
675
+ }
676
+ if (header.alg !== "RS256") {
677
+ throw new BioError(
678
+ `Expected RS256 token, got ${header.alg}. Use verifyToken() for HS256.`,
679
+ "unsupported_alg"
680
+ );
681
+ }
682
+ const jwksUri = options?.jwksUri ?? DEFAULT_JWKS_URI;
683
+ await verifyRS256Signature(headerB64, payloadB64, signatureB64, jwksUri, header.kid);
684
+ const payload = JSON.parse(base64UrlDecode(payloadB64));
685
+ const now = Math.floor(Date.now() / 1e3);
686
+ if (!payload.exp) throw new BioError("Token missing expiration claim", "invalid_token");
687
+ if (payload.exp < now) throw new BioError("Token has expired", "token_expired");
688
+ const allowedIssuers = options?.issuer ? [options.issuer] : DEFAULT_ISSUERS;
689
+ if (!allowedIssuers.includes(payload.iss)) {
690
+ throw new BioError(`Unknown issuer: ${payload.iss}`, "invalid_issuer");
691
+ }
692
+ if (options?.audience && payload.aud !== options.audience) {
693
+ throw new BioError(`Invalid audience: ${payload.aud}`, "invalid_audience");
694
+ }
695
+ return payload;
696
+ }
603
697
  export {
604
698
  BioAdmin,
605
699
  BioAuth,
@@ -607,5 +701,6 @@ export {
607
701
  decodeToken,
608
702
  generatePKCE,
609
703
  isTokenExpired,
610
- verifyToken
704
+ verifyToken,
705
+ verifyTokenJWKS
611
706
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insureco/bio",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "SDK for Bio-ID SSO integration on the Tawa platform",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",