@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/flow.js ADDED
@@ -0,0 +1,393 @@
1
+ // @bun
2
+ // src/cookie.ts
3
+ function makeSessionCookie(config, token) {
4
+ const parts = [`${config.name}=${token}`, `Max-Age=${config.maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
5
+ if (config.secure)
6
+ parts.push("Secure");
7
+ return parts.join("; ");
8
+ }
9
+ function makeClearCookie(config) {
10
+ const parts = [`${config.name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
11
+ if (config.secure)
12
+ parts.push("Secure");
13
+ return parts.join("; ");
14
+ }
15
+ function extractCookie(cookieHeader, name) {
16
+ const prefix = `${name}=`;
17
+ for (const cookie of cookieHeader.split(";")) {
18
+ const trimmed = cookie.trim();
19
+ if (trimmed.startsWith(prefix)) {
20
+ return trimmed.substring(prefix.length);
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ // src/token.ts
27
+ var encoder = new TextEncoder;
28
+ function base64urlEncode(data) {
29
+ let binary = "";
30
+ for (const byte of data) {
31
+ binary += String.fromCharCode(byte);
32
+ }
33
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
+ }
35
+ function base64urlDecode(str) {
36
+ if (!/^[A-Za-z0-9_-]*$/.test(str))
37
+ throw new Error("Invalid base64url");
38
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
39
+ const binary = atob(padded);
40
+ const bytes = new Uint8Array(binary.length);
41
+ for (let i = 0;i < binary.length; i++) {
42
+ bytes[i] = binary.charCodeAt(i);
43
+ }
44
+ return bytes;
45
+ }
46
+ var ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-256" };
47
+ var SIGN_PARAMS = { name: "ECDSA", hash: "SHA-256" };
48
+ async function generateKeyPair() {
49
+ const kid = crypto.randomUUID().slice(0, 8);
50
+ const keyPair = await crypto.subtle.generateKey(ECDSA_PARAMS, true, ["sign", "verify"]);
51
+ if (!("privateKey" in keyPair))
52
+ throw new Error("Expected CryptoKeyPair from ECDSA generateKey");
53
+ const privateExport = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
54
+ const publicExport = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
55
+ if (privateExport instanceof ArrayBuffer || publicExport instanceof ArrayBuffer)
56
+ throw new Error("Expected JWK from exportKey");
57
+ return { kid, privateKeyJwk: privateExport, publicKeyJwk: publicExport };
58
+ }
59
+ async function importPrivateKey(jwk) {
60
+ return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["sign"]);
61
+ }
62
+ async function importPublicKey(jwk) {
63
+ return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
64
+ }
65
+ async function createToken(config, user, options = {}) {
66
+ const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
67
+ const payload = {
68
+ sub: user.id,
69
+ email: user.email,
70
+ name: user.name,
71
+ admin: user.isAdmin,
72
+ scopes: user.scopes,
73
+ flags: user.flags,
74
+ kid: config.kid,
75
+ iat: now,
76
+ exp: now + config.maxAgeSeconds,
77
+ ...user.audience === undefined ? {} : { aud: user.audience }
78
+ };
79
+ const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
80
+ const payloadBytes = encoder.encode(payloadB64);
81
+ const signature = await crypto.subtle.sign(SIGN_PARAMS, config.privateKey, payloadBytes);
82
+ const sigB64 = base64urlEncode(new Uint8Array(signature));
83
+ return `${payloadB64}.${sigB64}`;
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
+ }
102
+ async function validateToken(config, token) {
103
+ const parts = token.split(".");
104
+ if (parts.length !== 2)
105
+ return null;
106
+ const [payloadB64 = "", sigB64 = ""] = parts;
107
+ if (!payloadB64 || !sigB64)
108
+ return null;
109
+ let decoded;
110
+ try {
111
+ decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
112
+ } catch {
113
+ return null;
114
+ }
115
+ if (!isTokenPayload(decoded))
116
+ return null;
117
+ const payload = decoded;
118
+ const now = Math.floor(Date.now() / 1000);
119
+ if (now > payload.exp)
120
+ return null;
121
+ if (payload.iat > now + CLOCK_SKEW_SECONDS)
122
+ return null;
123
+ if (now - payload.iat > config.maxAgeSeconds)
124
+ return null;
125
+ const entry = config.publicKeys.find((k) => k.kid === payload.kid);
126
+ if (!entry)
127
+ return null;
128
+ try {
129
+ const signatureBytes = base64urlDecode(sigB64);
130
+ const payloadBytes = encoder.encode(payloadB64);
131
+ const valid = await crypto.subtle.verify(SIGN_PARAMS, entry.key, signatureBytes, payloadBytes);
132
+ if (!valid)
133
+ return null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ return payload;
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
+ }
163
+
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
281
+ var DEFAULT_AUTH_URL = "https://auth.uln.industries";
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
+ export {
382
+ CHALLENGE_PARAM,
383
+ DEFAULT_AUTH_URL,
384
+ DEFAULT_REVALIDATE_SECONDS,
385
+ INSECURE_STATE_COOKIE,
386
+ STATE_COOKIE,
387
+ STATE_PARAM,
388
+ clearRevalidationCache,
389
+ handleLoginCallback,
390
+ pkceChallenge,
391
+ redirectToLogin,
392
+ revalidateToken
393
+ };
package/dist/hono.d.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import type { TokenPayload } from "./types.ts";
3
+ declare module "hono" {
4
+ interface ContextVariableMap {
5
+ user: TokenPayload;
6
+ }
7
+ }
3
8
  interface AuthOptions {
4
9
  /**
5
10
  * Service binding name (e.g. "AUTH"). If provided, validates tokens
6
11
  * via the auth worker service binding instead of fetching public keys.
12
+ * Binding mode also rejects tokens for deleted users and revoked sessions.
7
13
  */
8
14
  binding?: string;
9
15
  /**
@@ -15,18 +21,38 @@ interface AuthOptions {
15
21
  * Auth service base URL. Defaults to "https://auth.uln.industries".
16
22
  */
17
23
  authUrl?: string;
18
- /** Cookie name. Defaults to "uln_auth". */
24
+ /**
25
+ * Cookie name. Defaults to "__Host-uln_auth": the `__Host-` prefix means only this exact host can set it, so a page on
26
+ * a sibling subdomain can't plant a session cookie (session fixation / cookie tossing).
27
+ */
19
28
  cookie?: string;
20
29
  /** Cookie/token max age in seconds. Defaults to 86400 (24h). */
21
30
  maxAge?: number;
31
+ /**
32
+ * The registered origin of this site, used for the scope check. Defaults to the request URL's origin.
33
+ * Set this when the Worker is reached through a different hostname than the one registered in auth.
34
+ */
35
+ siteOrigin?: string;
36
+ /**
37
+ * Public-key mode only: confirm a locally valid token with the auth service at most this often (seconds, per
38
+ * token), so password resets, deleted users and removed scopes take effect within this window instead of when the
39
+ * token expires. If the auth service is unreachable the local result stands. Defaults to 300; 0 disables.
40
+ */
41
+ revalidateSeconds?: number;
22
42
  }
43
+ /** Test hook: forget cached JWKS. */
44
+ export declare function clearPublicKeyCache(): void;
23
45
  /**
24
46
  * Hono middleware that handles the full auth flow:
25
47
  *
26
- * 1. If `?code=` in URL → exchange for token, set cookie, redirect to clean URL
27
- * 2. If session cookie present → validate, set `c.get("user")`
48
+ * 1. If `?code=` in URL → check it belongs to this browser's login, exchange for token, set cookie, redirect to
49
+ * clean URL
50
+ * 2. If session cookie or `Authorization: Bearer` present → validate, check site access, set `c.get("user")`
28
51
  * 3. If unauthenticated → redirect to auth.uln.industries/login
29
52
  *
53
+ * Tokens issued to another origin are rejected. Users without access to this site (not admin, origin not in
54
+ * `scopes`) get a 403.
55
+ *
30
56
  * Usage:
31
57
  * ```ts
32
58
  * import { auth } from "@uln/auth/hono";
@@ -45,6 +71,6 @@ interface AuthOptions {
45
71
  * ```
46
72
  */
47
73
  export declare function auth(options?: AuthOptions): MiddlewareHandler;
48
- /** Type-safe helper to get the authenticated user from context. */
74
+ /** Get the authenticated user from context. Throws if called on a route that `auth()` doesn't cover. */
49
75
  export declare function getUser(c: Context): TokenPayload;
50
76
  export type { TokenPayload } from "./types.ts";