@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 CHANGED
@@ -1,7 +1,9 @@
1
- export type { TokenPayload, VerifyConfig, PublicKeyEntry, CookieConfig } from "./types.ts";
2
- export { validateToken, importPublicKey } from "./token.ts";
3
- export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
4
- export type { AuthCodeExchangeConfig } from "./middleware.ts";
5
- export { getAuthUser, redirectToLogin, handleCallback, getClientIP, checkBearerToken } from "./middleware.ts";
6
1
  import type { PublicKeyEntry } from "./types.ts";
2
+ export { extractCookie, makeClearCookie, makeSessionCookie } from "./cookie.ts";
3
+ export type { AuthCodeExchangeConfig } from "./middleware.ts";
4
+ export { checkBearerToken, getAuthUser, getClientIP, handleCallback, redirectToLogin } from "./middleware.ts";
5
+ export { hasSiteAccess } from "./remote.ts";
6
+ export { importPublicKey, validateToken } from "./token.ts";
7
+ export type { CookieConfig, PublicKeyEntry, TokenPayload, VerifyConfig } from "./types.ts";
8
+ /** Fetch and import the auth service's public signing keys. Cache the result; refetch when a token's kid is unknown. */
7
9
  export declare function fetchPublicKeys(baseUrl: string): Promise<PublicKeyEntry[]>;
package/dist/client.js CHANGED
@@ -1,28 +1,4 @@
1
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
2
  // src/token.ts
27
3
  var encoder = new TextEncoder;
28
4
  function base64urlEncode(data) {
@@ -33,6 +9,8 @@ function base64urlEncode(data) {
33
9
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
10
  }
35
11
  function base64urlDecode(str) {
12
+ if (!/^[A-Za-z0-9_-]*$/.test(str))
13
+ throw new Error("Invalid base64url");
36
14
  const padded = str.replace(/-/g, "+").replace(/_/g, "/");
37
15
  const binary = atob(padded);
38
16
  const bytes = new Uint8Array(binary.length);
@@ -60,8 +38,8 @@ async function importPrivateKey(jwk) {
60
38
  async function importPublicKey(jwk) {
61
39
  return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
62
40
  }
63
- async function createToken(config, user) {
64
- const now = Math.floor(Date.now() / 1000);
41
+ async function createToken(config, user, options = {}) {
42
+ const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
65
43
  const payload = {
66
44
  sub: user.id,
67
45
  email: user.email,
@@ -71,7 +49,10 @@ async function createToken(config, user) {
71
49
  flags: user.flags,
72
50
  kid: config.kid,
73
51
  iat: now,
74
- exp: now + config.maxAgeSeconds
52
+ exp: now + config.maxAgeSeconds,
53
+ ...user.audience === undefined ? {} : { aud: user.audience },
54
+ ...user.sessionId === undefined ? {} : { sid: user.sessionId },
55
+ ...user.deviceBound ? { dbsc: true } : {}
75
56
  };
76
57
  const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
77
58
  const payloadBytes = encoder.encode(payloadB64);
@@ -79,25 +60,46 @@ async function createToken(config, user) {
79
60
  const sigB64 = base64urlEncode(new Uint8Array(signature));
80
61
  return `${payloadB64}.${sigB64}`;
81
62
  }
63
+ var CLOCK_SKEW_SECONDS = 60;
64
+ function isStringArray(value) {
65
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
66
+ }
67
+ function isFlags(value) {
68
+ if (typeof value !== "object" || value === null || Array.isArray(value))
69
+ return false;
70
+ return Object.values(value).every(isStringArray);
71
+ }
72
+ function isTokenPayload(value) {
73
+ if (typeof value !== "object" || value === null)
74
+ return false;
75
+ 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)) {
76
+ return false;
77
+ }
78
+ 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");
79
+ }
82
80
  async function validateToken(config, token) {
83
- const dotIndex = token.indexOf(".");
84
- if (dotIndex === -1)
81
+ const parts = token.split(".");
82
+ if (parts.length !== 2)
85
83
  return null;
86
- const payloadB64 = token.substring(0, dotIndex);
87
- const sigB64 = token.substring(dotIndex + 1);
88
- let payload;
84
+ const [payloadB64 = "", sigB64 = ""] = parts;
85
+ if (!payloadB64 || !sigB64)
86
+ return null;
87
+ let decoded;
89
88
  try {
90
- const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
91
- payload = JSON.parse(decoded);
89
+ decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
92
90
  } catch {
93
91
  return null;
94
92
  }
95
- if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
93
+ if (!isTokenPayload(decoded))
96
94
  return null;
97
- }
95
+ const payload = decoded;
98
96
  const now = Math.floor(Date.now() / 1000);
99
97
  if (now > payload.exp)
100
98
  return null;
99
+ if (payload.iat > now + CLOCK_SKEW_SECONDS)
100
+ return null;
101
+ if (now - payload.iat > config.maxAgeSeconds)
102
+ return null;
101
103
  const entry = config.publicKeys.find((k) => k.kid === payload.kid);
102
104
  if (!entry)
103
105
  return null;
@@ -112,85 +114,337 @@ async function validateToken(config, token) {
112
114
  }
113
115
  return payload;
114
116
  }
117
+ function peekTokenPayload(token) {
118
+ const payloadB64 = token.split(".")[0];
119
+ if (!payloadB64)
120
+ return null;
121
+ try {
122
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
123
+ return isTokenPayload(decoded) ? decoded : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ function peekTokenKid(token) {
129
+ const payloadB64 = token.split(".")[0];
130
+ if (!payloadB64)
131
+ return null;
132
+ try {
133
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
134
+ if (typeof decoded !== "object" || decoded === null || !("kid" in decoded))
135
+ return null;
136
+ return typeof decoded.kid === "string" ? decoded.kid : null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ // src/remote.ts
143
+ function publicFetcher(authUrl) {
144
+ return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
145
+ }
146
+ function bindingFetcher(binding) {
147
+ return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
148
+ }
149
+ function isLoopbackHostname(hostname) {
150
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
151
+ }
152
+ function toOrigin(value) {
153
+ try {
154
+ return new URL(value).origin;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+ function hasSiteAccess(payload, origin) {
160
+ return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
161
+ }
162
+ function hasSiteAccessFor(payload, site) {
163
+ const scopeOrigin = toOrigin(site.scopeOrigin);
164
+ if (!scopeOrigin || payload.aud === undefined)
165
+ return false;
166
+ if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud))
167
+ return false;
168
+ if (payload.admin)
169
+ return true;
170
+ if (isLoopbackHostname(new URL(scopeOrigin).hostname))
171
+ return true;
172
+ return payload.scopes.includes(scopeOrigin);
173
+ }
174
+ async function readJsonObject(res) {
175
+ try {
176
+ const body = await res.json();
177
+ return typeof body === "object" && body !== null ? body : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+ async function exchangeAuthCode(auth, code, redirectOrigin, codeVerifier) {
183
+ const res = await auth.fetch("/api/exchange-code", {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify(codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier })
187
+ });
188
+ if (!res.ok)
189
+ return null;
190
+ const body = await readJsonObject(res);
191
+ if (!body || !("token" in body) || typeof body.token !== "string")
192
+ return null;
193
+ return body.token;
194
+ }
195
+ async function checkTokenRemote(auth, token, audience) {
196
+ let res;
197
+ try {
198
+ res = await auth.fetch("/api/verify-token", {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify(audience === undefined ? { token } : { token, audience })
202
+ });
203
+ } catch {
204
+ return { status: "unavailable" };
205
+ }
206
+ if (res.status >= 500)
207
+ return { status: "unavailable" };
208
+ if (!res.ok)
209
+ return { status: "invalid" };
210
+ const body = await readJsonObject(res);
211
+ if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null)
212
+ return { status: "invalid" };
213
+ const user = body.user;
214
+ if (!("id" in user))
215
+ return { status: "invalid" };
216
+ const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
217
+ if (!isTokenPayload(candidate))
218
+ return { status: "invalid" };
219
+ return {
220
+ status: "valid",
221
+ payload: {
222
+ sub: candidate.sub,
223
+ email: candidate.email,
224
+ name: candidate.name,
225
+ admin: candidate.admin,
226
+ scopes: candidate.scopes,
227
+ flags: candidate.flags,
228
+ kid: candidate.kid,
229
+ iat: candidate.iat,
230
+ exp: candidate.exp,
231
+ ...candidate.aud === undefined ? {} : { aud: candidate.aud }
232
+ }
233
+ };
234
+ }
235
+ async function verifyTokenRemote(auth, token, audience) {
236
+ const result = await checkTokenRemote(auth, token, audience);
237
+ return result.status === "valid" ? result.payload : null;
238
+ }
239
+ async function fetchJwks(auth) {
240
+ const res = await auth.fetch("/.well-known/jwk.json");
241
+ if (!res.ok)
242
+ throw new Error(`Failed to fetch JWKS: ${res.status}`);
243
+ const body = await readJsonObject(res);
244
+ if (!body || !("keys" in body) || !Array.isArray(body.keys))
245
+ throw new Error("Malformed JWKS response");
246
+ const entries = [];
247
+ for (const jwk of body.keys) {
248
+ if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string")
249
+ continue;
250
+ if (!("kty" in jwk) || typeof jwk.kty !== "string")
251
+ continue;
252
+ const { kid, ...keyData } = jwk;
253
+ entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
254
+ }
255
+ return entries;
256
+ }
257
+
258
+ // src/cookie.ts
259
+ function makeSessionCookie(config, token) {
260
+ const parts = [`${config.name}=${token}`, `Max-Age=${config.maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
261
+ if (config.secure)
262
+ parts.push("Secure");
263
+ return parts.join("; ");
264
+ }
265
+ function makeClearCookie(config) {
266
+ const parts = [`${config.name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
267
+ if (config.secure)
268
+ parts.push("Secure");
269
+ return parts.join("; ");
270
+ }
271
+ function extractCookie(cookieHeader, name) {
272
+ const prefix = `${name}=`;
273
+ for (const cookie of cookieHeader.split(";")) {
274
+ const trimmed = cookie.trim();
275
+ if (trimmed.startsWith(prefix)) {
276
+ return trimmed.substring(prefix.length);
277
+ }
278
+ }
279
+ return null;
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) {
338
+ const url = new URL(request.url);
339
+ const code = url.searchParams.get("code");
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 });
358
+ }
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();
387
+ }
115
388
 
116
389
  // src/middleware.ts
117
390
  function getClientIP(request) {
118
391
  return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
119
392
  }
120
393
  function checkBearerToken(request, expectedToken) {
121
- const header = request.headers.get("Authorization");
122
- return header === `Bearer ${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;
123
403
  }
124
- async function getAuthUser(request, verifyConfig, cookieConfig) {
404
+ async function getAuthUser(request, verifyConfig, cookieConfig, siteOrigin) {
125
405
  const cookieHeader = request.headers.get("Cookie");
126
406
  if (!cookieHeader)
127
407
  return null;
128
408
  const token = extractCookie(cookieHeader, cookieConfig.name);
129
409
  if (!token)
130
410
  return null;
131
- return validateToken(verifyConfig, token);
132
- }
133
- function redirectToLogin(currentUrl) {
134
- const loginUrl = new URL("/login", "https://auth.uln.industries");
135
- loginUrl.searchParams.set("redirect", currentUrl);
136
- loginUrl.searchParams.set("response_type", "code");
137
- return Response.redirect(loginUrl.toString(), 302);
138
- }
139
- async function exchangeCode(code, redirectOrigin, config) {
140
- const res = await fetch(`${config.authUrl}/api/exchange-code`, {
141
- method: "POST",
142
- headers: { "Content-Type": "application/json" },
143
- body: JSON.stringify({ code, redirectOrigin })
144
- });
145
- if (!res.ok)
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))
146
416
  return null;
147
- const data = await res.json();
148
- return typeof data.token === "string" ? data.token : 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;
149
422
  }
150
423
  async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
151
- const url = new URL(request.url);
152
- const code = url.searchParams.get("code");
153
- if (code) {
154
- if (!exchangeConfig)
155
- return redirectToLogin(url.origin);
156
- const token = await exchangeCode(code, url.origin, exchangeConfig);
157
- if (!token)
158
- return redirectToLogin(url.origin);
159
- const payload = await validateToken(verifyConfig, token);
160
- if (!payload)
161
- return redirectToLogin(url.origin);
162
- url.searchParams.delete("code");
163
- return new Response(null, {
164
- status: 302,
165
- headers: {
166
- Location: url.toString(),
167
- "Set-Cookie": makeSessionCookie(cookieConfig, token)
168
- }
169
- });
170
- }
171
- return null;
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
+ });
172
431
  }
432
+
173
433
  // src/client.ts
174
434
  async function fetchPublicKeys(baseUrl) {
175
- const res = await fetch(`${baseUrl}/.well-known/jwk.json`);
176
- if (!res.ok)
177
- throw new Error(`Failed to fetch JWKS from ${baseUrl}: ${res.status}`);
178
- const jwks = await res.json();
179
- return Promise.all(jwks.keys.map(async (jwk) => ({
180
- kid: jwk.kid,
181
- key: await importPublicKey(jwk)
182
- })));
435
+ return fetchJwks(publicFetcher(baseUrl));
183
436
  }
184
437
  export {
185
- validateToken,
186
- redirectToLogin,
187
- makeSessionCookie,
188
- makeClearCookie,
189
- importPublicKey,
190
- handleCallback,
191
- getClientIP,
192
- getAuthUser,
193
- fetchPublicKeys,
438
+ checkBearerToken,
194
439
  extractCookie,
195
- checkBearerToken
440
+ fetchPublicKeys,
441
+ getAuthUser,
442
+ getClientIP,
443
+ handleCallback,
444
+ hasSiteAccess,
445
+ importPublicKey,
446
+ makeClearCookie,
447
+ makeSessionCookie,
448
+ redirectToLogin,
449
+ validateToken
196
450
  };
package/dist/cookie.js CHANGED
@@ -23,7 +23,7 @@ function extractCookie(cookieHeader, name) {
23
23
  return null;
24
24
  }
25
25
  export {
26
- makeSessionCookie,
26
+ extractCookie,
27
27
  makeClearCookie,
28
- extractCookie
28
+ makeSessionCookie
29
29
  };
package/dist/flow.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { type FetchLike } from "./remote.ts";
2
+ import type { CookieConfig, TokenPayload } from "./types.ts";
3
+ export declare const DEFAULT_AUTH_URL = "https://auth.uln.industries";
4
+ /**
5
+ * Short-lived cookie tying a `?code=` callback to the browser that started the login. Its value is
6
+ * `<state>.<pkce verifier>`. The `__Host-` prefix makes browsers refuse it unless it was set by this exact host
7
+ * (Secure, `Path=/`, no `Domain`), so a page on a sibling subdomain can't plant a known state for the whole domain.
8
+ */
9
+ export declare const STATE_COOKIE = "__Host-uln_auth_state";
10
+ /** The state cookie's name on plain-http origins (local development), where browsers refuse `__Host-` cookies. */
11
+ export declare const INSECURE_STATE_COOKIE = "uln_auth_state";
12
+ /** Query parameter that carries the state through the auth service and back on the return URL. */
13
+ export declare const STATE_PARAM = "uln_state";
14
+ /**
15
+ * PKCE (RFC 7636, S256) challenge on the return URL. The auth service stores it with the one-time code and only
16
+ * exchanges the code for a client that presents the verifier, which never leaves this browser's state cookie, so a
17
+ * code that leaks (history, logs, a shared link) is useless on its own.
18
+ */
19
+ export declare const CHALLENGE_PARAM = "uln_challenge";
20
+ /**
21
+ * This library's version on the return URL (0.6.1+), so the auth service can track which version each site runs.
22
+ * The auth service strips it from the URL it sends the browser back to.
23
+ */
24
+ export declare const VERSION_PARAM = "uln_v";
25
+ /** S256 code challenge for a PKCE verifier: base64url(SHA-256(verifier)). */
26
+ export declare function pkceChallenge(verifier: string): Promise<string>;
27
+ /**
28
+ * Redirect to the auth service's login page. Sets a one-time state cookie and puts the state on the return URL, which
29
+ * the auth service hands back untouched alongside `?code=`, together with a PKCE challenge for the verifier kept in
30
+ * the cookie. Requests a one-time code callback, so signed tokens never travel in URLs.
31
+ */
32
+ export declare function redirectToLogin(currentUrl: string, authUrl?: string): Promise<Response>;
33
+ export interface LoginCallbackOptions {
34
+ auth: FetchLike;
35
+ authUrl: string;
36
+ cookieConfig: CookieConfig;
37
+ /** Validate the exchanged token (locally or with the auth service). Null rejects it. */
38
+ validate(token: string): Promise<TokenPayload | null>;
39
+ /** Origin registered for scopes, when it differs from the request URL's origin. */
40
+ scopeOrigin?: string;
41
+ }
42
+ /**
43
+ * Handle a `?code=` login callback; null when the request isn't one. The code is only exchanged when the request's
44
+ * `uln_state` matches this browser's state cookie, so a code planted in a link by someone else (login CSRF, which
45
+ * would sign the victim in to the attacker's account) is refused, and the exchange proves this browser holds the PKCE
46
+ * verifier. Any failure sends the user back to log in.
47
+ */
48
+ export declare function handleLoginCallback(request: Request, options: LoginCallbackOptions): Promise<Response | null>;
49
+ export declare const DEFAULT_REVALIDATE_SECONDS = 300;
50
+ /**
51
+ * Confirm a locally valid token with the auth service, at most once per `intervalSeconds` per token. Returns the
52
+ * payload to use: the auth service's (current admin/scopes/flags) after a fresh check, the local one while a previous
53
+ * confirmation is fresh or when the auth service can't be reached, and null when the auth service rejects the token
54
+ * (deleted user, revoked session, wrong audience).
55
+ */
56
+ export declare function revalidateToken(auth: FetchLike, token: string, local: TokenPayload, audience: string, intervalSeconds?: number): Promise<TokenPayload | null>;
57
+ /** Test hook: forget which tokens were recently confirmed. */
58
+ export declare function clearRevalidationCache(): void;