@uln/auth 0.4.1 → 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 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,8 @@ 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 }
75
54
  };
76
55
  const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
77
56
  const payloadBytes = encoder.encode(payloadB64);
@@ -79,25 +58,46 @@ async function createToken(config, user) {
79
58
  const sigB64 = base64urlEncode(new Uint8Array(signature));
80
59
  return `${payloadB64}.${sigB64}`;
81
60
  }
61
+ var CLOCK_SKEW_SECONDS = 60;
62
+ function isStringArray(value) {
63
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
64
+ }
65
+ function isFlags(value) {
66
+ if (typeof value !== "object" || value === null || Array.isArray(value))
67
+ return false;
68
+ return Object.values(value).every(isStringArray);
69
+ }
70
+ function isTokenPayload(value) {
71
+ if (typeof value !== "object" || value === null)
72
+ return false;
73
+ 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)) {
74
+ return false;
75
+ }
76
+ 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");
77
+ }
82
78
  async function validateToken(config, token) {
83
- const dotIndex = token.indexOf(".");
84
- if (dotIndex === -1)
79
+ const parts = token.split(".");
80
+ if (parts.length !== 2)
81
+ return null;
82
+ const [payloadB64 = "", sigB64 = ""] = parts;
83
+ if (!payloadB64 || !sigB64)
85
84
  return null;
86
- const payloadB64 = token.substring(0, dotIndex);
87
- const sigB64 = token.substring(dotIndex + 1);
88
- let payload;
85
+ let decoded;
89
86
  try {
90
- const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
91
- payload = JSON.parse(decoded);
87
+ decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
92
88
  } catch {
93
89
  return null;
94
90
  }
95
- if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
91
+ if (!isTokenPayload(decoded))
96
92
  return null;
97
- }
93
+ const payload = decoded;
98
94
  const now = Math.floor(Date.now() / 1000);
99
95
  if (now > payload.exp)
100
96
  return null;
97
+ if (payload.iat > now + CLOCK_SKEW_SECONDS)
98
+ return null;
99
+ if (now - payload.iat > config.maxAgeSeconds)
100
+ return null;
101
101
  const entry = config.publicKeys.find((k) => k.kid === payload.kid);
102
102
  if (!entry)
103
103
  return null;
@@ -112,85 +112,331 @@ async function validateToken(config, token) {
112
112
  }
113
113
  return payload;
114
114
  }
115
+ function peekTokenPayload(token) {
116
+ const payloadB64 = token.split(".")[0];
117
+ if (!payloadB64)
118
+ return null;
119
+ try {
120
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
121
+ return isTokenPayload(decoded) ? decoded : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+ function peekTokenKid(token) {
127
+ const payloadB64 = token.split(".")[0];
128
+ if (!payloadB64)
129
+ return null;
130
+ try {
131
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
132
+ if (typeof decoded !== "object" || decoded === null || !("kid" in decoded))
133
+ return null;
134
+ return typeof decoded.kid === "string" ? decoded.kid : null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ // src/remote.ts
141
+ function publicFetcher(authUrl) {
142
+ return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
143
+ }
144
+ function bindingFetcher(binding) {
145
+ return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
146
+ }
147
+ function isLoopbackHostname(hostname) {
148
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
149
+ }
150
+ function toOrigin(value) {
151
+ try {
152
+ return new URL(value).origin;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ function hasSiteAccess(payload, origin) {
158
+ return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
159
+ }
160
+ function hasSiteAccessFor(payload, site) {
161
+ const scopeOrigin = toOrigin(site.scopeOrigin);
162
+ if (!scopeOrigin || payload.aud === undefined)
163
+ return false;
164
+ if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud))
165
+ return false;
166
+ if (payload.admin)
167
+ return true;
168
+ if (isLoopbackHostname(new URL(scopeOrigin).hostname))
169
+ return true;
170
+ return payload.scopes.includes(scopeOrigin);
171
+ }
172
+ async function readJsonObject(res) {
173
+ try {
174
+ const body = await res.json();
175
+ return typeof body === "object" && body !== null ? body : null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ async function exchangeAuthCode(auth, code, redirectOrigin, codeVerifier) {
181
+ const res = await auth.fetch("/api/exchange-code", {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify(codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier })
185
+ });
186
+ if (!res.ok)
187
+ return null;
188
+ const body = await readJsonObject(res);
189
+ if (!body || !("token" in body) || typeof body.token !== "string")
190
+ return null;
191
+ return body.token;
192
+ }
193
+ async function checkTokenRemote(auth, token, audience) {
194
+ let res;
195
+ try {
196
+ res = await auth.fetch("/api/verify-token", {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify(audience === undefined ? { token } : { token, audience })
200
+ });
201
+ } catch {
202
+ return { status: "unavailable" };
203
+ }
204
+ if (res.status >= 500)
205
+ return { status: "unavailable" };
206
+ if (!res.ok)
207
+ return { status: "invalid" };
208
+ const body = await readJsonObject(res);
209
+ if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null)
210
+ return { status: "invalid" };
211
+ const user = body.user;
212
+ if (!("id" in user))
213
+ return { status: "invalid" };
214
+ const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
215
+ if (!isTokenPayload(candidate))
216
+ return { status: "invalid" };
217
+ return {
218
+ status: "valid",
219
+ payload: {
220
+ sub: candidate.sub,
221
+ email: candidate.email,
222
+ name: candidate.name,
223
+ admin: candidate.admin,
224
+ scopes: candidate.scopes,
225
+ flags: candidate.flags,
226
+ kid: candidate.kid,
227
+ iat: candidate.iat,
228
+ exp: candidate.exp,
229
+ ...candidate.aud === undefined ? {} : { aud: candidate.aud }
230
+ }
231
+ };
232
+ }
233
+ async function verifyTokenRemote(auth, token, audience) {
234
+ const result = await checkTokenRemote(auth, token, audience);
235
+ return result.status === "valid" ? result.payload : null;
236
+ }
237
+ async function fetchJwks(auth) {
238
+ const res = await auth.fetch("/.well-known/jwk.json");
239
+ if (!res.ok)
240
+ throw new Error(`Failed to fetch JWKS: ${res.status}`);
241
+ const body = await readJsonObject(res);
242
+ if (!body || !("keys" in body) || !Array.isArray(body.keys))
243
+ throw new Error("Malformed JWKS response");
244
+ const entries = [];
245
+ for (const jwk of body.keys) {
246
+ if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string")
247
+ continue;
248
+ if (!("kty" in jwk) || typeof jwk.kty !== "string")
249
+ continue;
250
+ const { kid, ...keyData } = jwk;
251
+ entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
252
+ }
253
+ return entries;
254
+ }
255
+
256
+ // src/cookie.ts
257
+ function makeSessionCookie(config, token) {
258
+ const parts = [`${config.name}=${token}`, `Max-Age=${config.maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
259
+ if (config.secure)
260
+ parts.push("Secure");
261
+ return parts.join("; ");
262
+ }
263
+ function makeClearCookie(config) {
264
+ const parts = [`${config.name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
265
+ if (config.secure)
266
+ parts.push("Secure");
267
+ return parts.join("; ");
268
+ }
269
+ function extractCookie(cookieHeader, name) {
270
+ const prefix = `${name}=`;
271
+ for (const cookie of cookieHeader.split(";")) {
272
+ const trimmed = cookie.trim();
273
+ if (trimmed.startsWith(prefix)) {
274
+ return trimmed.substring(prefix.length);
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+ // src/flow.ts
280
+ var DEFAULT_AUTH_URL = "https://auth.uln.industries";
281
+ var STATE_COOKIE = "__Host-uln_auth_state";
282
+ var INSECURE_STATE_COOKIE = "uln_auth_state";
283
+ var STATE_PARAM = "uln_state";
284
+ var CHALLENGE_PARAM = "uln_challenge";
285
+ var STATE_MAX_AGE_SECONDS = 600;
286
+ function base64url(bytes) {
287
+ let binary = "";
288
+ for (const byte of bytes)
289
+ binary += String.fromCharCode(byte);
290
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
291
+ }
292
+ function randomToken() {
293
+ return base64url(crypto.getRandomValues(new Uint8Array(32)));
294
+ }
295
+ async function pkceChallenge(verifier) {
296
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
297
+ }
298
+ function stateCookie(value, secure, maxAgeSeconds) {
299
+ const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
300
+ const parts = [`${name}=${value}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
301
+ if (secure)
302
+ parts.push("Secure");
303
+ return parts.join("; ");
304
+ }
305
+ function readStateCookie(request, secure) {
306
+ const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
307
+ const [state = "", verifier = "", ...rest] = (extractCookie(request.headers.get("Cookie") ?? "", name) ?? "").split(".");
308
+ return state && verifier && rest.length === 0 ? { state, verifier } : null;
309
+ }
310
+ function stripCallbackParams(url) {
311
+ url.searchParams.delete("code");
312
+ url.searchParams.delete(STATE_PARAM);
313
+ url.searchParams.delete(CHALLENGE_PARAM);
314
+ }
315
+ async function redirectToLogin(currentUrl, authUrl = DEFAULT_AUTH_URL) {
316
+ const returnUrl = new URL(currentUrl);
317
+ stripCallbackParams(returnUrl);
318
+ const state = randomToken();
319
+ const verifier = randomToken();
320
+ returnUrl.searchParams.set(STATE_PARAM, state);
321
+ returnUrl.searchParams.set(CHALLENGE_PARAM, await pkceChallenge(verifier));
322
+ const loginUrl = new URL("/login", authUrl);
323
+ loginUrl.searchParams.set("redirect", returnUrl.toString());
324
+ loginUrl.searchParams.set("response_type", "code");
325
+ const headers = new Headers({ Location: loginUrl.toString() });
326
+ headers.append("Set-Cookie", stateCookie(`${state}.${verifier}`, returnUrl.protocol === "https:", STATE_MAX_AGE_SECONDS));
327
+ return new Response(null, { status: 302, headers });
328
+ }
329
+ async function handleLoginCallback(request, options) {
330
+ const url = new URL(request.url);
331
+ const code = url.searchParams.get("code");
332
+ if (!code)
333
+ return null;
334
+ const state = url.searchParams.get(STATE_PARAM);
335
+ const secure = url.protocol === "https:";
336
+ const expected = readStateCookie(request, secure);
337
+ stripCallbackParams(url);
338
+ const startOver = () => redirectToLogin(url.toString(), options.authUrl);
339
+ if (!state || !expected || state !== expected.state)
340
+ return startOver();
341
+ const token = await exchangeAuthCode(options.auth, code, url.origin, expected.verifier);
342
+ if (!token)
343
+ return startOver();
344
+ const payload = await options.validate(token);
345
+ if (!payload)
346
+ return startOver();
347
+ const scopeOrigin = options.scopeOrigin ?? url.origin;
348
+ if (!hasSiteAccessFor(payload, { audiences: [url.origin, scopeOrigin], scopeOrigin })) {
349
+ return new Response("You don't have access to this site", { status: 403 });
350
+ }
351
+ const headers = new Headers({ Location: url.toString() });
352
+ headers.append("Set-Cookie", makeSessionCookie(options.cookieConfig, token));
353
+ headers.append("Set-Cookie", stateCookie("", secure, 0));
354
+ return new Response(null, { status: 302, headers });
355
+ }
356
+ var confirmedAt = new Map;
357
+ var MAX_CONFIRMED = 5000;
358
+ var DEFAULT_REVALIDATE_SECONDS = 300;
359
+ async function revalidateToken(auth, token, local, audience, intervalSeconds = DEFAULT_REVALIDATE_SECONDS) {
360
+ const last = confirmedAt.get(token);
361
+ if (last !== undefined && Date.now() - last < intervalSeconds * 1000)
362
+ return local;
363
+ const result = await checkTokenRemote(auth, token, audience);
364
+ if (result.status === "unavailable")
365
+ return local;
366
+ confirmedAt.delete(token);
367
+ if (result.status === "invalid")
368
+ return null;
369
+ if (confirmedAt.size >= MAX_CONFIRMED) {
370
+ const oldest = confirmedAt.keys().next();
371
+ if (!oldest.done)
372
+ confirmedAt.delete(oldest.value);
373
+ }
374
+ confirmedAt.set(token, Date.now());
375
+ return result.payload;
376
+ }
377
+ function clearRevalidationCache() {
378
+ confirmedAt.clear();
379
+ }
115
380
 
116
381
  // src/middleware.ts
117
382
  function getClientIP(request) {
118
383
  return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
119
384
  }
120
385
  function checkBearerToken(request, expectedToken) {
121
- const header = request.headers.get("Authorization");
122
- return header === `Bearer ${expectedToken}`;
386
+ const header = request.headers.get("Authorization") ?? "";
387
+ const expected = `Bearer ${expectedToken}`;
388
+ const a = new TextEncoder().encode(header);
389
+ const b = new TextEncoder().encode(expected);
390
+ let diff = a.length ^ b.length;
391
+ for (let i = 0;i < b.length; i++) {
392
+ diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
393
+ }
394
+ return diff === 0;
123
395
  }
124
- async function getAuthUser(request, verifyConfig, cookieConfig) {
396
+ async function getAuthUser(request, verifyConfig, cookieConfig, siteOrigin) {
125
397
  const cookieHeader = request.headers.get("Cookie");
126
398
  if (!cookieHeader)
127
399
  return null;
128
400
  const token = extractCookie(cookieHeader, cookieConfig.name);
129
401
  if (!token)
130
402
  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)
403
+ const audience = new URL(request.url).origin;
404
+ const scopeOrigin = siteOrigin ?? audience;
405
+ const site = { audiences: [audience, scopeOrigin], scopeOrigin };
406
+ const payload = await validateToken(verifyConfig, token);
407
+ if (!payload || !hasSiteAccessFor(payload, site))
146
408
  return null;
147
- const data = await res.json();
148
- return typeof data.token === "string" ? data.token : null;
409
+ const { revalidate } = verifyConfig;
410
+ if (!revalidate)
411
+ return payload;
412
+ const current = await revalidateToken(publicFetcher(revalidate.authUrl), token, payload, payload.aud ?? audience, revalidate.intervalSeconds);
413
+ return current && hasSiteAccessFor(current, site) ? current : null;
149
414
  }
150
415
  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;
416
+ const authUrl = exchangeConfig?.authUrl ?? DEFAULT_AUTH_URL;
417
+ return handleLoginCallback(request, {
418
+ auth: publicFetcher(authUrl),
419
+ authUrl,
420
+ cookieConfig,
421
+ validate: (token) => validateToken(verifyConfig, token)
422
+ });
172
423
  }
424
+
173
425
  // src/client.ts
174
426
  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
- })));
427
+ return fetchJwks(publicFetcher(baseUrl));
183
428
  }
184
429
  export {
185
- validateToken,
186
- redirectToLogin,
187
- makeSessionCookie,
188
- makeClearCookie,
189
- importPublicKey,
190
- handleCallback,
191
- getClientIP,
192
- getAuthUser,
193
- fetchPublicKeys,
430
+ checkBearerToken,
194
431
  extractCookie,
195
- checkBearerToken
432
+ fetchPublicKeys,
433
+ getAuthUser,
434
+ getClientIP,
435
+ handleCallback,
436
+ hasSiteAccess,
437
+ importPublicKey,
438
+ makeClearCookie,
439
+ makeSessionCookie,
440
+ redirectToLogin,
441
+ validateToken
196
442
  };
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,53 @@
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
+ /** S256 code challenge for a PKCE verifier: base64url(SHA-256(verifier)). */
21
+ export declare function pkceChallenge(verifier: string): Promise<string>;
22
+ /**
23
+ * Redirect to the auth service's login page. Sets a one-time state cookie and puts the state on the return URL, which
24
+ * the auth service hands back untouched alongside `?code=`, together with a PKCE challenge for the verifier kept in
25
+ * the cookie. Requests a one-time code callback, so signed tokens never travel in URLs.
26
+ */
27
+ export declare function redirectToLogin(currentUrl: string, authUrl?: string): Promise<Response>;
28
+ export interface LoginCallbackOptions {
29
+ auth: FetchLike;
30
+ authUrl: string;
31
+ cookieConfig: CookieConfig;
32
+ /** Validate the exchanged token (locally or with the auth service). Null rejects it. */
33
+ validate(token: string): Promise<TokenPayload | null>;
34
+ /** Origin registered for scopes, when it differs from the request URL's origin. */
35
+ scopeOrigin?: string;
36
+ }
37
+ /**
38
+ * Handle a `?code=` login callback; null when the request isn't one. The code is only exchanged when the request's
39
+ * `uln_state` matches this browser's state cookie, so a code planted in a link by someone else (login CSRF, which
40
+ * would sign the victim in to the attacker's account) is refused, and the exchange proves this browser holds the PKCE
41
+ * verifier. Any failure sends the user back to log in.
42
+ */
43
+ export declare function handleLoginCallback(request: Request, options: LoginCallbackOptions): Promise<Response | null>;
44
+ export declare const DEFAULT_REVALIDATE_SECONDS = 300;
45
+ /**
46
+ * Confirm a locally valid token with the auth service, at most once per `intervalSeconds` per token. Returns the
47
+ * payload to use: the auth service's (current admin/scopes/flags) after a fresh check, the local one while a previous
48
+ * confirmation is fresh or when the auth service can't be reached, and null when the auth service rejects the token
49
+ * (deleted user, revoked session, wrong audience).
50
+ */
51
+ export declare function revalidateToken(auth: FetchLike, token: string, local: TokenPayload, audience: string, intervalSeconds?: number): Promise<TokenPayload | null>;
52
+ /** Test hook: forget which tokens were recently confirmed. */
53
+ export declare function clearRevalidationCache(): void;