@zintrust/core 0.7.7 → 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.
- package/package.json +1 -1
- package/src/auth/LoginFlow.d.ts +7 -1
- package/src/auth/LoginFlow.d.ts.map +1 -1
- package/src/auth/LoginFlow.js +98 -2
- package/src/cli/OptionalCliExtensions.d.ts +1 -0
- package/src/cli/OptionalCliExtensions.d.ts.map +1 -1
- package/src/cli/OptionalCliExtensions.js +24 -2
- package/src/cli/commands/MySqlProxyCommand.d.ts.map +1 -1
- package/src/cli/commands/MySqlProxyCommand.js +1 -1
- package/src/cli/commands/RoutesCommand.d.ts.map +1 -1
- package/src/cli/commands/RoutesCommand.js +39 -2
- package/src/cli/commands/ScheduleStartCommand.d.ts.map +1 -1
- package/src/cli/commands/ScheduleStartCommand.js +14 -9
- package/src/cli/commands/schedule/ScheduleCliSupport.d.ts.map +1 -1
- package/src/cli/commands/schedule/ScheduleCliSupport.js +29 -4
- package/src/config/env.d.ts +2 -0
- package/src/config/env.d.ts.map +1 -1
- package/src/config/env.js +2 -0
- package/src/http/Request.d.ts +1 -0
- package/src/http/Request.d.ts.map +1 -1
- package/src/http/Request.js +3 -0
- package/src/index.d.ts +8 -4
- package/src/index.d.ts.map +1 -1
- package/src/index.js +7 -4
- package/src/middleware/BulletproofAuthMiddleware.d.ts +2 -1
- package/src/middleware/BulletproofAuthMiddleware.d.ts.map +1 -1
- package/src/middleware/BulletproofAuthMiddleware.js +106 -36
- package/src/runtime/useFileLoader.d.ts +5 -0
- package/src/runtime/useFileLoader.d.ts.map +1 -1
- package/src/runtime/useFileLoader.js +58 -37
- package/src/security/BulletproofDeviceStore.d.ts +18 -0
- package/src/security/BulletproofDeviceStore.d.ts.map +1 -0
- package/src/security/BulletproofDeviceStore.js +243 -0
- package/src/security/JwtVerifier.d.ts +75 -0
- package/src/security/JwtVerifier.d.ts.map +1 -0
- package/src/security/JwtVerifier.js +336 -0
- package/src/templates/project/basic/app/Controllers/AuthController.ts.tpl +24 -10
- package/src/templates/project/basic/config/trace.ts.tpl +73 -0
- package/src/templates/project/basic/database/migrations/20260419000000_create_bulletproof_devices_table.ts.tpl +36 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { Env } from '../config/env.js';
|
|
2
|
+
import { ErrorFactory } from '../exceptions/ZintrustError.js';
|
|
3
|
+
import { isNonEmptyString } from '../helper/index.js';
|
|
4
|
+
import { useDatabase } from '../orm/Database.js';
|
|
5
|
+
const DEFAULTS = Object.freeze({
|
|
6
|
+
dbConnection: 'default',
|
|
7
|
+
dbTable: 'zintrust_bulletproof_devices',
|
|
8
|
+
});
|
|
9
|
+
const getConnection = () => {
|
|
10
|
+
const value = Env.get('BULLETPROOF_DEVICE_DB_CONNECTION', DEFAULTS.dbConnection).trim();
|
|
11
|
+
return value === '' ? DEFAULTS.dbConnection : value;
|
|
12
|
+
};
|
|
13
|
+
const getTable = () => {
|
|
14
|
+
const value = Env.get('BULLETPROOF_DEVICE_DB_TABLE', DEFAULTS.dbTable).trim();
|
|
15
|
+
return value === '' ? DEFAULTS.dbTable : value;
|
|
16
|
+
};
|
|
17
|
+
const normalizeString = (value) => {
|
|
18
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
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
|
+
};
|
|
32
|
+
const normalizeDate = (value) => {
|
|
33
|
+
if (value instanceof Date && Number.isFinite(value.getTime()))
|
|
34
|
+
return value;
|
|
35
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
36
|
+
const date = new Date(value);
|
|
37
|
+
return Number.isFinite(date.getTime()) ? date : undefined;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
};
|
|
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', {
|
|
69
|
+
table,
|
|
70
|
+
...(deviceId === undefined ? {} : { deviceId }),
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const toStoredRecord = (row) => {
|
|
74
|
+
const deviceId = normalizeString(row['device_id']);
|
|
75
|
+
const signingSecret = normalizeString(row['signing_secret']);
|
|
76
|
+
const lastSeenAt = normalizeDate(row['last_seen_at']);
|
|
77
|
+
if (deviceId === undefined || signingSecret === undefined || lastSeenAt === undefined) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
deviceId,
|
|
82
|
+
signingSecret,
|
|
83
|
+
lastSeenAt,
|
|
84
|
+
...(normalizeString(row['user_id']) === undefined
|
|
85
|
+
? {}
|
|
86
|
+
: { userId: normalizeString(row['user_id']) }),
|
|
87
|
+
...(normalizeString(row['user_agent']) === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { userAgent: normalizeString(row['user_agent']) }),
|
|
90
|
+
...(normalizeDate(row['created_at']) === undefined
|
|
91
|
+
? {}
|
|
92
|
+
: { createdAt: normalizeDate(row['created_at']) }),
|
|
93
|
+
...(normalizeDate(row['updated_at']) === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: { updatedAt: normalizeDate(row['updated_at']) }),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
const buildInsertPayload = (record) => {
|
|
99
|
+
const lastSeenAt = record.lastSeenAt.toISOString();
|
|
100
|
+
const deviceId = normalizeRequiredDeviceId(record.deviceId);
|
|
101
|
+
const signingSecret = normalizeRequiredSigningSecret(record.signingSecret);
|
|
102
|
+
return {
|
|
103
|
+
user_id: normalizeString(record.userId) ?? null,
|
|
104
|
+
device_id: deviceId,
|
|
105
|
+
signing_secret: signingSecret,
|
|
106
|
+
user_agent: normalizeString(record.userAgent) ?? null,
|
|
107
|
+
last_seen_at: lastSeenAt,
|
|
108
|
+
created_at: lastSeenAt,
|
|
109
|
+
updated_at: lastSeenAt,
|
|
110
|
+
};
|
|
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
|
+
};
|
|
155
|
+
export const BulletproofDeviceStore = Object.freeze({
|
|
156
|
+
async findByDeviceId(deviceId) {
|
|
157
|
+
if (!isNonEmptyString(deviceId))
|
|
158
|
+
return null;
|
|
159
|
+
const db = useDatabase(undefined, getConnection());
|
|
160
|
+
const table = getTable();
|
|
161
|
+
const normalizedDeviceId = deviceId.trim();
|
|
162
|
+
try {
|
|
163
|
+
const row = await db
|
|
164
|
+
.table(table)
|
|
165
|
+
.where('device_id', '=', normalizedDeviceId)
|
|
166
|
+
.first();
|
|
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;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw createStoreError(table, 'lookup', error, { deviceId: normalizedDeviceId });
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
async upsert(record) {
|
|
181
|
+
const db = useDatabase(undefined, getConnection());
|
|
182
|
+
const table = getTable();
|
|
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']);
|
|
198
|
+
try {
|
|
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
|
+
});
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
throw createStoreError(table, 'upsert', error, { deviceId });
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
async removeByDeviceId(deviceId) {
|
|
230
|
+
if (!isNonEmptyString(deviceId))
|
|
231
|
+
return;
|
|
232
|
+
const db = useDatabase(undefined, getConnection());
|
|
233
|
+
const table = getTable();
|
|
234
|
+
const normalizedDeviceId = deviceId.trim();
|
|
235
|
+
try {
|
|
236
|
+
await db.table(table).where('device_id', '=', normalizedDeviceId).delete();
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
throw createStoreError(table, 'delete', error, { deviceId: normalizedDeviceId });
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
export default BulletproofDeviceStore;
|
|
@@ -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;
|