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