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