@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/service.js CHANGED
@@ -23,80 +23,399 @@ function extractCookie(cookieHeader, name) {
23
23
  return null;
24
24
  }
25
25
 
26
- // src/service.ts
27
- async function verifyToken(authService, token) {
28
- const res = await authService.fetch("https://auth/api/verify-token", {
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", {
29
206
  method: "POST",
30
207
  headers: { "Content-Type": "application/json" },
31
- body: JSON.stringify({ token })
208
+ body: JSON.stringify(codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier })
32
209
  });
33
210
  if (!res.ok)
34
211
  return null;
35
- const data = await res.json();
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" };
36
241
  return {
37
- sub: data.user.id,
38
- email: data.user.email,
39
- name: data.user.name,
40
- admin: data.user.admin,
41
- scopes: data.user.scopes,
42
- flags: data.user.flags,
43
- kid: data.user.kid,
44
- iat: 0,
45
- exp: 0
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
+ }
46
255
  };
47
256
  }
48
- async function getAuthUser(request, authService, cookieConfig) {
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
+
382
+ // src/service.ts
383
+ async function verifyToken(authService, token, audience) {
384
+ return verifyTokenRemote(bindingFetcher(authService), token, audience);
385
+ }
386
+ async function getAuthUser(request, authService, cookieConfig, siteOrigin) {
49
387
  const cookieHeader = request.headers.get("Cookie");
50
388
  if (!cookieHeader)
51
389
  return null;
52
390
  const token = extractCookie(cookieHeader, cookieConfig.name);
53
391
  if (!token)
54
392
  return null;
55
- return verifyToken(authService, token);
56
- }
57
- function redirectToLogin(currentUrl) {
58
- const loginUrl = new URL("/login", "https://auth.uln.industries");
59
- loginUrl.searchParams.set("redirect", currentUrl);
60
- loginUrl.searchParams.set("response_type", "code");
61
- return Response.redirect(loginUrl.toString(), 302);
62
- }
63
- async function exchangeCode(authService, code, redirectOrigin) {
64
- const res = await authService.fetch("https://auth/api/exchange-code", {
65
- method: "POST",
66
- headers: { "Content-Type": "application/json" },
67
- body: JSON.stringify({ code, redirectOrigin })
68
- });
69
- if (!res.ok)
393
+ const requestOrigin = new URL(request.url).origin;
394
+ const scopeOrigin = siteOrigin ?? requestOrigin;
395
+ const audiences = [requestOrigin, scopeOrigin];
396
+ const audience = peekTokenPayload(token)?.aud;
397
+ if (audience === undefined || !audiences.includes(audience))
398
+ return null;
399
+ const payload = await verifyToken(authService, token, audience);
400
+ if (!payload || !hasSiteAccessFor(payload, { audiences, scopeOrigin }))
70
401
  return null;
71
- const data = await res.json();
72
- return typeof data.token === "string" ? data.token : null;
402
+ return payload;
73
403
  }
74
404
  async function handleCallback(request, authService, cookieConfig) {
75
- const url = new URL(request.url);
76
- const code = url.searchParams.get("code");
77
- if (code) {
78
- const token = await exchangeCode(authService, code, url.origin);
79
- if (!token)
80
- return redirectToLogin(url.origin);
81
- const payload = await verifyToken(authService, token);
82
- if (!payload)
83
- return redirectToLogin(url.origin);
84
- url.searchParams.delete("code");
85
- return new Response(null, {
86
- status: 302,
87
- headers: {
88
- Location: url.toString(),
89
- "Set-Cookie": makeSessionCookie(cookieConfig, token)
90
- }
91
- });
92
- }
93
- return null;
405
+ const audience = new URL(request.url).origin;
406
+ return handleLoginCallback(request, {
407
+ auth: bindingFetcher(authService),
408
+ authUrl: DEFAULT_AUTH_URL,
409
+ cookieConfig,
410
+ validate: (token) => verifyToken(authService, token, audience)
411
+ });
94
412
  }
95
413
  export {
96
- verifyToken,
97
- redirectToLogin,
98
- makeSessionCookie,
99
- makeClearCookie,
414
+ getAuthUser,
100
415
  handleCallback,
101
- getAuthUser
416
+ hasSiteAccess,
417
+ makeClearCookie,
418
+ makeSessionCookie,
419
+ redirectToLogin,
420
+ verifyToken
102
421
  };
package/dist/token.d.ts CHANGED
@@ -16,8 +16,27 @@ export interface CreateTokenInput {
16
16
  isAdmin: boolean;
17
17
  scopes: string[];
18
18
  flags: Record<string, string[]>;
19
+ /** The origin the token is for (see `TokenPayload.aud`). */
20
+ audience?: string;
21
+ }
22
+ export interface CreateTokenOptions {
23
+ /** Unix seconds to use as `iat` instead of now (e.g. to land just after a session revocation cut-off). */
24
+ issuedAt?: number;
19
25
  }
20
26
  /** Create a signed token: `base64url(JSON payload).base64url(ECDSA-SHA256 signature)`. */
21
- export declare function createToken(config: SigningConfig, user: CreateTokenInput): Promise<string>;
22
- /** Validate a signed token. Returns the payload if valid and not expired, or null. Matches `kid` to find the correct public key. */
27
+ export declare function createToken(config: SigningConfig, user: CreateTokenInput, options?: CreateTokenOptions): Promise<string>;
28
+ /** Structural check for a decoded token payload. Rejects anything whose fields don't match `TokenPayload`. */
29
+ export declare function isTokenPayload(value: unknown): value is TokenPayload;
30
+ /**
31
+ * Validate a signed token. Returns the payload if the signature is valid and the token is within its lifetime,
32
+ * or null. Matches `kid` to find the correct public key. Tokens issued in the future (beyond a small clock skew)
33
+ * or older than `config.maxAgeSeconds` are rejected even if `exp` says otherwise.
34
+ */
23
35
  export declare function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null>;
36
+ /**
37
+ * Decode a token's payload **without verifying it** — only to decide which key or account to look up, or which
38
+ * audience to ask about, before the real check. Null unless it is structurally a `TokenPayload`.
39
+ */
40
+ export declare function peekTokenPayload(token: string): TokenPayload | null;
41
+ /** Read the `kid` claim from a token without verifying it. Used to decide whether a JWKS refresh is worthwhile. */
42
+ export declare function peekTokenKid(token: string): string | null;
package/dist/token.js CHANGED
@@ -9,6 +9,8 @@ function base64urlEncode(data) {
9
9
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
10
10
  }
11
11
  function base64urlDecode(str) {
12
+ if (!/^[A-Za-z0-9_-]*$/.test(str))
13
+ throw new Error("Invalid base64url");
12
14
  const padded = str.replace(/-/g, "+").replace(/_/g, "/");
13
15
  const binary = atob(padded);
14
16
  const bytes = new Uint8Array(binary.length);
@@ -36,8 +38,8 @@ async function importPrivateKey(jwk) {
36
38
  async function importPublicKey(jwk) {
37
39
  return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
38
40
  }
39
- async function createToken(config, user) {
40
- const now = Math.floor(Date.now() / 1000);
41
+ async function createToken(config, user, options = {}) {
42
+ const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
41
43
  const payload = {
42
44
  sub: user.id,
43
45
  email: user.email,
@@ -47,7 +49,8 @@ async function createToken(config, user) {
47
49
  flags: user.flags,
48
50
  kid: config.kid,
49
51
  iat: now,
50
- exp: now + config.maxAgeSeconds
52
+ exp: now + config.maxAgeSeconds,
53
+ ...user.audience === undefined ? {} : { aud: user.audience }
51
54
  };
52
55
  const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
53
56
  const payloadBytes = encoder.encode(payloadB64);
@@ -55,25 +58,46 @@ async function createToken(config, user) {
55
58
  const sigB64 = base64urlEncode(new Uint8Array(signature));
56
59
  return `${payloadB64}.${sigB64}`;
57
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
+ }
58
78
  async function validateToken(config, token) {
59
- const dotIndex = token.indexOf(".");
60
- 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)
61
84
  return null;
62
- const payloadB64 = token.substring(0, dotIndex);
63
- const sigB64 = token.substring(dotIndex + 1);
64
- let payload;
85
+ let decoded;
65
86
  try {
66
- const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
67
- payload = JSON.parse(decoded);
87
+ decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
68
88
  } catch {
69
89
  return null;
70
90
  }
71
- if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
91
+ if (!isTokenPayload(decoded))
72
92
  return null;
73
- }
93
+ const payload = decoded;
74
94
  const now = Math.floor(Date.now() / 1000);
75
95
  if (now > payload.exp)
76
96
  return null;
97
+ if (payload.iat > now + CLOCK_SKEW_SECONDS)
98
+ return null;
99
+ if (now - payload.iat > config.maxAgeSeconds)
100
+ return null;
77
101
  const entry = config.publicKeys.find((k) => k.kid === payload.kid);
78
102
  if (!entry)
79
103
  return null;
@@ -88,10 +112,37 @@ async function validateToken(config, token) {
88
112
  }
89
113
  return payload;
90
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
+ }
91
139
  export {
92
- validateToken,
93
- importPublicKey,
94
- importPrivateKey,
140
+ createToken,
95
141
  generateKeyPair,
96
- createToken
142
+ importPrivateKey,
143
+ importPublicKey,
144
+ isTokenPayload,
145
+ peekTokenKid,
146
+ peekTokenPayload,
147
+ validateToken
97
148
  };
package/dist/totp.d.ts CHANGED
@@ -11,7 +11,13 @@ export declare function generateTotpSecret(): string;
11
11
  export declare function getTotpUri(secret: string, email: string, issuer: string): string;
12
12
  /** Six-digit code for the current 30-second step (deterministic counterpart to verifyTotpCode in tests). */
13
13
  export declare function currentTotpCode(secret: string, nowMs?: number): Promise<string>;
14
- /** Verify a TOTP code against the secret. Allows ±1 time step for clock drift. */
14
+ /**
15
+ * Verify a TOTP code against the secret, allowing ±1 time step for clock drift.
16
+ * Returns the matched time step, or null. Callers should persist the step and reject any code whose step is not
17
+ * strictly greater than the last accepted one — otherwise a code can be replayed for up to 90 seconds.
18
+ */
19
+ export declare function verifyTotpStep(secret: string, code: string, nowMs?: number): Promise<number | null>;
20
+ /** Verify a TOTP code against the secret. Allows ±1 time step for clock drift. Does not prevent replay. */
15
21
  export declare function verifyTotpCode(secret: string, code: string): Promise<boolean>;
16
22
  /** Generate a set of one-time backup codes for 2FA recovery. Returns plain-text codes. */
17
23
  export declare function generateBackupCodes(): string[];