@uln/auth 0.4.2 → 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/hono.js
CHANGED
|
@@ -33,6 +33,8 @@ function base64urlEncode(data) {
|
|
|
33
33
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
34
|
}
|
|
35
35
|
function base64urlDecode(str) {
|
|
36
|
+
if (!/^[A-Za-z0-9_-]*$/.test(str))
|
|
37
|
+
throw new Error("Invalid base64url");
|
|
36
38
|
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
37
39
|
const binary = atob(padded);
|
|
38
40
|
const bytes = new Uint8Array(binary.length);
|
|
@@ -60,8 +62,8 @@ async function importPrivateKey(jwk) {
|
|
|
60
62
|
async function importPublicKey(jwk) {
|
|
61
63
|
return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
|
|
62
64
|
}
|
|
63
|
-
async function createToken(config, user) {
|
|
64
|
-
const now = Math.floor(Date.now() / 1000);
|
|
65
|
+
async function createToken(config, user, options = {}) {
|
|
66
|
+
const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
|
|
65
67
|
const payload = {
|
|
66
68
|
sub: user.id,
|
|
67
69
|
email: user.email,
|
|
@@ -71,7 +73,8 @@ async function createToken(config, user) {
|
|
|
71
73
|
flags: user.flags,
|
|
72
74
|
kid: config.kid,
|
|
73
75
|
iat: now,
|
|
74
|
-
exp: now + config.maxAgeSeconds
|
|
76
|
+
exp: now + config.maxAgeSeconds,
|
|
77
|
+
...user.audience === undefined ? {} : { aud: user.audience }
|
|
75
78
|
};
|
|
76
79
|
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
|
77
80
|
const payloadBytes = encoder.encode(payloadB64);
|
|
@@ -79,25 +82,46 @@ async function createToken(config, user) {
|
|
|
79
82
|
const sigB64 = base64urlEncode(new Uint8Array(signature));
|
|
80
83
|
return `${payloadB64}.${sigB64}`;
|
|
81
84
|
}
|
|
85
|
+
var CLOCK_SKEW_SECONDS = 60;
|
|
86
|
+
function isStringArray(value) {
|
|
87
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
88
|
+
}
|
|
89
|
+
function isFlags(value) {
|
|
90
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
91
|
+
return false;
|
|
92
|
+
return Object.values(value).every(isStringArray);
|
|
93
|
+
}
|
|
94
|
+
function isTokenPayload(value) {
|
|
95
|
+
if (typeof value !== "object" || value === null)
|
|
96
|
+
return false;
|
|
97
|
+
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)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
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");
|
|
101
|
+
}
|
|
82
102
|
async function validateToken(config, token) {
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
103
|
+
const parts = token.split(".");
|
|
104
|
+
if (parts.length !== 2)
|
|
105
|
+
return null;
|
|
106
|
+
const [payloadB64 = "", sigB64 = ""] = parts;
|
|
107
|
+
if (!payloadB64 || !sigB64)
|
|
85
108
|
return null;
|
|
86
|
-
|
|
87
|
-
const sigB64 = token.substring(dotIndex + 1);
|
|
88
|
-
let payload;
|
|
109
|
+
let decoded;
|
|
89
110
|
try {
|
|
90
|
-
|
|
91
|
-
payload = JSON.parse(decoded);
|
|
111
|
+
decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
92
112
|
} catch {
|
|
93
113
|
return null;
|
|
94
114
|
}
|
|
95
|
-
if (
|
|
115
|
+
if (!isTokenPayload(decoded))
|
|
96
116
|
return null;
|
|
97
|
-
|
|
117
|
+
const payload = decoded;
|
|
98
118
|
const now = Math.floor(Date.now() / 1000);
|
|
99
119
|
if (now > payload.exp)
|
|
100
120
|
return null;
|
|
121
|
+
if (payload.iat > now + CLOCK_SKEW_SECONDS)
|
|
122
|
+
return null;
|
|
123
|
+
if (now - payload.iat > config.maxAgeSeconds)
|
|
124
|
+
return null;
|
|
101
125
|
const entry = config.publicKeys.find((k) => k.kid === payload.kid);
|
|
102
126
|
if (!entry)
|
|
103
127
|
return null;
|
|
@@ -112,10 +136,251 @@ async function validateToken(config, token) {
|
|
|
112
136
|
}
|
|
113
137
|
return payload;
|
|
114
138
|
}
|
|
139
|
+
function peekTokenPayload(token) {
|
|
140
|
+
const payloadB64 = token.split(".")[0];
|
|
141
|
+
if (!payloadB64)
|
|
142
|
+
return null;
|
|
143
|
+
try {
|
|
144
|
+
const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
145
|
+
return isTokenPayload(decoded) ? decoded : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function peekTokenKid(token) {
|
|
151
|
+
const payloadB64 = token.split(".")[0];
|
|
152
|
+
if (!payloadB64)
|
|
153
|
+
return null;
|
|
154
|
+
try {
|
|
155
|
+
const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
|
156
|
+
if (typeof decoded !== "object" || decoded === null || !("kid" in decoded))
|
|
157
|
+
return null;
|
|
158
|
+
return typeof decoded.kid === "string" ? decoded.kid : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
115
163
|
|
|
116
|
-
// src/
|
|
164
|
+
// src/remote.ts
|
|
165
|
+
function publicFetcher(authUrl) {
|
|
166
|
+
return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
|
|
167
|
+
}
|
|
168
|
+
function bindingFetcher(binding) {
|
|
169
|
+
return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
|
|
170
|
+
}
|
|
171
|
+
function isLoopbackHostname(hostname) {
|
|
172
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
173
|
+
}
|
|
174
|
+
function toOrigin(value) {
|
|
175
|
+
try {
|
|
176
|
+
return new URL(value).origin;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function hasSiteAccess(payload, origin) {
|
|
182
|
+
return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
|
|
183
|
+
}
|
|
184
|
+
function hasSiteAccessFor(payload, site) {
|
|
185
|
+
const scopeOrigin = toOrigin(site.scopeOrigin);
|
|
186
|
+
if (!scopeOrigin || payload.aud === undefined)
|
|
187
|
+
return false;
|
|
188
|
+
if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud))
|
|
189
|
+
return false;
|
|
190
|
+
if (payload.admin)
|
|
191
|
+
return true;
|
|
192
|
+
if (isLoopbackHostname(new URL(scopeOrigin).hostname))
|
|
193
|
+
return true;
|
|
194
|
+
return payload.scopes.includes(scopeOrigin);
|
|
195
|
+
}
|
|
196
|
+
async function readJsonObject(res) {
|
|
197
|
+
try {
|
|
198
|
+
const body = await res.json();
|
|
199
|
+
return typeof body === "object" && body !== null ? body : null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function exchangeAuthCode(auth, code, redirectOrigin, codeVerifier) {
|
|
205
|
+
const res = await auth.fetch("/api/exchange-code", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "Content-Type": "application/json" },
|
|
208
|
+
body: JSON.stringify(codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier })
|
|
209
|
+
});
|
|
210
|
+
if (!res.ok)
|
|
211
|
+
return null;
|
|
212
|
+
const body = await readJsonObject(res);
|
|
213
|
+
if (!body || !("token" in body) || typeof body.token !== "string")
|
|
214
|
+
return null;
|
|
215
|
+
return body.token;
|
|
216
|
+
}
|
|
217
|
+
async function checkTokenRemote(auth, token, audience) {
|
|
218
|
+
let res;
|
|
219
|
+
try {
|
|
220
|
+
res = await auth.fetch("/api/verify-token", {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { "Content-Type": "application/json" },
|
|
223
|
+
body: JSON.stringify(audience === undefined ? { token } : { token, audience })
|
|
224
|
+
});
|
|
225
|
+
} catch {
|
|
226
|
+
return { status: "unavailable" };
|
|
227
|
+
}
|
|
228
|
+
if (res.status >= 500)
|
|
229
|
+
return { status: "unavailable" };
|
|
230
|
+
if (!res.ok)
|
|
231
|
+
return { status: "invalid" };
|
|
232
|
+
const body = await readJsonObject(res);
|
|
233
|
+
if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null)
|
|
234
|
+
return { status: "invalid" };
|
|
235
|
+
const user = body.user;
|
|
236
|
+
if (!("id" in user))
|
|
237
|
+
return { status: "invalid" };
|
|
238
|
+
const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
|
|
239
|
+
if (!isTokenPayload(candidate))
|
|
240
|
+
return { status: "invalid" };
|
|
241
|
+
return {
|
|
242
|
+
status: "valid",
|
|
243
|
+
payload: {
|
|
244
|
+
sub: candidate.sub,
|
|
245
|
+
email: candidate.email,
|
|
246
|
+
name: candidate.name,
|
|
247
|
+
admin: candidate.admin,
|
|
248
|
+
scopes: candidate.scopes,
|
|
249
|
+
flags: candidate.flags,
|
|
250
|
+
kid: candidate.kid,
|
|
251
|
+
iat: candidate.iat,
|
|
252
|
+
exp: candidate.exp,
|
|
253
|
+
...candidate.aud === undefined ? {} : { aud: candidate.aud }
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
async function verifyTokenRemote(auth, token, audience) {
|
|
258
|
+
const result = await checkTokenRemote(auth, token, audience);
|
|
259
|
+
return result.status === "valid" ? result.payload : null;
|
|
260
|
+
}
|
|
261
|
+
async function fetchJwks(auth) {
|
|
262
|
+
const res = await auth.fetch("/.well-known/jwk.json");
|
|
263
|
+
if (!res.ok)
|
|
264
|
+
throw new Error(`Failed to fetch JWKS: ${res.status}`);
|
|
265
|
+
const body = await readJsonObject(res);
|
|
266
|
+
if (!body || !("keys" in body) || !Array.isArray(body.keys))
|
|
267
|
+
throw new Error("Malformed JWKS response");
|
|
268
|
+
const entries = [];
|
|
269
|
+
for (const jwk of body.keys) {
|
|
270
|
+
if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string")
|
|
271
|
+
continue;
|
|
272
|
+
if (!("kty" in jwk) || typeof jwk.kty !== "string")
|
|
273
|
+
continue;
|
|
274
|
+
const { kid, ...keyData } = jwk;
|
|
275
|
+
entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
|
|
276
|
+
}
|
|
277
|
+
return entries;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/flow.ts
|
|
117
281
|
var DEFAULT_AUTH_URL = "https://auth.uln.industries";
|
|
118
|
-
var
|
|
282
|
+
var STATE_COOKIE = "__Host-uln_auth_state";
|
|
283
|
+
var INSECURE_STATE_COOKIE = "uln_auth_state";
|
|
284
|
+
var STATE_PARAM = "uln_state";
|
|
285
|
+
var CHALLENGE_PARAM = "uln_challenge";
|
|
286
|
+
var STATE_MAX_AGE_SECONDS = 600;
|
|
287
|
+
function base64url(bytes) {
|
|
288
|
+
let binary = "";
|
|
289
|
+
for (const byte of bytes)
|
|
290
|
+
binary += String.fromCharCode(byte);
|
|
291
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
292
|
+
}
|
|
293
|
+
function randomToken() {
|
|
294
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
295
|
+
}
|
|
296
|
+
async function pkceChallenge(verifier) {
|
|
297
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
298
|
+
}
|
|
299
|
+
function stateCookie(value, secure, maxAgeSeconds) {
|
|
300
|
+
const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
|
|
301
|
+
const parts = [`${name}=${value}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
|
|
302
|
+
if (secure)
|
|
303
|
+
parts.push("Secure");
|
|
304
|
+
return parts.join("; ");
|
|
305
|
+
}
|
|
306
|
+
function readStateCookie(request, secure) {
|
|
307
|
+
const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
|
|
308
|
+
const [state = "", verifier = "", ...rest] = (extractCookie(request.headers.get("Cookie") ?? "", name) ?? "").split(".");
|
|
309
|
+
return state && verifier && rest.length === 0 ? { state, verifier } : null;
|
|
310
|
+
}
|
|
311
|
+
function stripCallbackParams(url) {
|
|
312
|
+
url.searchParams.delete("code");
|
|
313
|
+
url.searchParams.delete(STATE_PARAM);
|
|
314
|
+
url.searchParams.delete(CHALLENGE_PARAM);
|
|
315
|
+
}
|
|
316
|
+
async function redirectToLogin(currentUrl, authUrl = DEFAULT_AUTH_URL) {
|
|
317
|
+
const returnUrl = new URL(currentUrl);
|
|
318
|
+
stripCallbackParams(returnUrl);
|
|
319
|
+
const state = randomToken();
|
|
320
|
+
const verifier = randomToken();
|
|
321
|
+
returnUrl.searchParams.set(STATE_PARAM, state);
|
|
322
|
+
returnUrl.searchParams.set(CHALLENGE_PARAM, await pkceChallenge(verifier));
|
|
323
|
+
const loginUrl = new URL("/login", authUrl);
|
|
324
|
+
loginUrl.searchParams.set("redirect", returnUrl.toString());
|
|
325
|
+
loginUrl.searchParams.set("response_type", "code");
|
|
326
|
+
const headers = new Headers({ Location: loginUrl.toString() });
|
|
327
|
+
headers.append("Set-Cookie", stateCookie(`${state}.${verifier}`, returnUrl.protocol === "https:", STATE_MAX_AGE_SECONDS));
|
|
328
|
+
return new Response(null, { status: 302, headers });
|
|
329
|
+
}
|
|
330
|
+
async function handleLoginCallback(request, options) {
|
|
331
|
+
const url = new URL(request.url);
|
|
332
|
+
const code = url.searchParams.get("code");
|
|
333
|
+
if (!code)
|
|
334
|
+
return null;
|
|
335
|
+
const state = url.searchParams.get(STATE_PARAM);
|
|
336
|
+
const secure = url.protocol === "https:";
|
|
337
|
+
const expected = readStateCookie(request, secure);
|
|
338
|
+
stripCallbackParams(url);
|
|
339
|
+
const startOver = () => redirectToLogin(url.toString(), options.authUrl);
|
|
340
|
+
if (!state || !expected || state !== expected.state)
|
|
341
|
+
return startOver();
|
|
342
|
+
const token = await exchangeAuthCode(options.auth, code, url.origin, expected.verifier);
|
|
343
|
+
if (!token)
|
|
344
|
+
return startOver();
|
|
345
|
+
const payload = await options.validate(token);
|
|
346
|
+
if (!payload)
|
|
347
|
+
return startOver();
|
|
348
|
+
const scopeOrigin = options.scopeOrigin ?? url.origin;
|
|
349
|
+
if (!hasSiteAccessFor(payload, { audiences: [url.origin, scopeOrigin], scopeOrigin })) {
|
|
350
|
+
return new Response("You don't have access to this site", { status: 403 });
|
|
351
|
+
}
|
|
352
|
+
const headers = new Headers({ Location: url.toString() });
|
|
353
|
+
headers.append("Set-Cookie", makeSessionCookie(options.cookieConfig, token));
|
|
354
|
+
headers.append("Set-Cookie", stateCookie("", secure, 0));
|
|
355
|
+
return new Response(null, { status: 302, headers });
|
|
356
|
+
}
|
|
357
|
+
var confirmedAt = new Map;
|
|
358
|
+
var MAX_CONFIRMED = 5000;
|
|
359
|
+
var DEFAULT_REVALIDATE_SECONDS = 300;
|
|
360
|
+
async function revalidateToken(auth, token, local, audience, intervalSeconds = DEFAULT_REVALIDATE_SECONDS) {
|
|
361
|
+
const last = confirmedAt.get(token);
|
|
362
|
+
if (last !== undefined && Date.now() - last < intervalSeconds * 1000)
|
|
363
|
+
return local;
|
|
364
|
+
const result = await checkTokenRemote(auth, token, audience);
|
|
365
|
+
if (result.status === "unavailable")
|
|
366
|
+
return local;
|
|
367
|
+
confirmedAt.delete(token);
|
|
368
|
+
if (result.status === "invalid")
|
|
369
|
+
return null;
|
|
370
|
+
if (confirmedAt.size >= MAX_CONFIRMED) {
|
|
371
|
+
const oldest = confirmedAt.keys().next();
|
|
372
|
+
if (!oldest.done)
|
|
373
|
+
confirmedAt.delete(oldest.value);
|
|
374
|
+
}
|
|
375
|
+
confirmedAt.set(token, Date.now());
|
|
376
|
+
return result.payload;
|
|
377
|
+
}
|
|
378
|
+
function clearRevalidationCache() {
|
|
379
|
+
confirmedAt.clear();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/hono.ts
|
|
383
|
+
var DEFAULT_COOKIE = "__Host-uln_auth";
|
|
119
384
|
var DEFAULT_MAX_AGE = 86400;
|
|
120
385
|
function isPublicPath(pathname, patterns) {
|
|
121
386
|
for (const pattern of patterns) {
|
|
@@ -129,70 +394,31 @@ function isPublicPath(pathname, patterns) {
|
|
|
129
394
|
}
|
|
130
395
|
return false;
|
|
131
396
|
}
|
|
132
|
-
var
|
|
133
|
-
var
|
|
134
|
-
var
|
|
135
|
-
async function getPublicKeys(authUrl) {
|
|
136
|
-
|
|
137
|
-
|
|
397
|
+
var keyCaches = new Map;
|
|
398
|
+
var CACHE_TTL = 10 * 60000;
|
|
399
|
+
var MIN_REFRESH_INTERVAL = 1e4;
|
|
400
|
+
async function getPublicKeys(authUrl, wantedKid) {
|
|
401
|
+
const cached = keyCaches.get(authUrl);
|
|
402
|
+
const age = cached ? Date.now() - cached.fetchedAt : Number.POSITIVE_INFINITY;
|
|
403
|
+
const hasKid = wantedKid === null || cached?.keys.some((k) => k.kid === wantedKid);
|
|
404
|
+
if (cached && age < CACHE_TTL && (hasKid || age < MIN_REFRESH_INTERVAL)) {
|
|
405
|
+
return cached.keys;
|
|
138
406
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const jwks = await res.json();
|
|
143
|
-
cachedKeys = await Promise.all(jwks.keys.map(async (jwk) => ({
|
|
144
|
-
kid: jwk.kid,
|
|
145
|
-
key: await importPublicKey(jwk)
|
|
146
|
-
})));
|
|
147
|
-
cachedAt = Date.now();
|
|
148
|
-
return cachedKeys;
|
|
149
|
-
}
|
|
150
|
-
async function validateWithPublicKeys(token, authUrl, maxAge) {
|
|
151
|
-
const publicKeys = await getPublicKeys(authUrl);
|
|
152
|
-
return validateToken({ publicKeys, maxAgeSeconds: maxAge }, token);
|
|
153
|
-
}
|
|
154
|
-
async function validateWithBinding(token, binding) {
|
|
155
|
-
const res = await binding.fetch("https://auth/api/verify-token", {
|
|
156
|
-
method: "POST",
|
|
157
|
-
headers: { "Content-Type": "application/json" },
|
|
158
|
-
body: JSON.stringify({ token })
|
|
159
|
-
});
|
|
160
|
-
if (!res.ok)
|
|
161
|
-
return null;
|
|
162
|
-
const data = await res.json();
|
|
163
|
-
return {
|
|
164
|
-
sub: data.user.id,
|
|
165
|
-
email: data.user.email,
|
|
166
|
-
name: data.user.name,
|
|
167
|
-
admin: data.user.admin,
|
|
168
|
-
scopes: data.user.scopes,
|
|
169
|
-
flags: data.user.flags,
|
|
170
|
-
kid: data.user.kid,
|
|
171
|
-
iat: 0,
|
|
172
|
-
exp: 0
|
|
173
|
-
};
|
|
407
|
+
const keys = await fetchJwks(publicFetcher(authUrl));
|
|
408
|
+
keyCaches.set(authUrl, { keys, fetchedAt: Date.now() });
|
|
409
|
+
return keys;
|
|
174
410
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
method: "POST",
|
|
178
|
-
headers: { "Content-Type": "application/json" },
|
|
179
|
-
body: JSON.stringify({ code, redirectOrigin })
|
|
180
|
-
});
|
|
181
|
-
if (!res.ok)
|
|
182
|
-
return null;
|
|
183
|
-
const data = await res.json();
|
|
184
|
-
return typeof data.token === "string" ? data.token : null;
|
|
411
|
+
function clearPublicKeyCache() {
|
|
412
|
+
keyCaches.clear();
|
|
185
413
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const data = await res.json();
|
|
195
|
-
return typeof data.token === "string" ? data.token : null;
|
|
414
|
+
function isFetcher(value) {
|
|
415
|
+
return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
|
|
416
|
+
}
|
|
417
|
+
function getBinding(env, name) {
|
|
418
|
+
const binding = typeof env === "object" && env !== null ? Reflect.get(env, name) : undefined;
|
|
419
|
+
if (!isFetcher(binding))
|
|
420
|
+
throw new Error(`Service binding "${name}" not found in env`);
|
|
421
|
+
return bindingFetcher(binding);
|
|
196
422
|
}
|
|
197
423
|
function auth(options) {
|
|
198
424
|
const authUrl = options?.authUrl ?? DEFAULT_AUTH_URL;
|
|
@@ -200,55 +426,51 @@ function auth(options) {
|
|
|
200
426
|
const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
|
|
201
427
|
const publicPaths = options?.public ?? [];
|
|
202
428
|
const bindingName = options?.binding;
|
|
429
|
+
const revalidateSeconds = options?.revalidateSeconds ?? DEFAULT_REVALIDATE_SECONDS;
|
|
203
430
|
const cookieConf = { name: cookieName, maxAgeSeconds: maxAge, secure: true };
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const binding = env[bindingName];
|
|
207
|
-
if (!binding)
|
|
208
|
-
throw new Error(`Service binding "${bindingName}" not found in env`);
|
|
209
|
-
return validateWithBinding(token, binding);
|
|
210
|
-
}
|
|
211
|
-
return validateWithPublicKeys(token, authUrl, maxAge);
|
|
431
|
+
function authService(env) {
|
|
432
|
+
return bindingName ? getBinding(env, bindingName) : publicFetcher(authUrl);
|
|
212
433
|
}
|
|
213
|
-
async function
|
|
434
|
+
async function validate(token, env, audiences) {
|
|
214
435
|
if (bindingName) {
|
|
215
|
-
const
|
|
216
|
-
if (!
|
|
217
|
-
|
|
218
|
-
return
|
|
436
|
+
const audience = peekTokenPayload(token)?.aud;
|
|
437
|
+
if (audience === undefined || !audiences.includes(audience))
|
|
438
|
+
return null;
|
|
439
|
+
return verifyTokenRemote(getBinding(env, bindingName), token, audience);
|
|
219
440
|
}
|
|
220
|
-
|
|
441
|
+
const publicKeys = await getPublicKeys(authUrl, peekTokenKid(token));
|
|
442
|
+
const local = await validateToken({ publicKeys, maxAgeSeconds: maxAge }, token);
|
|
443
|
+
if (!local || local.aud === undefined || !audiences.includes(local.aud))
|
|
444
|
+
return null;
|
|
445
|
+
if (revalidateSeconds <= 0)
|
|
446
|
+
return local;
|
|
447
|
+
return revalidateToken(publicFetcher(authUrl), token, local, local.aud, revalidateSeconds);
|
|
221
448
|
}
|
|
222
449
|
return async (c, next) => {
|
|
223
450
|
const url = new URL(c.req.url);
|
|
451
|
+
const scopeOrigin = options?.siteOrigin ?? url.origin;
|
|
452
|
+
const site = { audiences: [url.origin, scopeOrigin], scopeOrigin };
|
|
224
453
|
if (isPublicPath(url.pathname, publicPaths)) {
|
|
225
454
|
return next();
|
|
226
455
|
}
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
url.searchParams.delete("code");
|
|
238
|
-
return new Response(null, {
|
|
239
|
-
status: 302,
|
|
240
|
-
headers: {
|
|
241
|
-
Location: url.toString(),
|
|
242
|
-
"Set-Cookie": makeSessionCookie(cookieConf, token)
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
}
|
|
456
|
+
const callback = await handleLoginCallback(c.req.raw, {
|
|
457
|
+
auth: authService(c.env),
|
|
458
|
+
authUrl,
|
|
459
|
+
cookieConfig: cookieConf,
|
|
460
|
+
validate: (token) => validate(token, c.env, site.audiences),
|
|
461
|
+
scopeOrigin: site.scopeOrigin
|
|
462
|
+
});
|
|
463
|
+
if (callback)
|
|
464
|
+
return callback;
|
|
246
465
|
const cookieHeader = c.req.header("Cookie");
|
|
247
466
|
if (cookieHeader) {
|
|
248
467
|
const token = extractCookie(cookieHeader, cookieName);
|
|
249
468
|
if (token) {
|
|
250
|
-
const payload = await validate(token, c.env);
|
|
469
|
+
const payload = await validate(token, c.env, site.audiences);
|
|
251
470
|
if (payload) {
|
|
471
|
+
if (!hasSiteAccessFor(payload, site)) {
|
|
472
|
+
return c.text("You don't have access to this site", 403);
|
|
473
|
+
}
|
|
252
474
|
c.set("user", payload);
|
|
253
475
|
return next();
|
|
254
476
|
}
|
|
@@ -257,23 +479,25 @@ function auth(options) {
|
|
|
257
479
|
const authHeader = c.req.header("Authorization");
|
|
258
480
|
if (authHeader?.startsWith("Bearer ")) {
|
|
259
481
|
const token = authHeader.substring(7);
|
|
260
|
-
const payload = await validate(token, c.env);
|
|
261
|
-
if (payload)
|
|
262
|
-
c.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
482
|
+
const payload = await validate(token, c.env, site.audiences);
|
|
483
|
+
if (!payload)
|
|
484
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
485
|
+
if (!hasSiteAccessFor(payload, site))
|
|
486
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
487
|
+
c.set("user", payload);
|
|
488
|
+
return next();
|
|
266
489
|
}
|
|
267
|
-
|
|
268
|
-
loginUrl.searchParams.set("redirect", c.req.url);
|
|
269
|
-
loginUrl.searchParams.set("response_type", "code");
|
|
270
|
-
return c.redirect(loginUrl.toString(), 302);
|
|
490
|
+
return redirectToLogin(c.req.url, authUrl);
|
|
271
491
|
};
|
|
272
492
|
}
|
|
273
493
|
function getUser(c) {
|
|
274
|
-
|
|
494
|
+
const user = c.get("user");
|
|
495
|
+
if (!user)
|
|
496
|
+
throw new Error("getUser() called on a route that is not behind the auth() middleware");
|
|
497
|
+
return user;
|
|
275
498
|
}
|
|
276
499
|
export {
|
|
277
|
-
|
|
278
|
-
|
|
500
|
+
auth,
|
|
501
|
+
clearPublicKeyCache,
|
|
502
|
+
getUser
|
|
279
503
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
export
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export type { CreateTokenInput } from "./token.ts";
|
|
5
|
-
export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
|
|
6
|
-
export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
|
|
1
|
+
export { extractCookie, makeClearCookie, makeSessionCookie } from "./cookie.ts";
|
|
2
|
+
export { checkBearerToken, getAuthUser, getClientIP, redirectToLogin } from "./middleware.ts";
|
|
3
|
+
export { hashPassword, MAX_PASSWORD_LENGTH, needsRehash, verifyPassword } from "./password.ts";
|
|
7
4
|
export type { RateLimitResult } from "./rate-limit.ts";
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
10
|
-
export {
|
|
5
|
+
export { checkRateLimit, clearRateLimit, consumeRateLimit, recordFailedAttempt } from "./rate-limit.ts";
|
|
6
|
+
export { hasSiteAccess } from "./remote.ts";
|
|
7
|
+
export type { CreateTokenInput } from "./token.ts";
|
|
8
|
+
export { createToken, generateKeyPair, importPrivateKey, importPublicKey, isTokenPayload, validateToken, } from "./token.ts";
|
|
9
|
+
export { base32Encode, generateBackupCodes, generateTotpSecret, getTotpUri, hashBackupCode, verifyTotpCode, verifyTotpStep, } from "./totp.ts";
|
|
10
|
+
export type { CookieConfig, PublicKeyEntry, RateLimitConfig, RateLimitEntry, RateLimitStore, SigningConfig, TokenPayload, User, UserStore, VerifyConfig, } from "./types.ts";
|
|
11
|
+
export { changePassword, createUser, isValidEmail, verifyUserCredentials } from "./users.ts";
|