@uln/auth 0.4.1 → 0.6.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/client.d.ts +7 -5
- package/dist/client.js +341 -95
- package/dist/cookie.js +2 -2
- package/dist/flow.d.ts +53 -0
- package/dist/flow.js +393 -0
- package/dist/hono.d.ts +30 -4
- package/dist/hono.js +346 -122
- package/dist/index.d.ts +10 -9
- package/dist/index.js +492 -172
- package/dist/middleware.d.ts +15 -5
- package/dist/middleware.js +309 -57
- package/dist/password.d.ts +9 -2
- package/dist/password.js +53 -23
- package/dist/rate-limit.d.ts +11 -0
- package/dist/rate-limit.js +21 -2
- package/dist/remote.d.ts +52 -0
- package/dist/remote.js +264 -0
- package/dist/service.d.ts +13 -13
- package/dist/service.js +375 -56
- package/dist/token.d.ts +21 -2
- package/dist/token.js +67 -16
- package/dist/totp.d.ts +7 -1
- package/dist/totp.js +26 -18
- package/dist/types.d.ts +20 -0
- package/dist/users.d.ts +5 -0
- package/dist/users.js +69 -27
- package/package.json +35 -20
- package/src/client.ts +10 -16
- package/src/flow.ts +170 -0
- package/src/hono.ts +120 -134
- package/src/index.ts +31 -23
- package/src/middleware.ts +50 -50
- package/src/password.ts +68 -28
- package/src/rate-limit.ts +38 -0
- package/src/remote.ts +158 -0
- package/src/service.ts +40 -80
- package/src/token.ts +101 -16
- package/src/totp.ts +25 -12
- package/src/types.ts +17 -0
- package/src/users.ts +21 -3
package/dist/index.js
CHANGED
|
@@ -22,7 +22,6 @@ function extractCookie(cookieHeader, name) {
|
|
|
22
22
|
}
|
|
23
23
|
return null;
|
|
24
24
|
}
|
|
25
|
-
|
|
26
25
|
// src/token.ts
|
|
27
26
|
var encoder = new TextEncoder;
|
|
28
27
|
function base64urlEncode(data) {
|
|
@@ -33,6 +32,8 @@ function base64urlEncode(data) {
|
|
|
33
32
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
33
|
}
|
|
35
34
|
function base64urlDecode(str) {
|
|
35
|
+
if (!/^[A-Za-z0-9_-]*$/.test(str))
|
|
36
|
+
throw new Error("Invalid base64url");
|
|
36
37
|
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
38
|
const binary = atob(padded);
|
|
38
39
|
const bytes = new Uint8Array(binary.length);
|
|
@@ -60,8 +61,8 @@ async function importPrivateKey(jwk) {
|
|
|
60
61
|
async function importPublicKey(jwk) {
|
|
61
62
|
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
|
|
62
63
|
}
|
|
63
|
-
async function createToken(config, user) {
|
|
64
|
-
const now = Math.floor(Date.now() / 1000);
|
|
64
|
+
async function createToken(config, user, options = {}) {
|
|
65
|
+
const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
|
|
65
66
|
const payload = {
|
|
66
67
|
sub: user.id,
|
|
67
68
|
email: user.email,
|
|
@@ -71,7 +72,8 @@ async function createToken(config, user) {
|
|
|
71
72
|
flags: user.flags,
|
|
72
73
|
kid: config.kid,
|
|
73
74
|
iat: now,
|
|
74
|
-
exp: now + config.maxAgeSeconds
|
|
75
|
+
exp: now + config.maxAgeSeconds,
|
|
76
|
+
...user.audience === undefined ? {} : { aud: user.audience }
|
|
75
77
|
};
|
|
76
78
|
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
77
79
|
const payloadBytes = encoder.encode(payloadB64);
|
|
@@ -79,25 +81,46 @@ async function createToken(config, user) {
|
|
|
79
81
|
const sigB64 = base64urlEncode(new Uint8Array(signature));
|
|
80
82
|
return `${payloadB64}.${sigB64}`;
|
|
81
83
|
}
|
|
84
|
+
var CLOCK_SKEW_SECONDS = 60;
|
|
85
|
+
function isStringArray(value) {
|
|
86
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
87
|
+
}
|
|
88
|
+
function isFlags(value) {
|
|
89
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
90
|
+
return false;
|
|
91
|
+
return Object.values(value).every(isStringArray);
|
|
92
|
+
}
|
|
93
|
+
function isTokenPayload(value) {
|
|
94
|
+
if (typeof value !== "object" || value === null)
|
|
95
|
+
return false;
|
|
96
|
+
if (!("sub" in value) || !("email" in value) || !("name" in value) || !("admin" in value) || !("scopes" in value) || !("flags" in value) || !("kid" in value) || !("iat" in value) || !("exp" in value)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return typeof value.sub === "string" && typeof value.email === "string" && typeof value.name === "string" && typeof value.admin === "boolean" && isStringArray(value.scopes) && isFlags(value.flags) && typeof value.kid === "string" && Number.isSafeInteger(value.iat) && Number.isSafeInteger(value.exp) && (!("aud" in value) || value.aud === undefined || typeof value.aud === "string");
|
|
100
|
+
}
|
|
82
101
|
async function validateToken(config, token) {
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
102
|
+
const parts = token.split(".");
|
|
103
|
+
if (parts.length !== 2)
|
|
85
104
|
return null;
|
|
86
|
-
const payloadB64 =
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
const [payloadB64 = "", sigB64 = ""] = parts;
|
|
106
|
+
if (!payloadB64 || !sigB64)
|
|
107
|
+
return null;
|
|
108
|
+
let decoded;
|
|
89
109
|
try {
|
|
90
|
-
|
|
91
|
-
payload = JSON.parse(decoded);
|
|
110
|
+
decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
92
111
|
} catch {
|
|
93
112
|
return null;
|
|
94
113
|
}
|
|
95
|
-
if (
|
|
114
|
+
if (!isTokenPayload(decoded))
|
|
96
115
|
return null;
|
|
97
|
-
|
|
116
|
+
const payload = decoded;
|
|
98
117
|
const now = Math.floor(Date.now() / 1000);
|
|
99
118
|
if (now > payload.exp)
|
|
100
119
|
return null;
|
|
120
|
+
if (payload.iat > now + CLOCK_SKEW_SECONDS)
|
|
121
|
+
return null;
|
|
122
|
+
if (now - payload.iat > config.maxAgeSeconds)
|
|
123
|
+
return null;
|
|
101
124
|
const entry = config.publicKeys.find((k) => k.kid === payload.kid);
|
|
102
125
|
if (!entry)
|
|
103
126
|
return null;
|
|
@@ -112,69 +135,301 @@ async function validateToken(config, token) {
|
|
|
112
135
|
}
|
|
113
136
|
return payload;
|
|
114
137
|
}
|
|
138
|
+
function peekTokenPayload(token) {
|
|
139
|
+
const payloadB64 = token.split(".")[0];
|
|
140
|
+
if (!payloadB64)
|
|
141
|
+
return null;
|
|
142
|
+
try {
|
|
143
|
+
const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
144
|
+
return isTokenPayload(decoded) ? decoded : null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function peekTokenKid(token) {
|
|
150
|
+
const payloadB64 = token.split(".")[0];
|
|
151
|
+
if (!payloadB64)
|
|
152
|
+
return null;
|
|
153
|
+
try {
|
|
154
|
+
const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
155
|
+
if (typeof decoded !== "object" || decoded === null || !("kid" in decoded))
|
|
156
|
+
return null;
|
|
157
|
+
return typeof decoded.kid === "string" ? decoded.kid : null;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
115
162
|
|
|
116
|
-
// src/
|
|
117
|
-
function
|
|
118
|
-
return
|
|
163
|
+
// src/remote.ts
|
|
164
|
+
function publicFetcher(authUrl) {
|
|
165
|
+
return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
|
|
119
166
|
}
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
return header === `Bearer ${expectedToken}`;
|
|
167
|
+
function bindingFetcher(binding) {
|
|
168
|
+
return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
|
|
123
169
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
170
|
+
function isLoopbackHostname(hostname) {
|
|
171
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
172
|
+
}
|
|
173
|
+
function toOrigin(value) {
|
|
174
|
+
try {
|
|
175
|
+
return new URL(value).origin;
|
|
176
|
+
} catch {
|
|
130
177
|
return null;
|
|
131
|
-
|
|
178
|
+
}
|
|
132
179
|
}
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
180
|
+
function hasSiteAccess(payload, origin) {
|
|
181
|
+
return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
|
|
182
|
+
}
|
|
183
|
+
function hasSiteAccessFor(payload, site) {
|
|
184
|
+
const scopeOrigin = toOrigin(site.scopeOrigin);
|
|
185
|
+
if (!scopeOrigin || payload.aud === undefined)
|
|
186
|
+
return false;
|
|
187
|
+
if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud))
|
|
188
|
+
return false;
|
|
189
|
+
if (payload.admin)
|
|
190
|
+
return true;
|
|
191
|
+
if (isLoopbackHostname(new URL(scopeOrigin).hostname))
|
|
192
|
+
return true;
|
|
193
|
+
return payload.scopes.includes(scopeOrigin);
|
|
194
|
+
}
|
|
195
|
+
async function readJsonObject(res) {
|
|
196
|
+
try {
|
|
197
|
+
const body = await res.json();
|
|
198
|
+
return typeof body === "object" && body !== null ? body : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
138
202
|
}
|
|
139
|
-
async function
|
|
140
|
-
const res = await fetch(
|
|
203
|
+
async function exchangeAuthCode(auth, code, redirectOrigin, codeVerifier) {
|
|
204
|
+
const res = await auth.fetch("/api/exchange-code", {
|
|
141
205
|
method: "POST",
|
|
142
206
|
headers: { "Content-Type": "application/json" },
|
|
143
|
-
body: JSON.stringify({ code, redirectOrigin })
|
|
207
|
+
body: JSON.stringify(codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier })
|
|
144
208
|
});
|
|
145
209
|
if (!res.ok)
|
|
146
210
|
return null;
|
|
147
|
-
const
|
|
148
|
-
|
|
211
|
+
const body = await readJsonObject(res);
|
|
212
|
+
if (!body || !("token" in body) || typeof body.token !== "string")
|
|
213
|
+
return null;
|
|
214
|
+
return body.token;
|
|
149
215
|
}
|
|
150
|
-
async function
|
|
216
|
+
async function checkTokenRemote(auth, token, audience) {
|
|
217
|
+
let res;
|
|
218
|
+
try {
|
|
219
|
+
res = await auth.fetch("/api/verify-token", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: { "Content-Type": "application/json" },
|
|
222
|
+
body: JSON.stringify(audience === undefined ? { token } : { token, audience })
|
|
223
|
+
});
|
|
224
|
+
} catch {
|
|
225
|
+
return { status: "unavailable" };
|
|
226
|
+
}
|
|
227
|
+
if (res.status >= 500)
|
|
228
|
+
return { status: "unavailable" };
|
|
229
|
+
if (!res.ok)
|
|
230
|
+
return { status: "invalid" };
|
|
231
|
+
const body = await readJsonObject(res);
|
|
232
|
+
if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null)
|
|
233
|
+
return { status: "invalid" };
|
|
234
|
+
const user = body.user;
|
|
235
|
+
if (!("id" in user))
|
|
236
|
+
return { status: "invalid" };
|
|
237
|
+
const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
|
|
238
|
+
if (!isTokenPayload(candidate))
|
|
239
|
+
return { status: "invalid" };
|
|
240
|
+
return {
|
|
241
|
+
status: "valid",
|
|
242
|
+
payload: {
|
|
243
|
+
sub: candidate.sub,
|
|
244
|
+
email: candidate.email,
|
|
245
|
+
name: candidate.name,
|
|
246
|
+
admin: candidate.admin,
|
|
247
|
+
scopes: candidate.scopes,
|
|
248
|
+
flags: candidate.flags,
|
|
249
|
+
kid: candidate.kid,
|
|
250
|
+
iat: candidate.iat,
|
|
251
|
+
exp: candidate.exp,
|
|
252
|
+
...candidate.aud === undefined ? {} : { aud: candidate.aud }
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
async function verifyTokenRemote(auth, token, audience) {
|
|
257
|
+
const result = await checkTokenRemote(auth, token, audience);
|
|
258
|
+
return result.status === "valid" ? result.payload : null;
|
|
259
|
+
}
|
|
260
|
+
async function fetchJwks(auth) {
|
|
261
|
+
const res = await auth.fetch("/.well-known/jwk.json");
|
|
262
|
+
if (!res.ok)
|
|
263
|
+
throw new Error(`Failed to fetch JWKS: ${res.status}`);
|
|
264
|
+
const body = await readJsonObject(res);
|
|
265
|
+
if (!body || !("keys" in body) || !Array.isArray(body.keys))
|
|
266
|
+
throw new Error("Malformed JWKS response");
|
|
267
|
+
const entries = [];
|
|
268
|
+
for (const jwk of body.keys) {
|
|
269
|
+
if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string")
|
|
270
|
+
continue;
|
|
271
|
+
if (!("kty" in jwk) || typeof jwk.kty !== "string")
|
|
272
|
+
continue;
|
|
273
|
+
const { kid, ...keyData } = jwk;
|
|
274
|
+
entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
|
|
275
|
+
}
|
|
276
|
+
return entries;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/flow.ts
|
|
280
|
+
var DEFAULT_AUTH_URL = "https://auth.uln.industries";
|
|
281
|
+
var STATE_COOKIE = "__Host-uln_auth_state";
|
|
282
|
+
var INSECURE_STATE_COOKIE = "uln_auth_state";
|
|
283
|
+
var STATE_PARAM = "uln_state";
|
|
284
|
+
var CHALLENGE_PARAM = "uln_challenge";
|
|
285
|
+
var STATE_MAX_AGE_SECONDS = 600;
|
|
286
|
+
function base64url(bytes) {
|
|
287
|
+
let binary = "";
|
|
288
|
+
for (const byte of bytes)
|
|
289
|
+
binary += String.fromCharCode(byte);
|
|
290
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
291
|
+
}
|
|
292
|
+
function randomToken() {
|
|
293
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
294
|
+
}
|
|
295
|
+
async function pkceChallenge(verifier) {
|
|
296
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
297
|
+
}
|
|
298
|
+
function stateCookie(value, secure, maxAgeSeconds) {
|
|
299
|
+
const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
|
|
300
|
+
const parts = [`${name}=${value}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
301
|
+
if (secure)
|
|
302
|
+
parts.push("Secure");
|
|
303
|
+
return parts.join("; ");
|
|
304
|
+
}
|
|
305
|
+
function readStateCookie(request, secure) {
|
|
306
|
+
const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
|
|
307
|
+
const [state = "", verifier = "", ...rest] = (extractCookie(request.headers.get("Cookie") ?? "", name) ?? "").split(".");
|
|
308
|
+
return state && verifier && rest.length === 0 ? { state, verifier } : null;
|
|
309
|
+
}
|
|
310
|
+
function stripCallbackParams(url) {
|
|
311
|
+
url.searchParams.delete("code");
|
|
312
|
+
url.searchParams.delete(STATE_PARAM);
|
|
313
|
+
url.searchParams.delete(CHALLENGE_PARAM);
|
|
314
|
+
}
|
|
315
|
+
async function redirectToLogin(currentUrl, authUrl = DEFAULT_AUTH_URL) {
|
|
316
|
+
const returnUrl = new URL(currentUrl);
|
|
317
|
+
stripCallbackParams(returnUrl);
|
|
318
|
+
const state = randomToken();
|
|
319
|
+
const verifier = randomToken();
|
|
320
|
+
returnUrl.searchParams.set(STATE_PARAM, state);
|
|
321
|
+
returnUrl.searchParams.set(CHALLENGE_PARAM, await pkceChallenge(verifier));
|
|
322
|
+
const loginUrl = new URL("/login", authUrl);
|
|
323
|
+
loginUrl.searchParams.set("redirect", returnUrl.toString());
|
|
324
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
325
|
+
const headers = new Headers({ Location: loginUrl.toString() });
|
|
326
|
+
headers.append("Set-Cookie", stateCookie(`${state}.${verifier}`, returnUrl.protocol === "https:", STATE_MAX_AGE_SECONDS));
|
|
327
|
+
return new Response(null, { status: 302, headers });
|
|
328
|
+
}
|
|
329
|
+
async function handleLoginCallback(request, options) {
|
|
151
330
|
const url = new URL(request.url);
|
|
152
331
|
const code = url.searchParams.get("code");
|
|
153
|
-
if (code)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
332
|
+
if (!code)
|
|
333
|
+
return null;
|
|
334
|
+
const state = url.searchParams.get(STATE_PARAM);
|
|
335
|
+
const secure = url.protocol === "https:";
|
|
336
|
+
const expected = readStateCookie(request, secure);
|
|
337
|
+
stripCallbackParams(url);
|
|
338
|
+
const startOver = () => redirectToLogin(url.toString(), options.authUrl);
|
|
339
|
+
if (!state || !expected || state !== expected.state)
|
|
340
|
+
return startOver();
|
|
341
|
+
const token = await exchangeAuthCode(options.auth, code, url.origin, expected.verifier);
|
|
342
|
+
if (!token)
|
|
343
|
+
return startOver();
|
|
344
|
+
const payload = await options.validate(token);
|
|
345
|
+
if (!payload)
|
|
346
|
+
return startOver();
|
|
347
|
+
const scopeOrigin = options.scopeOrigin ?? url.origin;
|
|
348
|
+
if (!hasSiteAccessFor(payload, { audiences: [url.origin, scopeOrigin], scopeOrigin })) {
|
|
349
|
+
return new Response("You don't have access to this site", { status: 403 });
|
|
170
350
|
}
|
|
171
|
-
|
|
351
|
+
const headers = new Headers({ Location: url.toString() });
|
|
352
|
+
headers.append("Set-Cookie", makeSessionCookie(options.cookieConfig, token));
|
|
353
|
+
headers.append("Set-Cookie", stateCookie("", secure, 0));
|
|
354
|
+
return new Response(null, { status: 302, headers });
|
|
355
|
+
}
|
|
356
|
+
var confirmedAt = new Map;
|
|
357
|
+
var MAX_CONFIRMED = 5000;
|
|
358
|
+
var DEFAULT_REVALIDATE_SECONDS = 300;
|
|
359
|
+
async function revalidateToken(auth, token, local, audience, intervalSeconds = DEFAULT_REVALIDATE_SECONDS) {
|
|
360
|
+
const last = confirmedAt.get(token);
|
|
361
|
+
if (last !== undefined && Date.now() - last < intervalSeconds * 1000)
|
|
362
|
+
return local;
|
|
363
|
+
const result = await checkTokenRemote(auth, token, audience);
|
|
364
|
+
if (result.status === "unavailable")
|
|
365
|
+
return local;
|
|
366
|
+
confirmedAt.delete(token);
|
|
367
|
+
if (result.status === "invalid")
|
|
368
|
+
return null;
|
|
369
|
+
if (confirmedAt.size >= MAX_CONFIRMED) {
|
|
370
|
+
const oldest = confirmedAt.keys().next();
|
|
371
|
+
if (!oldest.done)
|
|
372
|
+
confirmedAt.delete(oldest.value);
|
|
373
|
+
}
|
|
374
|
+
confirmedAt.set(token, Date.now());
|
|
375
|
+
return result.payload;
|
|
376
|
+
}
|
|
377
|
+
function clearRevalidationCache() {
|
|
378
|
+
confirmedAt.clear();
|
|
172
379
|
}
|
|
173
380
|
|
|
381
|
+
// src/middleware.ts
|
|
382
|
+
function getClientIP(request) {
|
|
383
|
+
return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
384
|
+
}
|
|
385
|
+
function checkBearerToken(request, expectedToken) {
|
|
386
|
+
const header = request.headers.get("Authorization") ?? "";
|
|
387
|
+
const expected = `Bearer ${expectedToken}`;
|
|
388
|
+
const a = new TextEncoder().encode(header);
|
|
389
|
+
const b = new TextEncoder().encode(expected);
|
|
390
|
+
let diff = a.length ^ b.length;
|
|
391
|
+
for (let i = 0;i < b.length; i++) {
|
|
392
|
+
diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
393
|
+
}
|
|
394
|
+
return diff === 0;
|
|
395
|
+
}
|
|
396
|
+
async function getAuthUser(request, verifyConfig, cookieConfig, siteOrigin) {
|
|
397
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
398
|
+
if (!cookieHeader)
|
|
399
|
+
return null;
|
|
400
|
+
const token = extractCookie(cookieHeader, cookieConfig.name);
|
|
401
|
+
if (!token)
|
|
402
|
+
return null;
|
|
403
|
+
const audience = new URL(request.url).origin;
|
|
404
|
+
const scopeOrigin = siteOrigin ?? audience;
|
|
405
|
+
const site = { audiences: [audience, scopeOrigin], scopeOrigin };
|
|
406
|
+
const payload = await validateToken(verifyConfig, token);
|
|
407
|
+
if (!payload || !hasSiteAccessFor(payload, site))
|
|
408
|
+
return null;
|
|
409
|
+
const { revalidate } = verifyConfig;
|
|
410
|
+
if (!revalidate)
|
|
411
|
+
return payload;
|
|
412
|
+
const current = await revalidateToken(publicFetcher(revalidate.authUrl), token, payload, payload.aud ?? audience, revalidate.intervalSeconds);
|
|
413
|
+
return current && hasSiteAccessFor(current, site) ? current : null;
|
|
414
|
+
}
|
|
415
|
+
async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
|
|
416
|
+
const authUrl = exchangeConfig?.authUrl ?? DEFAULT_AUTH_URL;
|
|
417
|
+
return handleLoginCallback(request, {
|
|
418
|
+
auth: publicFetcher(authUrl),
|
|
419
|
+
authUrl,
|
|
420
|
+
cookieConfig,
|
|
421
|
+
validate: (token) => validateToken(verifyConfig, token)
|
|
422
|
+
});
|
|
423
|
+
}
|
|
174
424
|
// src/password.ts
|
|
175
425
|
var SALT_LENGTH = 32;
|
|
176
426
|
var KEY_LENGTH = 64;
|
|
177
|
-
var
|
|
427
|
+
var STAGE_ITERATIONS = 1e5;
|
|
428
|
+
var ITERATIONS = 600000;
|
|
429
|
+
var MAX_ITERATIONS = 1e7;
|
|
430
|
+
var MAX_PASSWORD_LENGTH = 1024;
|
|
431
|
+
var HEX_REGEX = /^(?:[0-9a-f]{2})+$/;
|
|
432
|
+
var PREFIX = "pbkdf2-sha256";
|
|
178
433
|
function toHex(buffer) {
|
|
179
434
|
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
180
435
|
}
|
|
@@ -185,98 +440,76 @@ function fromHex(hex) {
|
|
|
185
440
|
}
|
|
186
441
|
return bytes;
|
|
187
442
|
}
|
|
188
|
-
async function deriveKey(password, salt) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
salt,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
443
|
+
async function deriveKey(password, salt, iterations) {
|
|
444
|
+
let material = new TextEncoder().encode(password);
|
|
445
|
+
let derived = new ArrayBuffer(0);
|
|
446
|
+
for (let done = 0;done < iterations; done += STAGE_ITERATIONS) {
|
|
447
|
+
const keyMaterial = await crypto.subtle.importKey("raw", material, "PBKDF2", false, ["deriveBits"]);
|
|
448
|
+
derived = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: STAGE_ITERATIONS, hash: "SHA-256" }, keyMaterial, KEY_LENGTH * 8);
|
|
449
|
+
material = new Uint8Array(derived);
|
|
450
|
+
}
|
|
451
|
+
return derived;
|
|
452
|
+
}
|
|
453
|
+
function parseHash(storedHash) {
|
|
454
|
+
let iterations = STAGE_ITERATIONS;
|
|
455
|
+
let saltHex;
|
|
456
|
+
let keyHex;
|
|
457
|
+
if (storedHash.startsWith(`${PREFIX}$`)) {
|
|
458
|
+
const [, rawIterations = "", salt = "", key = ""] = storedHash.split("$");
|
|
459
|
+
if (!/^[1-9]\d*$/.test(rawIterations))
|
|
460
|
+
return null;
|
|
461
|
+
iterations = Number(rawIterations);
|
|
462
|
+
if (iterations % STAGE_ITERATIONS !== 0 || iterations > MAX_ITERATIONS)
|
|
463
|
+
return null;
|
|
464
|
+
saltHex = salt;
|
|
465
|
+
keyHex = key;
|
|
466
|
+
} else {
|
|
467
|
+
const separatorIndex = storedHash.indexOf(":");
|
|
468
|
+
if (separatorIndex === -1)
|
|
469
|
+
return null;
|
|
470
|
+
saltHex = storedHash.substring(0, separatorIndex);
|
|
471
|
+
keyHex = storedHash.substring(separatorIndex + 1);
|
|
472
|
+
}
|
|
473
|
+
if (!HEX_REGEX.test(saltHex) || !HEX_REGEX.test(keyHex))
|
|
474
|
+
return null;
|
|
475
|
+
return { iterations, salt: fromHex(saltHex), key: fromHex(keyHex) };
|
|
197
476
|
}
|
|
198
477
|
async function hashPassword(password) {
|
|
199
478
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
200
|
-
const derived = await deriveKey(password, salt);
|
|
201
|
-
return `${toHex(salt.buffer)}
|
|
479
|
+
const derived = await deriveKey(password, salt, ITERATIONS);
|
|
480
|
+
return `${PREFIX}$${ITERATIONS}$${toHex(salt.buffer)}$${toHex(derived)}`;
|
|
202
481
|
}
|
|
203
482
|
async function verifyPassword(password, storedHash) {
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
483
|
+
const parsed = parseHash(storedHash);
|
|
484
|
+
if (!parsed)
|
|
206
485
|
return false;
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
const salt = fromHex(saltHex);
|
|
210
|
-
const expectedKey = fromHex(keyHex);
|
|
211
|
-
const derived = new Uint8Array(await deriveKey(password, salt));
|
|
212
|
-
if (derived.length !== expectedKey.length)
|
|
486
|
+
const derived = new Uint8Array(await deriveKey(password, parsed.salt, parsed.iterations));
|
|
487
|
+
if (derived.length !== parsed.key.length)
|
|
213
488
|
return false;
|
|
214
489
|
let diff = 0;
|
|
215
490
|
for (let i = 0;i < derived.length; i++) {
|
|
216
|
-
diff |= (derived[i] ?? 0) ^ (
|
|
491
|
+
diff |= (derived[i] ?? 0) ^ (parsed.key[i] ?? 0);
|
|
217
492
|
}
|
|
218
493
|
return diff === 0;
|
|
219
494
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
223
|
-
function isValidEmail(email) {
|
|
224
|
-
return email.length <= 254 && EMAIL_REGEX.test(email);
|
|
225
|
-
}
|
|
226
|
-
async function createUser(store, input) {
|
|
227
|
-
if (!isValidEmail(input.email))
|
|
228
|
-
throw new Error("Invalid email address");
|
|
229
|
-
const existing = await store.getUserByEmail(input.email);
|
|
230
|
-
if (existing)
|
|
231
|
-
throw new Error(`User with email ${input.email} already exists`);
|
|
232
|
-
const now = new Date().toISOString();
|
|
233
|
-
const passwordHash = input.password ? await hashPassword(input.password) : await hashPassword(crypto.randomUUID());
|
|
234
|
-
const user = {
|
|
235
|
-
id: crypto.randomUUID(),
|
|
236
|
-
email: input.email.toLowerCase(),
|
|
237
|
-
name: input.name,
|
|
238
|
-
passwordHash,
|
|
239
|
-
isAdmin: input.isAdmin ?? false,
|
|
240
|
-
emailVerified: input.isAdmin ?? false,
|
|
241
|
-
totpSecret: null,
|
|
242
|
-
totpEnabled: false,
|
|
243
|
-
createdAt: now,
|
|
244
|
-
updatedAt: now
|
|
245
|
-
};
|
|
246
|
-
await store.saveUser(user);
|
|
247
|
-
return user;
|
|
248
|
-
}
|
|
249
|
-
async function verifyUserCredentials(store, email, password) {
|
|
250
|
-
const user = await store.getUserByEmail(email.toLowerCase());
|
|
251
|
-
if (!user)
|
|
252
|
-
return null;
|
|
253
|
-
const valid = await verifyPassword(password, user.passwordHash);
|
|
254
|
-
return valid ? user : null;
|
|
495
|
+
function needsRehash(storedHash) {
|
|
496
|
+
return !storedHash.startsWith(`${PREFIX}$`) || (parseHash(storedHash)?.iterations ?? 0) < ITERATIONS;
|
|
255
497
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
updatedAt: new Date().toISOString()
|
|
264
|
-
};
|
|
265
|
-
await store.saveUser(updated);
|
|
498
|
+
// src/rate-limit.ts
|
|
499
|
+
async function consumeRateLimit(store, key, config) {
|
|
500
|
+
const entry = store.increment ? await store.increment(key, config.windowSeconds) : await incrementViaGetSet(store, key, config);
|
|
501
|
+
if (entry.attempts <= config.maxAttempts)
|
|
502
|
+
return { blocked: false };
|
|
503
|
+
const remaining = config.windowSeconds - Math.floor((Date.now() - entry.firstAttempt) / 1000);
|
|
504
|
+
return { blocked: true, remainingSeconds: Math.max(remaining, 1) };
|
|
266
505
|
}
|
|
267
|
-
async function
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
passwordHash: await hashPassword(newPassword),
|
|
274
|
-
updatedAt: new Date().toISOString()
|
|
275
|
-
};
|
|
276
|
-
await store.saveUser(updated);
|
|
506
|
+
async function incrementViaGetSet(store, key, config) {
|
|
507
|
+
const existing = await store.get(key);
|
|
508
|
+
const windowOver = existing !== null && Date.now() - existing.firstAttempt >= config.windowSeconds * 1000;
|
|
509
|
+
const entry = existing && !windowOver ? { attempts: existing.attempts + 1, firstAttempt: existing.firstAttempt } : { attempts: 1, firstAttempt: Date.now() };
|
|
510
|
+
await store.set(key, entry, config.windowSeconds);
|
|
511
|
+
return entry;
|
|
277
512
|
}
|
|
278
|
-
|
|
279
|
-
// src/rate-limit.ts
|
|
280
513
|
async function checkRateLimit(store, key, config) {
|
|
281
514
|
const entry = await store.get(key);
|
|
282
515
|
if (!entry)
|
|
@@ -292,6 +525,10 @@ async function checkRateLimit(store, key, config) {
|
|
|
292
525
|
return { blocked: false };
|
|
293
526
|
}
|
|
294
527
|
async function recordFailedAttempt(store, key, config) {
|
|
528
|
+
if (store.increment) {
|
|
529
|
+
await store.increment(key, config.windowSeconds);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
295
532
|
const entry = await store.get(key);
|
|
296
533
|
if (entry) {
|
|
297
534
|
await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
|
|
@@ -302,7 +539,6 @@ async function recordFailedAttempt(store, key, config) {
|
|
|
302
539
|
async function clearRateLimit(store, key) {
|
|
303
540
|
await store.delete(key);
|
|
304
541
|
}
|
|
305
|
-
|
|
306
542
|
// src/totp.ts
|
|
307
543
|
var TOTP_PERIOD = 30;
|
|
308
544
|
var TOTP_DIGITS = 6;
|
|
@@ -380,17 +616,21 @@ async function currentTotpCode(secret, nowMs = Date.now()) {
|
|
|
380
616
|
const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
|
|
381
617
|
return generateCode(secret, step);
|
|
382
618
|
}
|
|
383
|
-
async function
|
|
384
|
-
if (code
|
|
385
|
-
return
|
|
386
|
-
const
|
|
387
|
-
|
|
619
|
+
async function verifyTotpStep(secret, code, nowMs = Date.now()) {
|
|
620
|
+
if (!/^\d{6}$/.test(code))
|
|
621
|
+
return null;
|
|
622
|
+
const currentStep = Math.floor(nowMs / 1000 / TOTP_PERIOD);
|
|
623
|
+
let matched = null;
|
|
388
624
|
for (let offset = -TOTP_WINDOW;offset <= TOTP_WINDOW; offset++) {
|
|
389
|
-
const
|
|
625
|
+
const step = currentStep + offset;
|
|
626
|
+
const expected = await generateCode(secret, step);
|
|
390
627
|
if (timingSafeEqual(code, expected))
|
|
391
|
-
|
|
628
|
+
matched = step;
|
|
392
629
|
}
|
|
393
|
-
return
|
|
630
|
+
return matched;
|
|
631
|
+
}
|
|
632
|
+
async function verifyTotpCode(secret, code) {
|
|
633
|
+
return await verifyTotpStep(secret, code) !== null;
|
|
394
634
|
}
|
|
395
635
|
var BACKUP_CODE_COUNT = 10;
|
|
396
636
|
var BACKUP_CODE_LENGTH = 8;
|
|
@@ -398,11 +638,14 @@ var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
|
398
638
|
function generateBackupCodes() {
|
|
399
639
|
const codes = [];
|
|
400
640
|
for (let i = 0;i < BACKUP_CODE_COUNT; i++) {
|
|
401
|
-
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
402
|
-
crypto.getRandomValues(bytes);
|
|
403
641
|
let code = "";
|
|
404
|
-
|
|
405
|
-
|
|
642
|
+
while (code.length < BACKUP_CODE_LENGTH) {
|
|
643
|
+
const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
|
|
644
|
+
crypto.getRandomValues(bytes);
|
|
645
|
+
for (const byte of bytes) {
|
|
646
|
+
if (byte < 252 && code.length < BACKUP_CODE_LENGTH)
|
|
647
|
+
code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
|
|
648
|
+
}
|
|
406
649
|
}
|
|
407
650
|
codes.push(code);
|
|
408
651
|
}
|
|
@@ -422,32 +665,109 @@ function timingSafeEqual(a, b) {
|
|
|
422
665
|
}
|
|
423
666
|
return result === 0;
|
|
424
667
|
}
|
|
668
|
+
// src/users.ts
|
|
669
|
+
var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
670
|
+
function isValidEmail(email) {
|
|
671
|
+
return email.length <= 254 && EMAIL_REGEX.test(email);
|
|
672
|
+
}
|
|
673
|
+
async function createUser(store, input) {
|
|
674
|
+
if (!isValidEmail(input.email))
|
|
675
|
+
throw new Error("Invalid email address");
|
|
676
|
+
if (input.password !== undefined && input.password.length > MAX_PASSWORD_LENGTH)
|
|
677
|
+
throw new Error("Password is too long");
|
|
678
|
+
const existing = await store.getUserByEmail(input.email);
|
|
679
|
+
if (existing)
|
|
680
|
+
throw new Error(`User with email ${input.email} already exists`);
|
|
681
|
+
const now = new Date().toISOString();
|
|
682
|
+
const passwordHash = input.password ? await hashPassword(input.password) : await hashPassword(crypto.randomUUID());
|
|
683
|
+
const user = {
|
|
684
|
+
id: crypto.randomUUID(),
|
|
685
|
+
email: input.email.toLowerCase(),
|
|
686
|
+
name: input.name,
|
|
687
|
+
passwordHash,
|
|
688
|
+
isAdmin: input.isAdmin ?? false,
|
|
689
|
+
emailVerified: input.isAdmin ?? false,
|
|
690
|
+
totpSecret: null,
|
|
691
|
+
totpEnabled: false,
|
|
692
|
+
createdAt: now,
|
|
693
|
+
updatedAt: now
|
|
694
|
+
};
|
|
695
|
+
await store.saveUser(user);
|
|
696
|
+
return user;
|
|
697
|
+
}
|
|
698
|
+
var dummyHash = null;
|
|
699
|
+
async function verifyUserCredentials(store, email, password) {
|
|
700
|
+
const user = await store.getUserByEmail(email.toLowerCase());
|
|
701
|
+
if (!user) {
|
|
702
|
+
dummyHash ??= hashPassword(crypto.randomUUID());
|
|
703
|
+
await verifyPassword(password, await dummyHash);
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
const valid = await verifyPassword(password, user.passwordHash);
|
|
707
|
+
if (!valid)
|
|
708
|
+
return null;
|
|
709
|
+
if (!needsRehash(user.passwordHash))
|
|
710
|
+
return user;
|
|
711
|
+
const upgraded = { ...user, passwordHash: await hashPassword(password), updatedAt: new Date().toISOString() };
|
|
712
|
+
await store.saveUser(upgraded);
|
|
713
|
+
return upgraded;
|
|
714
|
+
}
|
|
715
|
+
async function updateName(store, userId, name) {
|
|
716
|
+
const user = await store.getUser(userId);
|
|
717
|
+
if (!user)
|
|
718
|
+
throw new Error(`User ${userId} not found`);
|
|
719
|
+
const updated = {
|
|
720
|
+
...user,
|
|
721
|
+
name,
|
|
722
|
+
updatedAt: new Date().toISOString()
|
|
723
|
+
};
|
|
724
|
+
await store.saveUser(updated);
|
|
725
|
+
}
|
|
726
|
+
async function changePassword(store, userId, newPassword) {
|
|
727
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH)
|
|
728
|
+
throw new Error("Password is too long");
|
|
729
|
+
const user = await store.getUser(userId);
|
|
730
|
+
if (!user)
|
|
731
|
+
throw new Error(`User ${userId} not found`);
|
|
732
|
+
const updated = {
|
|
733
|
+
...user,
|
|
734
|
+
passwordHash: await hashPassword(newPassword),
|
|
735
|
+
updatedAt: new Date().toISOString()
|
|
736
|
+
};
|
|
737
|
+
await store.saveUser(updated);
|
|
738
|
+
}
|
|
425
739
|
export {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
verifyPassword,
|
|
429
|
-
validateToken,
|
|
430
|
-
redirectToLogin,
|
|
431
|
-
recordFailedAttempt,
|
|
432
|
-
makeSessionCookie,
|
|
433
|
-
makeClearCookie,
|
|
434
|
-
isValidEmail,
|
|
435
|
-
importPublicKey,
|
|
436
|
-
importPrivateKey,
|
|
437
|
-
hashPassword,
|
|
438
|
-
hashBackupCode,
|
|
439
|
-
getTotpUri,
|
|
440
|
-
getClientIP,
|
|
441
|
-
getAuthUser,
|
|
442
|
-
generateTotpSecret,
|
|
443
|
-
generateKeyPair,
|
|
444
|
-
generateBackupCodes,
|
|
445
|
-
extractCookie,
|
|
446
|
-
createUser,
|
|
447
|
-
createToken,
|
|
448
|
-
clearRateLimit,
|
|
449
|
-
checkRateLimit,
|
|
450
|
-
checkBearerToken,
|
|
740
|
+
MAX_PASSWORD_LENGTH,
|
|
741
|
+
base32Encode,
|
|
451
742
|
changePassword,
|
|
452
|
-
|
|
743
|
+
checkBearerToken,
|
|
744
|
+
checkRateLimit,
|
|
745
|
+
clearRateLimit,
|
|
746
|
+
consumeRateLimit,
|
|
747
|
+
createToken,
|
|
748
|
+
createUser,
|
|
749
|
+
extractCookie,
|
|
750
|
+
generateBackupCodes,
|
|
751
|
+
generateKeyPair,
|
|
752
|
+
generateTotpSecret,
|
|
753
|
+
getAuthUser,
|
|
754
|
+
getClientIP,
|
|
755
|
+
getTotpUri,
|
|
756
|
+
hasSiteAccess,
|
|
757
|
+
hashBackupCode,
|
|
758
|
+
hashPassword,
|
|
759
|
+
importPrivateKey,
|
|
760
|
+
importPublicKey,
|
|
761
|
+
isTokenPayload,
|
|
762
|
+
isValidEmail,
|
|
763
|
+
makeClearCookie,
|
|
764
|
+
makeSessionCookie,
|
|
765
|
+
needsRehash,
|
|
766
|
+
recordFailedAttempt,
|
|
767
|
+
redirectToLogin,
|
|
768
|
+
validateToken,
|
|
769
|
+
verifyPassword,
|
|
770
|
+
verifyTotpCode,
|
|
771
|
+
verifyTotpStep,
|
|
772
|
+
verifyUserCredentials
|
|
453
773
|
};
|