@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/src/rate-limit.ts CHANGED
@@ -5,6 +5,39 @@ export interface RateLimitResult {
5
5
  remainingSeconds?: number;
6
6
  }
7
7
 
8
+ /**
9
+ * Count an attempt against `key` and report whether the caller is now over the limit. The attempt is recorded
10
+ * *before* the decision, so concurrent requests can't all pass a check that ran before any of them was counted.
11
+ * Clear the key on success. Race-free only when the store implements `increment`.
12
+ */
13
+ export async function consumeRateLimit(
14
+ store: RateLimitStore,
15
+ key: string,
16
+ config: RateLimitConfig,
17
+ ): Promise<RateLimitResult> {
18
+ const entry = store.increment
19
+ ? await store.increment(key, config.windowSeconds)
20
+ : await incrementViaGetSet(store, key, config);
21
+ if (entry.attempts <= config.maxAttempts) return { blocked: false };
22
+ const remaining = config.windowSeconds - Math.floor((Date.now() - entry.firstAttempt) / 1000);
23
+ return { blocked: true, remainingSeconds: Math.max(remaining, 1) };
24
+ }
25
+
26
+ async function incrementViaGetSet(store: RateLimitStore, key: string, config: RateLimitConfig) {
27
+ const existing = await store.get(key);
28
+ const windowOver = existing !== null && Date.now() - existing.firstAttempt >= config.windowSeconds * 1000;
29
+ const entry =
30
+ existing && !windowOver
31
+ ? { attempts: existing.attempts + 1, firstAttempt: existing.firstAttempt }
32
+ : { attempts: 1, firstAttempt: Date.now() };
33
+ await store.set(key, entry, config.windowSeconds);
34
+ return entry;
35
+ }
36
+
37
+ /**
38
+ * @deprecated Check-then-record is racy: concurrent requests all pass the check before any is recorded. Use
39
+ * `consumeRateLimit`.
40
+ */
8
41
  export async function checkRateLimit(
9
42
  store: RateLimitStore,
10
43
  key: string,
@@ -24,7 +57,12 @@ export async function checkRateLimit(
24
57
  return { blocked: false };
25
58
  }
26
59
 
60
+ /** @deprecated Pair of `checkRateLimit`; use `consumeRateLimit`. */
27
61
  export async function recordFailedAttempt(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<void> {
62
+ if (store.increment) {
63
+ await store.increment(key, config.windowSeconds);
64
+ return;
65
+ }
28
66
  const entry = await store.get(key);
29
67
  if (entry) {
30
68
  await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
package/src/remote.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { importPublicKey, isTokenPayload } from "./token.ts";
2
+ import type { PublicKeyEntry, TokenPayload } from "./types.ts";
3
+
4
+ /** Anything with a Fetch-compatible `fetch` — the global `fetch` wrapped, or a Cloudflare service binding. */
5
+ export interface FetchLike {
6
+ fetch(input: string, init?: RequestInit): Promise<Response>;
7
+ }
8
+
9
+ /** Calls the auth service over the public internet. */
10
+ export function publicFetcher(authUrl: string): FetchLike {
11
+ return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
12
+ }
13
+
14
+ /** Calls the auth service through a Cloudflare service binding. The hostname is ignored by bindings. */
15
+ export function bindingFetcher(binding: { fetch(input: string, init?: RequestInit): Promise<Response> }): FetchLike {
16
+ return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
17
+ }
18
+
19
+ function isLoopbackHostname(hostname: string): boolean {
20
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
21
+ }
22
+
23
+ function toOrigin(value: string): string | null {
24
+ try {
25
+ return new URL(value).origin;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Whether a validated token grants access to `origin`. The token's `aud` must be this origin — a token issued to
33
+ * another site (or to the auth service itself) is never accepted, even for admins. Then admins can access every
34
+ * site; everyone else needs the origin in their `scopes` (loopback origins are exempt so local development works).
35
+ *
36
+ * A valid signature alone only proves the user has *an* account — every consuming site must also run this check.
37
+ */
38
+ export function hasSiteAccess(payload: TokenPayload, origin: string): boolean {
39
+ return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
40
+ }
41
+
42
+ /**
43
+ * `hasSiteAccess` for a site that answers on more than one origin — e.g. a Worker whose request URL origin (where the
44
+ * login callback landed, and so the token's audience) differs from the origin registered for scopes.
45
+ */
46
+ export function hasSiteAccessFor(payload: TokenPayload, site: { audiences: string[]; scopeOrigin: string }): boolean {
47
+ const scopeOrigin = toOrigin(site.scopeOrigin);
48
+ if (!scopeOrigin || payload.aud === undefined) return false;
49
+ if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud)) return false;
50
+ if (payload.admin) return true;
51
+ if (isLoopbackHostname(new URL(scopeOrigin).hostname)) return true;
52
+ return payload.scopes.includes(scopeOrigin);
53
+ }
54
+
55
+ async function readJsonObject(res: Response): Promise<object | null> {
56
+ try {
57
+ const body: unknown = await res.json();
58
+ return typeof body === "object" && body !== null ? body : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * POST /api/exchange-code — trade a one-time `?code=` for a signed token bound to `redirectOrigin`. `codeVerifier` is
66
+ * the PKCE verifier whose challenge went out with the login (required for codes issued with one).
67
+ */
68
+ export async function exchangeAuthCode(
69
+ auth: FetchLike,
70
+ code: string,
71
+ redirectOrigin: string,
72
+ codeVerifier?: string,
73
+ ): Promise<string | null> {
74
+ const res = await auth.fetch("/api/exchange-code", {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify(
78
+ codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier },
79
+ ),
80
+ });
81
+ if (!res.ok) return null;
82
+ const body = await readJsonObject(res);
83
+ if (!body || !("token" in body) || typeof body.token !== "string") return null;
84
+ return body.token;
85
+ }
86
+
87
+ export type RemoteCheck =
88
+ | { status: "valid"; payload: TokenPayload }
89
+ | { status: "invalid" }
90
+ /** Network error or 5xx — the auth service didn't give an answer. */
91
+ | { status: "unavailable" };
92
+
93
+ /**
94
+ * POST /api/verify-token — validate a token with the auth service itself. Unlike local JWKS validation this also
95
+ * rejects tokens for deleted users or revoked sessions, and returns the user's *current* admin/scopes/flags. With an
96
+ * `audience`, the auth service also rejects tokens issued to any other origin.
97
+ */
98
+ export async function checkTokenRemote(auth: FetchLike, token: string, audience?: string): Promise<RemoteCheck> {
99
+ let res: Response;
100
+ try {
101
+ res = await auth.fetch("/api/verify-token", {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify(audience === undefined ? { token } : { token, audience }),
105
+ });
106
+ } catch {
107
+ return { status: "unavailable" };
108
+ }
109
+ if (res.status >= 500) return { status: "unavailable" };
110
+ if (!res.ok) return { status: "invalid" };
111
+ const body = await readJsonObject(res);
112
+ if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null) return { status: "invalid" };
113
+ const user = body.user;
114
+ if (!("id" in user)) return { status: "invalid" };
115
+ const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
116
+ if (!isTokenPayload(candidate)) return { status: "invalid" };
117
+ return {
118
+ status: "valid",
119
+ payload: {
120
+ sub: candidate.sub,
121
+ email: candidate.email,
122
+ name: candidate.name,
123
+ admin: candidate.admin,
124
+ scopes: candidate.scopes,
125
+ flags: candidate.flags,
126
+ kid: candidate.kid,
127
+ iat: candidate.iat,
128
+ exp: candidate.exp,
129
+ ...(candidate.aud === undefined ? {} : { aud: candidate.aud }),
130
+ },
131
+ };
132
+ }
133
+
134
+ /** `checkTokenRemote`, collapsed to the payload or null (an unreachable auth service counts as invalid). */
135
+ export async function verifyTokenRemote(
136
+ auth: FetchLike,
137
+ token: string,
138
+ audience?: string,
139
+ ): Promise<TokenPayload | null> {
140
+ const result = await checkTokenRemote(auth, token, audience);
141
+ return result.status === "valid" ? result.payload : null;
142
+ }
143
+
144
+ /** Fetch and import the auth service's JWKS (`/.well-known/jwk.json`). Keys without a string `kid` are skipped. */
145
+ export async function fetchJwks(auth: FetchLike): Promise<PublicKeyEntry[]> {
146
+ const res = await auth.fetch("/.well-known/jwk.json");
147
+ if (!res.ok) throw new Error(`Failed to fetch JWKS: ${res.status}`);
148
+ const body = await readJsonObject(res);
149
+ if (!body || !("keys" in body) || !Array.isArray(body.keys)) throw new Error("Malformed JWKS response");
150
+ const entries: PublicKeyEntry[] = [];
151
+ for (const jwk of body.keys) {
152
+ if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string") continue;
153
+ if (!("kty" in jwk) || typeof jwk.kty !== "string") continue;
154
+ const { kid, ...keyData } = jwk;
155
+ entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
156
+ }
157
+ return entries;
158
+ }
package/src/service.ts CHANGED
@@ -1,51 +1,38 @@
1
- import { extractCookie, makeSessionCookie } from "./cookie.ts";
1
+ import { extractCookie } from "./cookie.ts";
2
+ import { DEFAULT_AUTH_URL, handleLoginCallback } from "./flow.ts";
3
+ import { bindingFetcher, hasSiteAccessFor, verifyTokenRemote } from "./remote.ts";
4
+ import { peekTokenPayload } from "./token.ts";
2
5
  import type { CookieConfig, TokenPayload } from "./types.ts";
3
- export { makeSessionCookie, makeClearCookie } from "./cookie.ts";
4
- export type { TokenPayload, CookieConfig } from "./types.ts";
6
+
7
+ export { makeClearCookie, makeSessionCookie } from "./cookie.ts";
8
+ export { redirectToLogin } from "./flow.ts";
9
+ export { hasSiteAccess } from "./remote.ts";
10
+ export type { CookieConfig, TokenPayload } from "./types.ts";
5
11
 
6
12
  /**
7
13
  * Validate a raw token string via the auth service binding.
8
- * Returns the token payload or null if invalid.
14
+ * Returns the token payload (with the user's current admin/scopes/flags) or null if invalid or revoked. Pass the
15
+ * site's origin as `audience` so tokens issued to other origins are rejected too.
16
+ * This does not check site access — use `getAuthUser` or `hasSiteAccess` for that.
9
17
  */
10
- export async function verifyToken(authService: Fetcher, token: string): Promise<TokenPayload | null> {
11
- const res = await authService.fetch("https://auth/api/verify-token", {
12
- method: "POST",
13
- headers: { "Content-Type": "application/json" },
14
- body: JSON.stringify({ token }),
15
- });
16
- if (!res.ok) return null;
17
- const data = (await res.json()) as {
18
- user: {
19
- id: string;
20
- email: string;
21
- name: string;
22
- admin: boolean;
23
- scopes: string[];
24
- flags: Record<string, string[]>;
25
- kid: string;
26
- };
27
- };
28
- return {
29
- sub: data.user.id,
30
- email: data.user.email,
31
- name: data.user.name,
32
- admin: data.user.admin,
33
- scopes: data.user.scopes,
34
- flags: data.user.flags,
35
- kid: data.user.kid,
36
- iat: 0,
37
- exp: 0,
38
- };
18
+ export async function verifyToken(
19
+ authService: Fetcher,
20
+ token: string,
21
+ audience?: string,
22
+ ): Promise<TokenPayload | null> {
23
+ return verifyTokenRemote(bindingFetcher(authService), token, audience);
39
24
  }
40
25
 
41
26
  /**
42
27
  * Get the authenticated user from the session cookie via the auth service binding.
43
- * Returns the token payload or null if not authenticated.
28
+ * Returns null if not authenticated, or if the token wasn't issued to — or the user doesn't have access to —
29
+ * `siteOrigin` (defaults to the request's origin).
44
30
  */
45
31
  export async function getAuthUser(
46
32
  request: Request,
47
33
  authService: Fetcher,
48
34
  cookieConfig: CookieConfig,
35
+ siteOrigin?: string,
49
36
  ): Promise<TokenPayload | null> {
50
37
  const cookieHeader = request.headers.get("Cookie");
51
38
  if (!cookieHeader) return null;
@@ -53,60 +40,33 @@ export async function getAuthUser(
53
40
  const token = extractCookie(cookieHeader, cookieConfig.name);
54
41
  if (!token) return null;
55
42
 
56
- return verifyToken(authService, token);
57
- }
58
-
59
- /**
60
- * Redirect to the centralized login page at auth.uln.industries.
61
- * Requests one-time auth-code callbacks so signed tokens are not sent through URLs.
62
- */
63
- export function redirectToLogin(currentUrl: string): Response {
64
- const loginUrl = new URL("/login", "https://auth.uln.industries");
65
- loginUrl.searchParams.set("redirect", currentUrl);
66
- loginUrl.searchParams.set("response_type", "code");
67
- return Response.redirect(loginUrl.toString(), 302);
68
- }
69
-
70
- async function exchangeCode(authService: Fetcher, code: string, redirectOrigin: string): Promise<string | null> {
71
- const res = await authService.fetch("https://auth/api/exchange-code", {
72
- method: "POST",
73
- headers: { "Content-Type": "application/json" },
74
- body: JSON.stringify({ code, redirectOrigin }),
75
- });
76
- if (!res.ok) return null;
77
- const data = (await res.json()) as { token?: string };
78
- return typeof data.token === "string" ? data.token : null;
43
+ // With a separate `siteOrigin`, either origin may be the token's audience. The unverified `aud` only picks which one
44
+ // to ask the auth service about (which checks it), so this is one call, and none for a token issued elsewhere.
45
+ const requestOrigin = new URL(request.url).origin;
46
+ const scopeOrigin = siteOrigin ?? requestOrigin;
47
+ const audiences = [requestOrigin, scopeOrigin];
48
+ const audience = peekTokenPayload(token)?.aud;
49
+ if (audience === undefined || !audiences.includes(audience)) return null;
50
+ const payload = await verifyToken(authService, token, audience);
51
+ if (!payload || !hasSiteAccessFor(payload, { audiences, scopeOrigin })) return null;
52
+ return payload;
79
53
  }
80
54
 
81
55
  /**
82
56
  * Handle an auth callback on a consuming Worker using the auth service binding.
83
- * Preferred `?code=` callbacks are exchanged for signed tokens before setting the local cookie.
84
- * Returns null if the request has no auth callback parameters.
57
+ * `?code=` callbacks started by this browser are exchanged for signed tokens before setting the local cookie.
58
+ * Returns 403 if the user doesn't have access to this site, or null if the request has no callback parameters.
85
59
  */
86
60
  export async function handleCallback(
87
61
  request: Request,
88
62
  authService: Fetcher,
89
63
  cookieConfig: CookieConfig,
90
64
  ): Promise<Response | null> {
91
- const url = new URL(request.url);
92
-
93
- const code = url.searchParams.get("code");
94
- if (code) {
95
- const token = await exchangeCode(authService, code, url.origin);
96
- if (!token) return redirectToLogin(url.origin);
97
-
98
- const payload = await verifyToken(authService, token);
99
- if (!payload) return redirectToLogin(url.origin);
100
-
101
- url.searchParams.delete("code");
102
- return new Response(null, {
103
- status: 302,
104
- headers: {
105
- Location: url.toString(),
106
- "Set-Cookie": makeSessionCookie(cookieConfig, token),
107
- },
108
- });
109
- }
110
-
111
- return null;
65
+ const audience = new URL(request.url).origin;
66
+ return handleLoginCallback(request, {
67
+ auth: bindingFetcher(authService),
68
+ authUrl: DEFAULT_AUTH_URL,
69
+ cookieConfig,
70
+ validate: (token) => verifyToken(authService, token, audience),
71
+ });
112
72
  }
package/src/token.ts CHANGED
@@ -10,7 +10,8 @@ function base64urlEncode(data: Uint8Array): string {
10
10
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
11
11
  }
12
12
 
13
- function base64urlDecode(str: string): Uint8Array {
13
+ function base64urlDecode(str: string): Uint8Array<ArrayBuffer> {
14
+ if (!/^[A-Za-z0-9_-]*$/.test(str)) throw new Error("Invalid base64url");
14
15
  const padded = str.replace(/-/g, "+").replace(/_/g, "/");
15
16
  const binary = atob(padded);
16
17
  const bytes = new Uint8Array(binary.length);
@@ -52,11 +53,26 @@ export interface CreateTokenInput {
52
53
  isAdmin: boolean;
53
54
  scopes: string[];
54
55
  flags: Record<string, string[]>;
56
+ /** The origin the token is for (see `TokenPayload.aud`). */
57
+ audience?: string;
58
+ /** See `TokenPayload.sid`. */
59
+ sessionId?: string;
60
+ /** See `TokenPayload.dbsc`. */
61
+ deviceBound?: boolean;
62
+ }
63
+
64
+ export interface CreateTokenOptions {
65
+ /** Unix seconds to use as `iat` instead of now (e.g. to land just after a session revocation cut-off). */
66
+ issuedAt?: number;
55
67
  }
56
68
 
57
69
  /** Create a signed token: `base64url(JSON payload).base64url(ECDSA-SHA256 signature)`. */
58
- export async function createToken(config: SigningConfig, user: CreateTokenInput): Promise<string> {
59
- const now = Math.floor(Date.now() / 1000);
70
+ export async function createToken(
71
+ config: SigningConfig,
72
+ user: CreateTokenInput,
73
+ options: CreateTokenOptions = {},
74
+ ): Promise<string> {
75
+ const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
60
76
  const payload: TokenPayload = {
61
77
  sub: user.id,
62
78
  email: user.email,
@@ -67,6 +83,9 @@ export async function createToken(config: SigningConfig, user: CreateTokenInput)
67
83
  kid: config.kid,
68
84
  iat: now,
69
85
  exp: now + config.maxAgeSeconds,
86
+ ...(user.audience === undefined ? {} : { aud: user.audience }),
87
+ ...(user.sessionId === undefined ? {} : { sid: user.sessionId }),
88
+ ...(user.deviceBound ? { dbsc: true } : {}),
70
89
  };
71
90
  const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
72
91
  const payloadBytes = encoder.encode(payloadB64);
@@ -75,28 +94,74 @@ export async function createToken(config: SigningConfig, user: CreateTokenInput)
75
94
  return `${payloadB64}.${sigB64}`;
76
95
  }
77
96
 
78
- /** Validate a signed token. Returns the payload if valid and not expired, or null. Matches `kid` to find the correct public key. */
79
- export async function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null> {
80
- const dotIndex = token.indexOf(".");
81
- if (dotIndex === -1) return null;
97
+ /** Allowed clock skew between the issuer and a verifier, in seconds. */
98
+ const CLOCK_SKEW_SECONDS = 60;
82
99
 
83
- const payloadB64 = token.substring(0, dotIndex);
84
- const sigB64 = token.substring(dotIndex + 1);
100
+ function isStringArray(value: unknown): value is string[] {
101
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
102
+ }
85
103
 
86
- let payload: TokenPayload;
87
- try {
88
- const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
89
- payload = JSON.parse(decoded) as TokenPayload;
90
- } catch {
91
- return null;
104
+ function isFlags(value: unknown): value is Record<string, string[]> {
105
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
106
+ return Object.values(value).every(isStringArray);
107
+ }
108
+
109
+ /** Structural check for a decoded token payload. Rejects anything whose fields don't match `TokenPayload`. */
110
+ export function isTokenPayload(value: unknown): value is TokenPayload {
111
+ if (typeof value !== "object" || value === null) return false;
112
+ if (
113
+ !("sub" in value) ||
114
+ !("email" in value) ||
115
+ !("name" in value) ||
116
+ !("admin" in value) ||
117
+ !("scopes" in value) ||
118
+ !("flags" in value) ||
119
+ !("kid" in value) ||
120
+ !("iat" in value) ||
121
+ !("exp" in value)
122
+ ) {
123
+ return false;
92
124
  }
125
+ return (
126
+ typeof value.sub === "string" &&
127
+ typeof value.email === "string" &&
128
+ typeof value.name === "string" &&
129
+ typeof value.admin === "boolean" &&
130
+ isStringArray(value.scopes) &&
131
+ isFlags(value.flags) &&
132
+ typeof value.kid === "string" &&
133
+ Number.isSafeInteger(value.iat) &&
134
+ Number.isSafeInteger(value.exp) &&
135
+ (!("aud" in value) || value.aud === undefined || typeof value.aud === "string") &&
136
+ (!("sid" in value) || value.sid === undefined || typeof value.sid === "string") &&
137
+ (!("dbsc" in value) || value.dbsc === undefined || typeof value.dbsc === "boolean")
138
+ );
139
+ }
93
140
 
94
- if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
141
+ /**
142
+ * Validate a signed token. Returns the payload if the signature is valid and the token is within its lifetime,
143
+ * or null. Matches `kid` to find the correct public key. Tokens issued in the future (beyond a small clock skew)
144
+ * or older than `config.maxAgeSeconds` are rejected even if `exp` says otherwise.
145
+ */
146
+ export async function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null> {
147
+ const parts = token.split(".");
148
+ if (parts.length !== 2) return null;
149
+ const [payloadB64 = "", sigB64 = ""] = parts;
150
+ if (!payloadB64 || !sigB64) return null;
151
+
152
+ let decoded: unknown;
153
+ try {
154
+ decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
155
+ } catch {
95
156
  return null;
96
157
  }
158
+ if (!isTokenPayload(decoded)) return null;
159
+ const payload = decoded;
97
160
 
98
161
  const now = Math.floor(Date.now() / 1000);
99
162
  if (now > payload.exp) return null;
163
+ if (payload.iat > now + CLOCK_SKEW_SECONDS) return null;
164
+ if (now - payload.iat > config.maxAgeSeconds) return null;
100
165
 
101
166
  const entry = config.publicKeys.find((k) => k.kid === payload.kid);
102
167
  if (!entry) return null;
@@ -112,3 +177,31 @@ export async function validateToken(config: VerifyConfig, token: string): Promis
112
177
 
113
178
  return payload;
114
179
  }
180
+
181
+ /**
182
+ * Decode a token's payload **without verifying it** — only to decide which key or account to look up, or which
183
+ * audience to ask about, before the real check. Null unless it is structurally a `TokenPayload`.
184
+ */
185
+ export function peekTokenPayload(token: string): TokenPayload | null {
186
+ const payloadB64 = token.split(".")[0];
187
+ if (!payloadB64) return null;
188
+ try {
189
+ const decoded: unknown = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
190
+ return isTokenPayload(decoded) ? decoded : null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /** Read the `kid` claim from a token without verifying it. Used to decide whether a JWKS refresh is worthwhile. */
197
+ export function peekTokenKid(token: string): string | null {
198
+ const payloadB64 = token.split(".")[0];
199
+ if (!payloadB64) return null;
200
+ try {
201
+ const decoded: unknown = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
202
+ if (typeof decoded !== "object" || decoded === null || !("kid" in decoded)) return null;
203
+ return typeof decoded.kid === "string" ? decoded.kid : null;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
package/src/totp.ts CHANGED
@@ -111,19 +111,29 @@ export async function currentTotpCode(secret: string, nowMs = Date.now()): Promi
111
111
  return generateCode(secret, step);
112
112
  }
113
113
 
114
- /** Verify a TOTP code against the secret. Allows ±1 time step for clock drift. */
115
- export async function verifyTotpCode(secret: string, code: string): Promise<boolean> {
116
- if (code.length !== TOTP_DIGITS) return false;
114
+ /**
115
+ * Verify a TOTP code against the secret, allowing ±1 time step for clock drift.
116
+ * Returns the matched time step, or null. Callers should persist the step and reject any code whose step is not
117
+ * strictly greater than the last accepted one — otherwise a code can be replayed for up to 90 seconds.
118
+ */
119
+ export async function verifyTotpStep(secret: string, code: string, nowMs = Date.now()): Promise<number | null> {
120
+ if (!/^\d{6}$/.test(code)) return null;
117
121
 
118
- const now = Math.floor(Date.now() / 1000);
119
- const currentStep = Math.floor(now / TOTP_PERIOD);
122
+ const currentStep = Math.floor(nowMs / 1000 / TOTP_PERIOD);
120
123
 
124
+ // Check every step (no early exit) so timing doesn't reveal which window matched.
125
+ let matched: number | null = null;
121
126
  for (let offset = -TOTP_WINDOW; offset <= TOTP_WINDOW; offset++) {
122
- const expected = await generateCode(secret, currentStep + offset);
123
- if (timingSafeEqual(code, expected)) return true;
127
+ const step = currentStep + offset;
128
+ const expected = await generateCode(secret, step);
129
+ if (timingSafeEqual(code, expected)) matched = step;
124
130
  }
131
+ return matched;
132
+ }
125
133
 
126
- return false;
134
+ /** Verify a TOTP code against the secret. Allows ±1 time step for clock drift. Does not prevent replay. */
135
+ export async function verifyTotpCode(secret: string, code: string): Promise<boolean> {
136
+ return (await verifyTotpStep(secret, code)) !== null;
127
137
  }
128
138
 
129
139
  const BACKUP_CODE_COUNT = 10;
@@ -134,11 +144,14 @@ const BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
134
144
  export function generateBackupCodes(): string[] {
135
145
  const codes: string[] = [];
136
146
  for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
137
- const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
138
- crypto.getRandomValues(bytes);
139
147
  let code = "";
140
- for (const byte of bytes) {
141
- code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
148
+ while (code.length < BACKUP_CODE_LENGTH) {
149
+ const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
150
+ crypto.getRandomValues(bytes);
151
+ for (const byte of bytes) {
152
+ // Rejection sampling: 252 is the largest multiple of 36 below 256, so every character is equally likely.
153
+ if (byte < 252 && code.length < BACKUP_CODE_LENGTH) code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
154
+ }
142
155
  }
143
156
  codes.push(code);
144
157
  }
package/src/types.ts CHANGED
@@ -28,6 +28,11 @@ export interface RateLimitStore {
28
28
  get(key: string): Promise<RateLimitEntry | null>;
29
29
  set(key: string, entry: RateLimitEntry, ttlSeconds: number): Promise<void>;
30
30
  delete(key: string): Promise<void>;
31
+ /**
32
+ * Atomically add one attempt (creating the entry if needed) and return the result. Optional, but stores that
33
+ * implement it are race-free under concurrent requests; otherwise a read-modify-write via get/set is used.
34
+ */
35
+ increment?(key: string, ttlSeconds: number): Promise<RateLimitEntry>;
31
36
  }
32
37
 
33
38
  export interface RateLimitConfig {
@@ -45,6 +50,19 @@ export interface TokenPayload {
45
50
  kid: string;
46
51
  iat: number;
47
52
  exp: number;
53
+ /**
54
+ * Audience: the origin this token was issued to (a site's origin, or the auth service's own origin for its session
55
+ * cookie). A token is only honoured where `aud` matches. Absent only on tokens minted before audience binding
56
+ * (library < 0.5); current verifiers reject those.
57
+ */
58
+ aud?: string;
59
+ /** Auth-service sessions only: the server-side session this token belongs to (worker `sessions` table). */
60
+ sid?: string;
61
+ /**
62
+ * Auth-service sessions only: a short-lived token for a Device Bound Session (DBSC), renewed by a proof from a key
63
+ * held by the user's device. Once a session is bound, only these tokens are honoured for it.
64
+ */
65
+ dbsc?: boolean;
48
66
  }
49
67
 
50
68
  export interface PublicKeyEntry {
@@ -61,6 +79,12 @@ export interface SigningConfig {
61
79
  export interface VerifyConfig {
62
80
  publicKeys: PublicKeyEntry[];
63
81
  maxAgeSeconds: number;
82
+ /**
83
+ * Also confirm locally valid tokens with the auth service (at most once per token every `intervalSeconds`, default
84
+ * 300), so password resets, deleted users and removed scopes take effect without waiting for the token to expire.
85
+ * If the auth service can't be reached the local result stands.
86
+ */
87
+ revalidate?: { authUrl: string; intervalSeconds?: number };
64
88
  }
65
89
 
66
90
  export interface CookieConfig {