@drmhse/authos-node 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/express.d.mts +24 -0
- package/dist/express.d.ts +24 -0
- package/dist/express.js +395 -0
- package/dist/express.mjs +373 -0
- package/dist/index.d.mts +67 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +296 -0
- package/dist/index.mjs +269 -0
- package/dist/types-C4aDSNcp.d.mts +98 -0
- package/dist/types-C4aDSNcp.d.ts +98 -0
- package/package.json +81 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto2 = require('crypto');
|
|
4
|
+
|
|
5
|
+
function _interopNamespace(e) {
|
|
6
|
+
if (e && e.__esModule) return e;
|
|
7
|
+
var n = Object.create(null);
|
|
8
|
+
if (e) {
|
|
9
|
+
Object.keys(e).forEach(function (k) {
|
|
10
|
+
if (k !== 'default') {
|
|
11
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
12
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
13
|
+
enumerable: true,
|
|
14
|
+
get: function () { return e[k]; }
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
n.default = e;
|
|
20
|
+
return Object.freeze(n);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
var crypto2__namespace = /*#__PURE__*/_interopNamespace(crypto2);
|
|
24
|
+
|
|
25
|
+
// src/jwks.ts
|
|
26
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
27
|
+
var DEFAULT_CACHE_TTL = 60 * 60 * 1e3;
|
|
28
|
+
function base64UrlDecode(input) {
|
|
29
|
+
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
30
|
+
const pad = base64.length % 4;
|
|
31
|
+
const padded = pad ? base64 + "=".repeat(4 - pad) : base64;
|
|
32
|
+
return Buffer.from(padded, "base64");
|
|
33
|
+
}
|
|
34
|
+
function jwkToPem(jwk) {
|
|
35
|
+
if (jwk.kty !== "RSA" || !jwk.n || !jwk.e) {
|
|
36
|
+
throw new Error("Only RSA keys are supported");
|
|
37
|
+
}
|
|
38
|
+
const n = base64UrlDecode(jwk.n);
|
|
39
|
+
const e = base64UrlDecode(jwk.e);
|
|
40
|
+
const nInt = encodeASN1Integer(n);
|
|
41
|
+
const eInt = encodeASN1Integer(e);
|
|
42
|
+
const rsaPublicKey = encodeASN1Sequence(Buffer.concat([nInt, eInt]));
|
|
43
|
+
const bitString = Buffer.concat([
|
|
44
|
+
Buffer.from([3]),
|
|
45
|
+
encodeASN1Length(rsaPublicKey.length + 1),
|
|
46
|
+
Buffer.from([0]),
|
|
47
|
+
// no unused bits
|
|
48
|
+
rsaPublicKey
|
|
49
|
+
]);
|
|
50
|
+
const algorithmIdentifier = Buffer.from([
|
|
51
|
+
48,
|
|
52
|
+
13,
|
|
53
|
+
// SEQUENCE
|
|
54
|
+
6,
|
|
55
|
+
9,
|
|
56
|
+
// OID
|
|
57
|
+
42,
|
|
58
|
+
134,
|
|
59
|
+
72,
|
|
60
|
+
134,
|
|
61
|
+
247,
|
|
62
|
+
13,
|
|
63
|
+
1,
|
|
64
|
+
1,
|
|
65
|
+
1,
|
|
66
|
+
// 1.2.840.113549.1.1.1
|
|
67
|
+
5,
|
|
68
|
+
0
|
|
69
|
+
// NULL
|
|
70
|
+
]);
|
|
71
|
+
const subjectPublicKeyInfo = encodeASN1Sequence(
|
|
72
|
+
Buffer.concat([algorithmIdentifier, bitString])
|
|
73
|
+
);
|
|
74
|
+
const base64Key = subjectPublicKeyInfo.toString("base64");
|
|
75
|
+
const lines = base64Key.match(/.{1,64}/g) || [];
|
|
76
|
+
return `-----BEGIN PUBLIC KEY-----
|
|
77
|
+
${lines.join("\n")}
|
|
78
|
+
-----END PUBLIC KEY-----`;
|
|
79
|
+
}
|
|
80
|
+
function encodeASN1Length(length) {
|
|
81
|
+
if (length < 128) {
|
|
82
|
+
return Buffer.from([length]);
|
|
83
|
+
}
|
|
84
|
+
const bytes = [];
|
|
85
|
+
let len = length;
|
|
86
|
+
while (len > 0) {
|
|
87
|
+
bytes.unshift(len & 255);
|
|
88
|
+
len = len >> 8;
|
|
89
|
+
}
|
|
90
|
+
return Buffer.from([128 | bytes.length, ...bytes]);
|
|
91
|
+
}
|
|
92
|
+
function encodeASN1Integer(value) {
|
|
93
|
+
const needsPadding = value[0] & 128;
|
|
94
|
+
const content = needsPadding ? Buffer.concat([Buffer.from([0]), value]) : value;
|
|
95
|
+
return Buffer.concat([
|
|
96
|
+
Buffer.from([2]),
|
|
97
|
+
// INTEGER tag
|
|
98
|
+
encodeASN1Length(content.length),
|
|
99
|
+
content
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
function encodeASN1Sequence(content) {
|
|
103
|
+
return Buffer.concat([
|
|
104
|
+
Buffer.from([48]),
|
|
105
|
+
// SEQUENCE tag
|
|
106
|
+
encodeASN1Length(content.length),
|
|
107
|
+
content
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
async function fetchJWKS(baseURL) {
|
|
111
|
+
const url = `${baseURL.replace(/\/$/, "")}/.well-known/jwks.json`;
|
|
112
|
+
const response = await fetch(url);
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`);
|
|
115
|
+
}
|
|
116
|
+
return response.json();
|
|
117
|
+
}
|
|
118
|
+
async function getJWKS(baseURL, cacheTTL) {
|
|
119
|
+
const cached = jwksCache.get(baseURL);
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
if (cached && now - cached.fetchedAt < cacheTTL) {
|
|
122
|
+
return cached.jwks;
|
|
123
|
+
}
|
|
124
|
+
const jwks = await fetchJWKS(baseURL);
|
|
125
|
+
jwksCache.set(baseURL, { jwks, fetchedAt: now });
|
|
126
|
+
return jwks;
|
|
127
|
+
}
|
|
128
|
+
function findKey(jwks, kid) {
|
|
129
|
+
return jwks.keys.find((key) => key.kid === kid) || null;
|
|
130
|
+
}
|
|
131
|
+
var TokenVerificationError = class extends Error {
|
|
132
|
+
constructor(message, code) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.code = code;
|
|
135
|
+
this.name = "TokenVerificationError";
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
function parseJWT(token) {
|
|
139
|
+
const parts = token.split(".");
|
|
140
|
+
if (parts.length !== 3) {
|
|
141
|
+
throw new TokenVerificationError("Invalid JWT format", "INVALID_TOKEN_FORMAT");
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const header = JSON.parse(base64UrlDecode(parts[0]).toString("utf8"));
|
|
145
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]).toString("utf8"));
|
|
146
|
+
return { header, payload };
|
|
147
|
+
} catch {
|
|
148
|
+
throw new TokenVerificationError("Invalid JWT encoding", "INVALID_TOKEN_FORMAT");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function createTokenVerifier(options) {
|
|
152
|
+
const { baseURL, jwksCacheTTL = DEFAULT_CACHE_TTL } = options;
|
|
153
|
+
async function verifyToken(token, verifyOptions = {}) {
|
|
154
|
+
const { audience, issuer, clockTolerance = 0 } = verifyOptions;
|
|
155
|
+
const { header, payload } = parseJWT(token);
|
|
156
|
+
if (header.alg !== "RS256") {
|
|
157
|
+
throw new TokenVerificationError(
|
|
158
|
+
`Unsupported algorithm: ${header.alg}. Only RS256 is supported.`,
|
|
159
|
+
"INVALID_ALGORITHM"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const kid = header.kid;
|
|
163
|
+
if (!kid) {
|
|
164
|
+
throw new TokenVerificationError("Token missing kid header", "MISSING_KID");
|
|
165
|
+
}
|
|
166
|
+
const jwks = await getJWKS(baseURL, jwksCacheTTL);
|
|
167
|
+
const jwk = findKey(jwks, kid);
|
|
168
|
+
if (!jwk) {
|
|
169
|
+
const freshJwks = await fetchJWKS(baseURL);
|
|
170
|
+
jwksCache.set(baseURL, { jwks: freshJwks, fetchedAt: Date.now() });
|
|
171
|
+
const freshJwk = findKey(freshJwks, kid);
|
|
172
|
+
if (!freshJwk) {
|
|
173
|
+
throw new TokenVerificationError(
|
|
174
|
+
`No matching key found for kid: ${kid}`,
|
|
175
|
+
"KEY_NOT_FOUND"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return verifyWithKey(token, freshJwk, payload, { audience, issuer, clockTolerance });
|
|
179
|
+
}
|
|
180
|
+
return verifyWithKey(token, jwk, payload, { audience, issuer, clockTolerance });
|
|
181
|
+
}
|
|
182
|
+
return { verifyToken };
|
|
183
|
+
}
|
|
184
|
+
function verifyWithKey(token, jwk, payload, options) {
|
|
185
|
+
const { audience, issuer, clockTolerance } = options;
|
|
186
|
+
const pem = jwkToPem(jwk);
|
|
187
|
+
const parts = token.split(".");
|
|
188
|
+
const signatureInput = `${parts[0]}.${parts[1]}`;
|
|
189
|
+
const signature = base64UrlDecode(parts[2]);
|
|
190
|
+
const verifier = crypto2__namespace.createVerify("RSA-SHA256");
|
|
191
|
+
verifier.update(signatureInput);
|
|
192
|
+
if (!verifier.verify(pem, signature)) {
|
|
193
|
+
throw new TokenVerificationError("Invalid token signature", "INVALID_SIGNATURE");
|
|
194
|
+
}
|
|
195
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
196
|
+
if (payload.exp && payload.exp + clockTolerance < now) {
|
|
197
|
+
throw new TokenVerificationError("Token has expired", "TOKEN_EXPIRED");
|
|
198
|
+
}
|
|
199
|
+
if (payload.iat && payload.iat - clockTolerance > now) {
|
|
200
|
+
throw new TokenVerificationError("Token is not yet valid", "TOKEN_NOT_YET_VALID");
|
|
201
|
+
}
|
|
202
|
+
if (audience) {
|
|
203
|
+
const tokenAud = payload.aud;
|
|
204
|
+
const validAud = Array.isArray(tokenAud) ? tokenAud.includes(audience) : tokenAud === audience;
|
|
205
|
+
if (!validAud) {
|
|
206
|
+
throw new TokenVerificationError("Invalid token audience", "INVALID_AUDIENCE");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (issuer) {
|
|
210
|
+
const tokenIss = payload.iss;
|
|
211
|
+
if (tokenIss !== issuer) {
|
|
212
|
+
throw new TokenVerificationError("Invalid token issuer", "INVALID_ISSUER");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { claims: payload, token };
|
|
216
|
+
}
|
|
217
|
+
function clearJWKSCache() {
|
|
218
|
+
jwksCache.clear();
|
|
219
|
+
}
|
|
220
|
+
var DEFAULT_TOLERANCE = 5 * 60 * 1e3;
|
|
221
|
+
var WebhookVerificationError = class extends Error {
|
|
222
|
+
constructor(message, code) {
|
|
223
|
+
super(message);
|
|
224
|
+
this.code = code;
|
|
225
|
+
this.name = "WebhookVerificationError";
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
function parseSignatureHeader(header) {
|
|
229
|
+
const parts = header.split(",");
|
|
230
|
+
let timestamp = 0;
|
|
231
|
+
const signatures = [];
|
|
232
|
+
for (const part of parts) {
|
|
233
|
+
const [key, value] = part.split("=", 2);
|
|
234
|
+
if (key === "t") {
|
|
235
|
+
timestamp = parseInt(value, 10);
|
|
236
|
+
} else if (key === "v1") {
|
|
237
|
+
signatures.push(value);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { timestamp, signatures };
|
|
241
|
+
}
|
|
242
|
+
function computeSignature(timestamp, payload, secret) {
|
|
243
|
+
const signedPayload = `${timestamp}.${payload}`;
|
|
244
|
+
return crypto2__namespace.createHmac("sha256", secret).update(signedPayload).digest("hex");
|
|
245
|
+
}
|
|
246
|
+
function secureCompare(a, b) {
|
|
247
|
+
if (a.length !== b.length) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
return crypto2__namespace.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
251
|
+
}
|
|
252
|
+
function verifyWebhookSignature(signatureHeader, payload, secret, options = {}) {
|
|
253
|
+
const { tolerance = DEFAULT_TOLERANCE } = options;
|
|
254
|
+
if (!signatureHeader) {
|
|
255
|
+
throw new WebhookVerificationError("Missing signature header", "MISSING_SIGNATURE");
|
|
256
|
+
}
|
|
257
|
+
if (!payload) {
|
|
258
|
+
throw new WebhookVerificationError("Missing payload", "MISSING_PAYLOAD");
|
|
259
|
+
}
|
|
260
|
+
if (!secret) {
|
|
261
|
+
throw new WebhookVerificationError("Missing webhook secret", "MISSING_SECRET");
|
|
262
|
+
}
|
|
263
|
+
const { timestamp, signatures } = parseSignatureHeader(signatureHeader);
|
|
264
|
+
if (!timestamp) {
|
|
265
|
+
throw new WebhookVerificationError("Missing timestamp in signature", "MISSING_TIMESTAMP");
|
|
266
|
+
}
|
|
267
|
+
if (signatures.length === 0) {
|
|
268
|
+
throw new WebhookVerificationError("No signatures found in header", "NO_SIGNATURES");
|
|
269
|
+
}
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
const timestampMs = timestamp * 1e3;
|
|
272
|
+
if (Math.abs(now - timestampMs) > tolerance) {
|
|
273
|
+
throw new WebhookVerificationError(
|
|
274
|
+
"Webhook timestamp is outside tolerance window",
|
|
275
|
+
"TIMESTAMP_EXPIRED"
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const expectedSignature = computeSignature(timestamp, payload, secret);
|
|
279
|
+
const isValid = signatures.some((sig) => secureCompare(sig, expectedSignature));
|
|
280
|
+
if (!isValid) {
|
|
281
|
+
throw new WebhookVerificationError("Invalid webhook signature", "INVALID_SIGNATURE");
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
function createWebhookSignature(payload, secret, timestamp) {
|
|
286
|
+
const ts = timestamp ?? Math.floor(Date.now() / 1e3);
|
|
287
|
+
const signature = computeSignature(ts, payload, secret);
|
|
288
|
+
return `t=${ts},v1=${signature}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
exports.TokenVerificationError = TokenVerificationError;
|
|
292
|
+
exports.WebhookVerificationError = WebhookVerificationError;
|
|
293
|
+
exports.clearJWKSCache = clearJWKSCache;
|
|
294
|
+
exports.createTokenVerifier = createTokenVerifier;
|
|
295
|
+
exports.createWebhookSignature = createWebhookSignature;
|
|
296
|
+
exports.verifyWebhookSignature = verifyWebhookSignature;
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import * as crypto2 from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/jwks.ts
|
|
4
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
5
|
+
var DEFAULT_CACHE_TTL = 60 * 60 * 1e3;
|
|
6
|
+
function base64UrlDecode(input) {
|
|
7
|
+
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
8
|
+
const pad = base64.length % 4;
|
|
9
|
+
const padded = pad ? base64 + "=".repeat(4 - pad) : base64;
|
|
10
|
+
return Buffer.from(padded, "base64");
|
|
11
|
+
}
|
|
12
|
+
function jwkToPem(jwk) {
|
|
13
|
+
if (jwk.kty !== "RSA" || !jwk.n || !jwk.e) {
|
|
14
|
+
throw new Error("Only RSA keys are supported");
|
|
15
|
+
}
|
|
16
|
+
const n = base64UrlDecode(jwk.n);
|
|
17
|
+
const e = base64UrlDecode(jwk.e);
|
|
18
|
+
const nInt = encodeASN1Integer(n);
|
|
19
|
+
const eInt = encodeASN1Integer(e);
|
|
20
|
+
const rsaPublicKey = encodeASN1Sequence(Buffer.concat([nInt, eInt]));
|
|
21
|
+
const bitString = Buffer.concat([
|
|
22
|
+
Buffer.from([3]),
|
|
23
|
+
encodeASN1Length(rsaPublicKey.length + 1),
|
|
24
|
+
Buffer.from([0]),
|
|
25
|
+
// no unused bits
|
|
26
|
+
rsaPublicKey
|
|
27
|
+
]);
|
|
28
|
+
const algorithmIdentifier = Buffer.from([
|
|
29
|
+
48,
|
|
30
|
+
13,
|
|
31
|
+
// SEQUENCE
|
|
32
|
+
6,
|
|
33
|
+
9,
|
|
34
|
+
// OID
|
|
35
|
+
42,
|
|
36
|
+
134,
|
|
37
|
+
72,
|
|
38
|
+
134,
|
|
39
|
+
247,
|
|
40
|
+
13,
|
|
41
|
+
1,
|
|
42
|
+
1,
|
|
43
|
+
1,
|
|
44
|
+
// 1.2.840.113549.1.1.1
|
|
45
|
+
5,
|
|
46
|
+
0
|
|
47
|
+
// NULL
|
|
48
|
+
]);
|
|
49
|
+
const subjectPublicKeyInfo = encodeASN1Sequence(
|
|
50
|
+
Buffer.concat([algorithmIdentifier, bitString])
|
|
51
|
+
);
|
|
52
|
+
const base64Key = subjectPublicKeyInfo.toString("base64");
|
|
53
|
+
const lines = base64Key.match(/.{1,64}/g) || [];
|
|
54
|
+
return `-----BEGIN PUBLIC KEY-----
|
|
55
|
+
${lines.join("\n")}
|
|
56
|
+
-----END PUBLIC KEY-----`;
|
|
57
|
+
}
|
|
58
|
+
function encodeASN1Length(length) {
|
|
59
|
+
if (length < 128) {
|
|
60
|
+
return Buffer.from([length]);
|
|
61
|
+
}
|
|
62
|
+
const bytes = [];
|
|
63
|
+
let len = length;
|
|
64
|
+
while (len > 0) {
|
|
65
|
+
bytes.unshift(len & 255);
|
|
66
|
+
len = len >> 8;
|
|
67
|
+
}
|
|
68
|
+
return Buffer.from([128 | bytes.length, ...bytes]);
|
|
69
|
+
}
|
|
70
|
+
function encodeASN1Integer(value) {
|
|
71
|
+
const needsPadding = value[0] & 128;
|
|
72
|
+
const content = needsPadding ? Buffer.concat([Buffer.from([0]), value]) : value;
|
|
73
|
+
return Buffer.concat([
|
|
74
|
+
Buffer.from([2]),
|
|
75
|
+
// INTEGER tag
|
|
76
|
+
encodeASN1Length(content.length),
|
|
77
|
+
content
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
function encodeASN1Sequence(content) {
|
|
81
|
+
return Buffer.concat([
|
|
82
|
+
Buffer.from([48]),
|
|
83
|
+
// SEQUENCE tag
|
|
84
|
+
encodeASN1Length(content.length),
|
|
85
|
+
content
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
async function fetchJWKS(baseURL) {
|
|
89
|
+
const url = `${baseURL.replace(/\/$/, "")}/.well-known/jwks.json`;
|
|
90
|
+
const response = await fetch(url);
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`);
|
|
93
|
+
}
|
|
94
|
+
return response.json();
|
|
95
|
+
}
|
|
96
|
+
async function getJWKS(baseURL, cacheTTL) {
|
|
97
|
+
const cached = jwksCache.get(baseURL);
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
if (cached && now - cached.fetchedAt < cacheTTL) {
|
|
100
|
+
return cached.jwks;
|
|
101
|
+
}
|
|
102
|
+
const jwks = await fetchJWKS(baseURL);
|
|
103
|
+
jwksCache.set(baseURL, { jwks, fetchedAt: now });
|
|
104
|
+
return jwks;
|
|
105
|
+
}
|
|
106
|
+
function findKey(jwks, kid) {
|
|
107
|
+
return jwks.keys.find((key) => key.kid === kid) || null;
|
|
108
|
+
}
|
|
109
|
+
var TokenVerificationError = class extends Error {
|
|
110
|
+
constructor(message, code) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.code = code;
|
|
113
|
+
this.name = "TokenVerificationError";
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
function parseJWT(token) {
|
|
117
|
+
const parts = token.split(".");
|
|
118
|
+
if (parts.length !== 3) {
|
|
119
|
+
throw new TokenVerificationError("Invalid JWT format", "INVALID_TOKEN_FORMAT");
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const header = JSON.parse(base64UrlDecode(parts[0]).toString("utf8"));
|
|
123
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]).toString("utf8"));
|
|
124
|
+
return { header, payload };
|
|
125
|
+
} catch {
|
|
126
|
+
throw new TokenVerificationError("Invalid JWT encoding", "INVALID_TOKEN_FORMAT");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function createTokenVerifier(options) {
|
|
130
|
+
const { baseURL, jwksCacheTTL = DEFAULT_CACHE_TTL } = options;
|
|
131
|
+
async function verifyToken(token, verifyOptions = {}) {
|
|
132
|
+
const { audience, issuer, clockTolerance = 0 } = verifyOptions;
|
|
133
|
+
const { header, payload } = parseJWT(token);
|
|
134
|
+
if (header.alg !== "RS256") {
|
|
135
|
+
throw new TokenVerificationError(
|
|
136
|
+
`Unsupported algorithm: ${header.alg}. Only RS256 is supported.`,
|
|
137
|
+
"INVALID_ALGORITHM"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const kid = header.kid;
|
|
141
|
+
if (!kid) {
|
|
142
|
+
throw new TokenVerificationError("Token missing kid header", "MISSING_KID");
|
|
143
|
+
}
|
|
144
|
+
const jwks = await getJWKS(baseURL, jwksCacheTTL);
|
|
145
|
+
const jwk = findKey(jwks, kid);
|
|
146
|
+
if (!jwk) {
|
|
147
|
+
const freshJwks = await fetchJWKS(baseURL);
|
|
148
|
+
jwksCache.set(baseURL, { jwks: freshJwks, fetchedAt: Date.now() });
|
|
149
|
+
const freshJwk = findKey(freshJwks, kid);
|
|
150
|
+
if (!freshJwk) {
|
|
151
|
+
throw new TokenVerificationError(
|
|
152
|
+
`No matching key found for kid: ${kid}`,
|
|
153
|
+
"KEY_NOT_FOUND"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return verifyWithKey(token, freshJwk, payload, { audience, issuer, clockTolerance });
|
|
157
|
+
}
|
|
158
|
+
return verifyWithKey(token, jwk, payload, { audience, issuer, clockTolerance });
|
|
159
|
+
}
|
|
160
|
+
return { verifyToken };
|
|
161
|
+
}
|
|
162
|
+
function verifyWithKey(token, jwk, payload, options) {
|
|
163
|
+
const { audience, issuer, clockTolerance } = options;
|
|
164
|
+
const pem = jwkToPem(jwk);
|
|
165
|
+
const parts = token.split(".");
|
|
166
|
+
const signatureInput = `${parts[0]}.${parts[1]}`;
|
|
167
|
+
const signature = base64UrlDecode(parts[2]);
|
|
168
|
+
const verifier = crypto2.createVerify("RSA-SHA256");
|
|
169
|
+
verifier.update(signatureInput);
|
|
170
|
+
if (!verifier.verify(pem, signature)) {
|
|
171
|
+
throw new TokenVerificationError("Invalid token signature", "INVALID_SIGNATURE");
|
|
172
|
+
}
|
|
173
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
174
|
+
if (payload.exp && payload.exp + clockTolerance < now) {
|
|
175
|
+
throw new TokenVerificationError("Token has expired", "TOKEN_EXPIRED");
|
|
176
|
+
}
|
|
177
|
+
if (payload.iat && payload.iat - clockTolerance > now) {
|
|
178
|
+
throw new TokenVerificationError("Token is not yet valid", "TOKEN_NOT_YET_VALID");
|
|
179
|
+
}
|
|
180
|
+
if (audience) {
|
|
181
|
+
const tokenAud = payload.aud;
|
|
182
|
+
const validAud = Array.isArray(tokenAud) ? tokenAud.includes(audience) : tokenAud === audience;
|
|
183
|
+
if (!validAud) {
|
|
184
|
+
throw new TokenVerificationError("Invalid token audience", "INVALID_AUDIENCE");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (issuer) {
|
|
188
|
+
const tokenIss = payload.iss;
|
|
189
|
+
if (tokenIss !== issuer) {
|
|
190
|
+
throw new TokenVerificationError("Invalid token issuer", "INVALID_ISSUER");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { claims: payload, token };
|
|
194
|
+
}
|
|
195
|
+
function clearJWKSCache() {
|
|
196
|
+
jwksCache.clear();
|
|
197
|
+
}
|
|
198
|
+
var DEFAULT_TOLERANCE = 5 * 60 * 1e3;
|
|
199
|
+
var WebhookVerificationError = class extends Error {
|
|
200
|
+
constructor(message, code) {
|
|
201
|
+
super(message);
|
|
202
|
+
this.code = code;
|
|
203
|
+
this.name = "WebhookVerificationError";
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
function parseSignatureHeader(header) {
|
|
207
|
+
const parts = header.split(",");
|
|
208
|
+
let timestamp = 0;
|
|
209
|
+
const signatures = [];
|
|
210
|
+
for (const part of parts) {
|
|
211
|
+
const [key, value] = part.split("=", 2);
|
|
212
|
+
if (key === "t") {
|
|
213
|
+
timestamp = parseInt(value, 10);
|
|
214
|
+
} else if (key === "v1") {
|
|
215
|
+
signatures.push(value);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { timestamp, signatures };
|
|
219
|
+
}
|
|
220
|
+
function computeSignature(timestamp, payload, secret) {
|
|
221
|
+
const signedPayload = `${timestamp}.${payload}`;
|
|
222
|
+
return crypto2.createHmac("sha256", secret).update(signedPayload).digest("hex");
|
|
223
|
+
}
|
|
224
|
+
function secureCompare(a, b) {
|
|
225
|
+
if (a.length !== b.length) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return crypto2.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
229
|
+
}
|
|
230
|
+
function verifyWebhookSignature(signatureHeader, payload, secret, options = {}) {
|
|
231
|
+
const { tolerance = DEFAULT_TOLERANCE } = options;
|
|
232
|
+
if (!signatureHeader) {
|
|
233
|
+
throw new WebhookVerificationError("Missing signature header", "MISSING_SIGNATURE");
|
|
234
|
+
}
|
|
235
|
+
if (!payload) {
|
|
236
|
+
throw new WebhookVerificationError("Missing payload", "MISSING_PAYLOAD");
|
|
237
|
+
}
|
|
238
|
+
if (!secret) {
|
|
239
|
+
throw new WebhookVerificationError("Missing webhook secret", "MISSING_SECRET");
|
|
240
|
+
}
|
|
241
|
+
const { timestamp, signatures } = parseSignatureHeader(signatureHeader);
|
|
242
|
+
if (!timestamp) {
|
|
243
|
+
throw new WebhookVerificationError("Missing timestamp in signature", "MISSING_TIMESTAMP");
|
|
244
|
+
}
|
|
245
|
+
if (signatures.length === 0) {
|
|
246
|
+
throw new WebhookVerificationError("No signatures found in header", "NO_SIGNATURES");
|
|
247
|
+
}
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const timestampMs = timestamp * 1e3;
|
|
250
|
+
if (Math.abs(now - timestampMs) > tolerance) {
|
|
251
|
+
throw new WebhookVerificationError(
|
|
252
|
+
"Webhook timestamp is outside tolerance window",
|
|
253
|
+
"TIMESTAMP_EXPIRED"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const expectedSignature = computeSignature(timestamp, payload, secret);
|
|
257
|
+
const isValid = signatures.some((sig) => secureCompare(sig, expectedSignature));
|
|
258
|
+
if (!isValid) {
|
|
259
|
+
throw new WebhookVerificationError("Invalid webhook signature", "INVALID_SIGNATURE");
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
function createWebhookSignature(payload, secret, timestamp) {
|
|
264
|
+
const ts = timestamp ?? Math.floor(Date.now() / 1e3);
|
|
265
|
+
const signature = computeSignature(ts, payload, secret);
|
|
266
|
+
return `t=${ts},v1=${signature}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export { TokenVerificationError, WebhookVerificationError, clearJWKSCache, createTokenVerifier, createWebhookSignature, verifyWebhookSignature };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { JwtClaims } from '@drmhse/sso-sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration options for the AuthOS Node.js adapter
|
|
5
|
+
*/
|
|
6
|
+
interface AuthOSNodeOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Base URL of the AuthOS API service
|
|
9
|
+
*/
|
|
10
|
+
baseURL: string;
|
|
11
|
+
/**
|
|
12
|
+
* Cache TTL for JWKS in milliseconds. Default: 1 hour (3600000ms)
|
|
13
|
+
*/
|
|
14
|
+
jwksCacheTTL?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* JSON Web Key structure
|
|
18
|
+
*/
|
|
19
|
+
interface JWK {
|
|
20
|
+
kty: string;
|
|
21
|
+
kid: string;
|
|
22
|
+
use?: string;
|
|
23
|
+
alg?: string;
|
|
24
|
+
n?: string;
|
|
25
|
+
e?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* JSON Web Key Set structure
|
|
29
|
+
*/
|
|
30
|
+
interface JWKS {
|
|
31
|
+
keys: JWK[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Verified token result
|
|
35
|
+
*/
|
|
36
|
+
interface VerifiedToken {
|
|
37
|
+
/**
|
|
38
|
+
* The decoded JWT claims
|
|
39
|
+
*/
|
|
40
|
+
claims: JwtClaims;
|
|
41
|
+
/**
|
|
42
|
+
* The raw token string
|
|
43
|
+
*/
|
|
44
|
+
token: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Express request with auth context attached
|
|
48
|
+
*/
|
|
49
|
+
interface AuthenticatedRequest {
|
|
50
|
+
auth: VerifiedToken;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Token verification options
|
|
54
|
+
*/
|
|
55
|
+
interface VerifyTokenOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Required audience (aud) claim
|
|
58
|
+
*/
|
|
59
|
+
audience?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Required issuer (iss) claim
|
|
62
|
+
*/
|
|
63
|
+
issuer?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Clock tolerance in seconds for exp/iat validation. Default: 0
|
|
66
|
+
*/
|
|
67
|
+
clockTolerance?: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Webhook verification options
|
|
71
|
+
*/
|
|
72
|
+
interface WebhookVerifyOptions {
|
|
73
|
+
/**
|
|
74
|
+
* Tolerance window in milliseconds for timestamp validation. Default: 5 minutes (300000ms)
|
|
75
|
+
*/
|
|
76
|
+
tolerance?: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Express middleware options for requireAuth
|
|
80
|
+
*/
|
|
81
|
+
interface RequireAuthOptions extends VerifyTokenOptions {
|
|
82
|
+
/**
|
|
83
|
+
* Custom function to extract token from request.
|
|
84
|
+
* Default: extracts from Authorization: Bearer header
|
|
85
|
+
*/
|
|
86
|
+
getToken?: (req: unknown) => string | null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Express middleware options for requirePermission
|
|
90
|
+
*/
|
|
91
|
+
interface RequirePermissionOptions {
|
|
92
|
+
/**
|
|
93
|
+
* Custom 403 error message
|
|
94
|
+
*/
|
|
95
|
+
message?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type { AuthOSNodeOptions as A, JWK as J, RequireAuthOptions as R, VerifyTokenOptions as V, WebhookVerifyOptions as W, VerifiedToken as a, JWKS as b, AuthenticatedRequest as c, RequirePermissionOptions as d };
|