@zintrust/core 0.7.8 → 0.7.9

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.
@@ -17,6 +17,18 @@ const getTable = () => {
17
17
  const normalizeString = (value) => {
18
18
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
19
19
  };
20
+ const normalizeRequiredDeviceId = (value) => {
21
+ const normalized = normalizeString(value);
22
+ if (normalized !== undefined)
23
+ return normalized;
24
+ throw ErrorFactory.createValidationError('Bulletproof device store requires a non-empty deviceId');
25
+ };
26
+ const normalizeRequiredSigningSecret = (value) => {
27
+ const normalized = normalizeString(value);
28
+ if (normalized !== undefined)
29
+ return normalized;
30
+ throw ErrorFactory.createValidationError('Bulletproof device store requires a non-empty signingSecret');
31
+ };
20
32
  const normalizeDate = (value) => {
21
33
  if (value instanceof Date && Number.isFinite(value.getTime()))
22
34
  return value;
@@ -26,10 +38,36 @@ const normalizeDate = (value) => {
26
38
  }
27
39
  return undefined;
28
40
  };
29
- const createMissingTableError = (table, error) => {
30
- return ErrorFactory.createConfigError(`Bulletproof device store table '${table}' is missing required columns (run migrations)`, {
41
+ const getErrorMessage = (error) => {
42
+ return error instanceof Error ? error.message : String(error);
43
+ };
44
+ const isSchemaError = (error) => {
45
+ const message = getErrorMessage(error).toLowerCase();
46
+ return (message.includes('no such table') ||
47
+ message.includes('no such column') ||
48
+ message.includes('unknown column') ||
49
+ message.includes('does not exist') ||
50
+ message.includes('undefined column') ||
51
+ message.includes('missing column'));
52
+ };
53
+ const createStoreError = (table, operation, error, details) => {
54
+ if (typeof error === 'object' && error !== null) {
55
+ const code = error.code;
56
+ if (typeof code === 'string' && code === 'CONFIG_ERROR') {
57
+ return error;
58
+ }
59
+ }
60
+ const message = getErrorMessage(error);
61
+ const detailPayload = { table, operation, error: message, ...details };
62
+ if (isSchemaError(error)) {
63
+ return ErrorFactory.createConfigError(`Bulletproof device store table '${table}' is missing required columns (run migrations)`, detailPayload);
64
+ }
65
+ return ErrorFactory.createDatabaseError(`Bulletproof device store ${operation} failed`, detailPayload);
66
+ };
67
+ const createInvalidStoredRecordError = (table, deviceId) => {
68
+ return ErrorFactory.createConfigError('Bulletproof device store returned an invalid record', {
31
69
  table,
32
- error: error instanceof Error ? error.message : String(error),
70
+ ...(deviceId === undefined ? {} : { deviceId }),
33
71
  });
34
72
  };
35
73
  const toStoredRecord = (row) => {
@@ -57,63 +95,135 @@ const toStoredRecord = (row) => {
57
95
  : { updatedAt: normalizeDate(row['updated_at']) }),
58
96
  };
59
97
  };
60
- const toPayload = (record) => {
98
+ const buildInsertPayload = (record) => {
61
99
  const lastSeenAt = record.lastSeenAt.toISOString();
100
+ const deviceId = normalizeRequiredDeviceId(record.deviceId);
101
+ const signingSecret = normalizeRequiredSigningSecret(record.signingSecret);
62
102
  return {
63
103
  user_id: normalizeString(record.userId) ?? null,
64
- device_id: record.deviceId,
65
- signing_secret: record.signingSecret,
104
+ device_id: deviceId,
105
+ signing_secret: signingSecret,
66
106
  user_agent: normalizeString(record.userAgent) ?? null,
67
107
  last_seen_at: lastSeenAt,
68
108
  created_at: lastSeenAt,
69
109
  updated_at: lastSeenAt,
70
110
  };
71
111
  };
112
+ const buildUpdatePayload = (record) => {
113
+ const lastSeenAt = record.lastSeenAt.toISOString();
114
+ const signingSecret = normalizeRequiredSigningSecret(record.signingSecret);
115
+ return {
116
+ user_id: normalizeString(record.userId) ?? null,
117
+ signing_secret: signingSecret,
118
+ user_agent: normalizeString(record.userAgent) ?? null,
119
+ last_seen_at: lastSeenAt,
120
+ updated_at: lastSeenAt,
121
+ };
122
+ };
123
+ const buildIgnoreInsert = (db, table, columns, conflictColumns) => {
124
+ const columnList = columns.join(', ');
125
+ const placeholders = columns.map(() => '?').join(', ');
126
+ const driver = db.getType();
127
+ if (driver === 'sqlite' || driver === 'd1' || driver === 'd1-remote') {
128
+ return `INSERT OR IGNORE INTO ${table} (${columnList}) VALUES (${placeholders})`;
129
+ }
130
+ if (driver === 'mysql') {
131
+ return `INSERT IGNORE INTO ${table} (${columnList}) VALUES (${placeholders})`;
132
+ }
133
+ if (driver === 'postgresql') {
134
+ return `INSERT INTO ${table} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${conflictColumns.join(', ')}) DO NOTHING`;
135
+ }
136
+ if (driver === 'sqlserver') {
137
+ const sourceColumns = columns.map((_, index) => `v${index + 1}`);
138
+ const selectClause = sourceColumns.map((name) => `? AS ${name}`).join(', ');
139
+ const conflictClause = conflictColumns
140
+ .map((column) => `target.${column} = source.${column}`)
141
+ .join(' AND ');
142
+ const insertValues = columns.map((column) => `source.${column}`).join(', ');
143
+ const sourceProjection = columns
144
+ .map((column, index) => `${sourceColumns[index]} AS ${column}`)
145
+ .join(', ');
146
+ return [
147
+ `MERGE INTO ${table} WITH (HOLDLOCK) AS target`,
148
+ `USING (SELECT ${sourceProjection} FROM (SELECT ${selectClause}) seed) AS source`,
149
+ `ON ${conflictClause}`,
150
+ `WHEN NOT MATCHED THEN INSERT (${columnList}) VALUES (${insertValues});`,
151
+ ].join(' ');
152
+ }
153
+ return `INSERT INTO ${table} (${columnList}) VALUES (${placeholders})`;
154
+ };
72
155
  export const BulletproofDeviceStore = Object.freeze({
73
156
  async findByDeviceId(deviceId) {
74
157
  if (!isNonEmptyString(deviceId))
75
158
  return null;
76
159
  const db = useDatabase(undefined, getConnection());
77
160
  const table = getTable();
161
+ const normalizedDeviceId = deviceId.trim();
78
162
  try {
79
163
  const row = await db
80
164
  .table(table)
81
- .where('device_id', '=', deviceId.trim())
165
+ .where('device_id', '=', normalizedDeviceId)
82
166
  .first();
83
- return row === null ? null : toStoredRecord(row);
167
+ if (row === null) {
168
+ return null;
169
+ }
170
+ const normalized = toStoredRecord(row);
171
+ if (normalized === null) {
172
+ throw createInvalidStoredRecordError(table, normalizedDeviceId);
173
+ }
174
+ return normalized;
84
175
  }
85
176
  catch (error) {
86
- throw createMissingTableError(table, error);
177
+ throw createStoreError(table, 'lookup', error, { deviceId: normalizedDeviceId });
87
178
  }
88
179
  },
89
180
  async upsert(record) {
90
181
  const db = useDatabase(undefined, getConnection());
91
182
  const table = getTable();
92
- const payload = toPayload(record);
183
+ const deviceId = normalizeRequiredDeviceId(record.deviceId);
184
+ const normalizedUserId = normalizeString(record.userId);
185
+ const normalizedUserAgent = normalizeString(record.userAgent);
186
+ const normalizedRecord = {
187
+ ...record,
188
+ deviceId,
189
+ signingSecret: normalizeRequiredSigningSecret(record.signingSecret),
190
+ ...(normalizedUserId === undefined ? {} : { userId: normalizedUserId }),
191
+ ...(normalizedUserAgent === undefined ? {} : { userAgent: normalizedUserAgent }),
192
+ };
193
+ const insertPayload = buildInsertPayload(normalizedRecord);
194
+ const updatePayload = buildUpdatePayload(normalizedRecord);
195
+ const insertColumns = Object.keys(insertPayload);
196
+ const insertValues = insertColumns.map((column) => insertPayload[column]);
197
+ const insertSql = buildIgnoreInsert(db, table, insertColumns, ['device_id']);
93
198
  try {
94
- await db.table(table).where('device_id', '=', record.deviceId).update(payload);
95
- const existing = await db
96
- .table(table)
97
- .where('device_id', '=', record.deviceId)
98
- .first();
99
- if (existing === null) {
100
- await db.table(table).insert(payload);
101
- }
102
- const stored = await db
103
- .table(table)
104
- .where('device_id', '=', record.deviceId)
105
- .first();
106
- const normalized = stored === null ? null : toStoredRecord(stored);
107
- if (normalized === null) {
108
- throw ErrorFactory.createConfigError('Bulletproof device store returned an invalid record', {
109
- table,
110
- deviceId: record.deviceId,
111
- });
112
- }
113
- return normalized;
199
+ return await db.transaction(async (transactionDb) => {
200
+ await transactionDb.table(table).where('device_id', '=', deviceId).update(updatePayload);
201
+ try {
202
+ await transactionDb.execute(insertSql, insertValues);
203
+ }
204
+ catch (error) {
205
+ const duplicateMessage = getErrorMessage(error).toLowerCase();
206
+ const isDuplicateKeyError = duplicateMessage.includes('duplicate') ||
207
+ duplicateMessage.includes('unique constraint') ||
208
+ duplicateMessage.includes('unique failed') ||
209
+ duplicateMessage.includes('duplicate key');
210
+ if (!isDuplicateKeyError) {
211
+ throw error;
212
+ }
213
+ }
214
+ const stored = await transactionDb
215
+ .table(table)
216
+ .where('device_id', '=', deviceId)
217
+ .first();
218
+ const normalized = stored === null ? null : toStoredRecord(stored);
219
+ if (normalized === null) {
220
+ throw createInvalidStoredRecordError(table, deviceId);
221
+ }
222
+ return normalized;
223
+ });
114
224
  }
115
225
  catch (error) {
116
- throw createMissingTableError(table, error);
226
+ throw createStoreError(table, 'upsert', error, { deviceId });
117
227
  }
118
228
  },
119
229
  async removeByDeviceId(deviceId) {
@@ -121,11 +231,12 @@ export const BulletproofDeviceStore = Object.freeze({
121
231
  return;
122
232
  const db = useDatabase(undefined, getConnection());
123
233
  const table = getTable();
234
+ const normalizedDeviceId = deviceId.trim();
124
235
  try {
125
- await db.table(table).where('device_id', '=', deviceId.trim()).delete();
236
+ await db.table(table).where('device_id', '=', normalizedDeviceId).delete();
126
237
  }
127
238
  catch (error) {
128
- throw createMissingTableError(table, error);
239
+ throw createStoreError(table, 'delete', error, { deviceId: normalizedDeviceId });
129
240
  }
130
241
  },
131
242
  });
@@ -0,0 +1,75 @@
1
+ import type { JwtPayload } from './JwtManager';
2
+ export type JwtVerifierAlgorithm = 'RS256';
3
+ type JwtVerifierJsonWebKey = Readonly<{
4
+ alg?: string;
5
+ crv?: string;
6
+ d?: string;
7
+ dp?: string;
8
+ dq?: string;
9
+ e?: string;
10
+ ext?: boolean;
11
+ k?: string;
12
+ key_ops?: string[];
13
+ kid?: string;
14
+ kty?: string;
15
+ n?: string;
16
+ oth?: unknown[];
17
+ p?: string;
18
+ q?: string;
19
+ qi?: string;
20
+ use?: string;
21
+ x?: string;
22
+ x5c?: string[];
23
+ x5t?: string;
24
+ 'x5t#S256'?: string;
25
+ x5u?: string;
26
+ y?: string;
27
+ }>;
28
+ export type JwtVerifierJwk = JwtVerifierJsonWebKey & Readonly<{
29
+ kid?: string;
30
+ alg?: string;
31
+ use?: string;
32
+ }>;
33
+ export type JwtVerifierJwksDocument = Readonly<{
34
+ keys: readonly JwtVerifierJwk[];
35
+ }>;
36
+ export type JwtVerifierFailureReason = 'invalid_token_format' | 'invalid_header' | 'invalid_payload' | 'missing_kid' | 'key_not_found' | 'jwks_fetch_failed' | 'invalid_jwks' | 'unsupported_algorithm' | 'invalid_jwk' | 'invalid_signature' | 'issuer_mismatch' | 'audience_mismatch' | 'token_expired' | 'token_not_yet_valid';
37
+ export type JwtVerifierFailure = Readonly<{
38
+ ok: false;
39
+ reason: JwtVerifierFailureReason;
40
+ message: string;
41
+ details?: unknown;
42
+ }>;
43
+ export type JwtVerifierSuccess = Readonly<{
44
+ ok: true;
45
+ payload: JwtPayload;
46
+ header: Readonly<Record<string, unknown>>;
47
+ jwk: JwtVerifierJwk;
48
+ cacheHit?: boolean;
49
+ }>;
50
+ export type JwtVerifierResult = JwtVerifierSuccess | JwtVerifierFailure;
51
+ export type JwtVerifierCommonInput = Readonly<{
52
+ token: string;
53
+ algorithm?: JwtVerifierAlgorithm;
54
+ issuer?: string | readonly string[];
55
+ audience?: string | readonly string[];
56
+ nowMs?: number;
57
+ }>;
58
+ export type JwtVerifierWithJwkInput = JwtVerifierCommonInput & Readonly<{
59
+ jwk: JwtVerifierJwk;
60
+ }>;
61
+ export type JwtVerifierWithJwksInput = JwtVerifierCommonInput & Readonly<{
62
+ jwksUrl: string;
63
+ cacheKey?: string;
64
+ cacheTtlSeconds?: number;
65
+ fetcher?: typeof fetch;
66
+ }>;
67
+ export declare const JwtVerifier: Readonly<{
68
+ clearCache: (cacheKey?: string) => void;
69
+ verifyWithJwk: (input: JwtVerifierWithJwkInput) => Promise<JwtPayload>;
70
+ verifyWithJwkResult: (input: JwtVerifierWithJwkInput) => Promise<JwtVerifierResult>;
71
+ verifyWithJwks: (input: JwtVerifierWithJwksInput) => Promise<JwtPayload>;
72
+ verifyWithJwksResult: (input: JwtVerifierWithJwksInput) => Promise<JwtVerifierResult>;
73
+ }>;
74
+ export default JwtVerifier;
75
+ //# sourceMappingURL=JwtVerifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"JwtVerifier.d.ts","sourceRoot":"","sources":["../../../src/security/JwtVerifier.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,336 @@
1
+ import { ErrorFactory } from '../exceptions/ZintrustError.js';
2
+ import { isArray, isNonEmptyString, isObject } from '../helper/index.js';
3
+ import { detectRuntimePlatform, RuntimeServices } from '../runtime/RuntimeServices.js';
4
+ const jwksCache = new Map();
5
+ const getRuntime = () => RuntimeServices.create(detectRuntimePlatform());
6
+ const isFailure = (value) => {
7
+ return isObject(value) && value['ok'] === false && typeof value['reason'] === 'string';
8
+ };
9
+ const toFailure = (reason, message, details) => ({
10
+ ok: false,
11
+ reason,
12
+ message,
13
+ ...(details === undefined ? {} : { details }),
14
+ });
15
+ const toThrownError = (failure) => {
16
+ return ErrorFactory.createSecurityError(failure.message, {
17
+ reason: failure.reason,
18
+ ...(failure.details === undefined ? {} : { details: failure.details }),
19
+ });
20
+ };
21
+ const normalizeBase64Url = (value) => {
22
+ const normalized = value.replaceAll('-', '+').replaceAll('_', '/');
23
+ const padding = normalized.length % 4;
24
+ return padding === 0 ? normalized : normalized.padEnd(normalized.length + (4 - padding), '=');
25
+ };
26
+ const base64UrlToBytes = (value) => {
27
+ const binary = globalThis.atob(normalizeBase64Url(value));
28
+ const bytes = new Uint8Array(binary.length);
29
+ for (let index = 0; index < binary.length; index++) {
30
+ bytes[index] = binary.codePointAt(index) ?? 0;
31
+ }
32
+ return bytes;
33
+ };
34
+ const decodeJwtSegment = (value) => {
35
+ const json = new TextDecoder().decode(base64UrlToBytes(value));
36
+ return JSON.parse(json);
37
+ };
38
+ const parseTokenParts = (token) => {
39
+ const parts = token.split('.');
40
+ if (parts.length !== 3) {
41
+ return toFailure('invalid_token_format', 'JWT must contain header, payload, and signature');
42
+ }
43
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
44
+ if (!isNonEmptyString(encodedHeader) ||
45
+ !isNonEmptyString(encodedPayload) ||
46
+ !isNonEmptyString(encodedSignature)) {
47
+ return toFailure('invalid_token_format', 'JWT parts must not be empty');
48
+ }
49
+ return { encodedHeader, encodedPayload, encodedSignature };
50
+ };
51
+ const parseHeader = (encodedHeader) => {
52
+ try {
53
+ const header = decodeJwtSegment(encodedHeader);
54
+ if (!isObject(header)) {
55
+ return toFailure('invalid_header', 'JWT header must be an object');
56
+ }
57
+ return header;
58
+ }
59
+ catch (error) {
60
+ return toFailure('invalid_header', 'JWT header is not valid JSON', {
61
+ cause: error instanceof Error ? error.message : String(error),
62
+ });
63
+ }
64
+ };
65
+ const parsePayload = (encodedPayload) => {
66
+ try {
67
+ const payload = decodeJwtSegment(encodedPayload);
68
+ if (!isObject(payload)) {
69
+ return toFailure('invalid_payload', 'JWT payload must be an object');
70
+ }
71
+ return payload;
72
+ }
73
+ catch (error) {
74
+ return toFailure('invalid_payload', 'JWT payload is not valid JSON', {
75
+ cause: error instanceof Error ? error.message : String(error),
76
+ });
77
+ }
78
+ };
79
+ const getAlgorithm = (inputAlgorithm) => {
80
+ return inputAlgorithm ?? 'RS256';
81
+ };
82
+ const validateHeaderAlgorithm = (header, algorithm) => {
83
+ if (header['alg'] !== algorithm) {
84
+ return toFailure('unsupported_algorithm', `JWT algorithm must be ${algorithm}`);
85
+ }
86
+ return undefined;
87
+ };
88
+ const normalizeExpectedValues = (value) => {
89
+ if (value === undefined)
90
+ return [];
91
+ if (typeof value === 'string') {
92
+ const trimmed = value.trim();
93
+ return trimmed === '' ? [] : [trimmed];
94
+ }
95
+ return value
96
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
97
+ .filter((item) => item !== '');
98
+ };
99
+ const normalizeAudienceClaim = (audience) => {
100
+ if (typeof audience === 'string') {
101
+ const trimmed = audience.trim();
102
+ return trimmed === '' ? [] : [trimmed];
103
+ }
104
+ if (isArray(audience)) {
105
+ return audience
106
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
107
+ .filter((item) => item !== '');
108
+ }
109
+ return [];
110
+ };
111
+ const validateClaims = (payload, input) => {
112
+ const nowMs = input.nowMs ?? Date.now();
113
+ if (typeof payload.exp === 'number' &&
114
+ Number.isFinite(payload.exp) &&
115
+ nowMs >= payload.exp * 1000) {
116
+ return toFailure('token_expired', 'JWT has expired');
117
+ }
118
+ if (typeof payload.nbf === 'number' &&
119
+ Number.isFinite(payload.nbf) &&
120
+ nowMs < payload.nbf * 1000) {
121
+ return toFailure('token_not_yet_valid', 'JWT is not valid yet');
122
+ }
123
+ const expectedIssuers = normalizeExpectedValues(input.issuer);
124
+ if (expectedIssuers.length > 0) {
125
+ const issuer = typeof payload.iss === 'string' ? payload.iss.trim() : '';
126
+ if (!expectedIssuers.includes(issuer)) {
127
+ return toFailure('issuer_mismatch', 'JWT issuer did not match the expected issuer', {
128
+ expected: expectedIssuers,
129
+ received: issuer,
130
+ });
131
+ }
132
+ }
133
+ const expectedAudiences = normalizeExpectedValues(input.audience);
134
+ if (expectedAudiences.length > 0) {
135
+ const audiences = normalizeAudienceClaim(payload.aud);
136
+ const matched = audiences.some((audience) => expectedAudiences.includes(audience));
137
+ if (!matched) {
138
+ return toFailure('audience_mismatch', 'JWT audience did not match the expected audience', {
139
+ expected: expectedAudiences,
140
+ received: audiences,
141
+ });
142
+ }
143
+ }
144
+ return undefined;
145
+ };
146
+ const validateJwkForAlgorithm = (jwk, algorithm) => {
147
+ if (jwk.kty !== 'RSA') {
148
+ return toFailure('invalid_jwk', 'JWK must use RSA for RS256 verification', {
149
+ kty: jwk.kty,
150
+ });
151
+ }
152
+ if (!isNonEmptyString(jwk.n) || !isNonEmptyString(jwk.e)) {
153
+ return toFailure('invalid_jwk', 'JWK must include RSA modulus and exponent');
154
+ }
155
+ if (isNonEmptyString(jwk.alg) && jwk.alg !== algorithm) {
156
+ return toFailure('invalid_jwk', 'JWK algorithm does not match the requested JWT algorithm', {
157
+ expected: algorithm,
158
+ received: jwk.alg,
159
+ });
160
+ }
161
+ if (isNonEmptyString(jwk.use) && jwk.use !== 'sig') {
162
+ return toFailure('invalid_jwk', 'JWK use must allow signature verification', {
163
+ use: jwk.use,
164
+ });
165
+ }
166
+ return undefined;
167
+ };
168
+ const verifyRs256Signature = async (params) => {
169
+ const subtle = getRuntime().crypto.subtle;
170
+ try {
171
+ const key = await subtle.importKey('jwk', params.jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
172
+ const signature = base64UrlToBytes(params.encodedSignature);
173
+ const signingInput = new TextEncoder().encode(params.signingInput);
174
+ return await subtle.verify('RSASSA-PKCS1-v1_5', key, signature, signingInput);
175
+ }
176
+ catch (error) {
177
+ return toFailure('invalid_jwk', 'JWK could not be imported for signature verification', {
178
+ cause: error instanceof Error ? error.message : String(error),
179
+ });
180
+ }
181
+ };
182
+ const fetchJwks = async (input) => {
183
+ const cacheKey = (input.cacheKey ?? input.jwksUrl).trim();
184
+ const nowMs = input.nowMs ?? Date.now();
185
+ const cached = jwksCache.get(cacheKey);
186
+ if (cached !== undefined && cached.expiresAtMs > nowMs) {
187
+ return { jwks: cached.jwks, cacheHit: true };
188
+ }
189
+ const fetcher = input.fetcher ?? getRuntime().fetch;
190
+ let response;
191
+ try {
192
+ response = await fetcher(input.jwksUrl, {
193
+ headers: { accept: 'application/json' },
194
+ });
195
+ }
196
+ catch (error) {
197
+ return toFailure('jwks_fetch_failed', 'Failed to fetch JWKS document', {
198
+ jwksUrl: input.jwksUrl,
199
+ cause: error instanceof Error ? error.message : String(error),
200
+ });
201
+ }
202
+ if (!response.ok) {
203
+ return toFailure('jwks_fetch_failed', 'JWKS endpoint returned a non-success response', {
204
+ jwksUrl: input.jwksUrl,
205
+ status: response.status,
206
+ });
207
+ }
208
+ let body;
209
+ try {
210
+ body = (await response.json());
211
+ }
212
+ catch (error) {
213
+ return toFailure('invalid_jwks', 'JWKS response was not valid JSON', {
214
+ jwksUrl: input.jwksUrl,
215
+ cause: error instanceof Error ? error.message : String(error),
216
+ });
217
+ }
218
+ if (!isObject(body) || !isArray(body['keys'])) {
219
+ return toFailure('invalid_jwks', 'JWKS response must contain a keys array', {
220
+ jwksUrl: input.jwksUrl,
221
+ });
222
+ }
223
+ const jwks = {
224
+ keys: body['keys'].filter((item) => isObject(item)),
225
+ };
226
+ const cacheTtlSeconds = typeof input.cacheTtlSeconds === 'number' && Number.isFinite(input.cacheTtlSeconds)
227
+ ? input.cacheTtlSeconds
228
+ : 3600;
229
+ const ttlMs = Math.max(1, cacheTtlSeconds) * 1000;
230
+ jwksCache.set(cacheKey, { jwks, expiresAtMs: nowMs + ttlMs });
231
+ return { jwks, cacheHit: false };
232
+ };
233
+ const resolveJwkFromJwks = (header, jwks, algorithm) => {
234
+ const kid = typeof header['kid'] === 'string' ? header['kid'].trim() : '';
235
+ if (kid === '') {
236
+ return toFailure('missing_kid', 'JWT header must include a kid when verifying with JWKS');
237
+ }
238
+ const jwk = jwks.keys.find((item) => {
239
+ if (item.kid !== kid)
240
+ return false;
241
+ if (isNonEmptyString(item.alg) && item.alg !== algorithm)
242
+ return false;
243
+ if (isNonEmptyString(item.use) && item.use !== 'sig')
244
+ return false;
245
+ return true;
246
+ });
247
+ if (jwk === undefined) {
248
+ return toFailure('key_not_found', 'No matching JWK was found for the JWT kid', { kid });
249
+ }
250
+ return jwk;
251
+ };
252
+ const verifyTokenWithJwk = async (input, jwk, cacheHit) => {
253
+ const tokenParts = parseTokenParts(input.token);
254
+ if (isFailure(tokenParts))
255
+ return tokenParts;
256
+ const header = parseHeader(tokenParts.encodedHeader);
257
+ if (isFailure(header))
258
+ return header;
259
+ const payload = parsePayload(tokenParts.encodedPayload);
260
+ if (isFailure(payload))
261
+ return payload;
262
+ const algorithm = getAlgorithm(input.algorithm);
263
+ const headerFailure = validateHeaderAlgorithm(header, algorithm);
264
+ if (headerFailure !== undefined)
265
+ return headerFailure;
266
+ const jwkFailure = validateJwkForAlgorithm(jwk, algorithm);
267
+ if (jwkFailure !== undefined)
268
+ return jwkFailure;
269
+ const signatureCheck = await verifyRs256Signature({
270
+ jwk,
271
+ signingInput: `${tokenParts.encodedHeader}.${tokenParts.encodedPayload}`,
272
+ encodedSignature: tokenParts.encodedSignature,
273
+ });
274
+ if (signatureCheck !== true) {
275
+ return signatureCheck === false
276
+ ? toFailure('invalid_signature', 'JWT signature could not be verified')
277
+ : signatureCheck;
278
+ }
279
+ const claimsFailure = validateClaims(payload, input);
280
+ if (claimsFailure !== undefined)
281
+ return claimsFailure;
282
+ return { ok: true, payload, header, jwk, ...(cacheHit === undefined ? {} : { cacheHit }) };
283
+ };
284
+ const verifyWithJwkResult = async (input) => {
285
+ return verifyTokenWithJwk(input, input.jwk);
286
+ };
287
+ const verifyWithJwksResult = async (input) => {
288
+ const tokenParts = parseTokenParts(input.token);
289
+ if (isFailure(tokenParts))
290
+ return tokenParts;
291
+ const header = parseHeader(tokenParts.encodedHeader);
292
+ if (isFailure(header))
293
+ return header;
294
+ const algorithm = getAlgorithm(input.algorithm);
295
+ const headerFailure = validateHeaderAlgorithm(header, algorithm);
296
+ if (headerFailure !== undefined)
297
+ return headerFailure;
298
+ const kid = typeof header['kid'] === 'string' ? header['kid'].trim() : '';
299
+ if (kid === '') {
300
+ return toFailure('missing_kid', 'JWT header must include a kid when verifying with JWKS');
301
+ }
302
+ const fetched = await fetchJwks(input);
303
+ if (isFailure(fetched))
304
+ return fetched;
305
+ const jwk = resolveJwkFromJwks(header, fetched.jwks, algorithm);
306
+ if (isFailure(jwk))
307
+ return jwk;
308
+ return verifyTokenWithJwk(input, jwk, fetched.cacheHit);
309
+ };
310
+ const verifyWithJwk = async (input) => {
311
+ const result = await verifyWithJwkResult(input);
312
+ if (!result.ok)
313
+ throw toThrownError(result);
314
+ return result.payload;
315
+ };
316
+ const verifyWithJwks = async (input) => {
317
+ const result = await verifyWithJwksResult(input);
318
+ if (!result.ok)
319
+ throw toThrownError(result);
320
+ return result.payload;
321
+ };
322
+ const clearCache = (cacheKey) => {
323
+ if (!isNonEmptyString(cacheKey)) {
324
+ jwksCache.clear();
325
+ return;
326
+ }
327
+ jwksCache.delete(cacheKey.trim());
328
+ };
329
+ export const JwtVerifier = Object.freeze({
330
+ clearCache,
331
+ verifyWithJwk,
332
+ verifyWithJwkResult,
333
+ verifyWithJwks,
334
+ verifyWithJwksResult,
335
+ });
336
+ export default JwtVerifier;
@@ -59,7 +59,7 @@ const getIssuedToken = (issued: unknown): string => {
59
59
  }
60
60
  }
61
61
 
62
- throw ErrorFactory.createSecurityError('LoginFlow jwt issuer returned an invalid access token');
62
+ throw ErrorFactory.createSecurityError('LoginFlow issuer returned an invalid access token');
63
63
  };
64
64
 
65
65
  const getIssuedString = (issued: unknown, key: string): string | undefined => {