@vc1023/passkey-2fa 0.1.1 → 0.2.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/README.md CHANGED
@@ -113,7 +113,36 @@ import {
113
113
  // each ceremony returns { ok:true } | { ok:false, reason:"cancelled"|"unsupported"|"error" }
114
114
  ```
115
115
 
116
+ ## Distributed rate limiting (optional)
117
+
118
+ The default limiter is in-memory and **per-instance** (fine for one instance; not shared across serverless instances/regions). For multi-instance production, inject a distributed `RateLimiter` — e.g. Upstash Redis:
119
+
120
+ ```ts
121
+ // app/lib/auth.ts
122
+ import { Ratelimit } from "@upstash/ratelimit";
123
+ import { Redis } from "@upstash/redis";
124
+ import { createPasskeyAuthHandlers, type RateLimiter } from "@vc1023/passkey-2fa/routes";
125
+
126
+ const redis = Redis.fromEnv(); // UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN
127
+ const cache = new Map<string, Ratelimit>();
128
+
129
+ const rateLimit: RateLimiter = async (key, limit, windowMs) => {
130
+ const id = `${limit}:${windowMs}`;
131
+ let rl = cache.get(id);
132
+ if (!rl) {
133
+ rl = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(limit, `${windowMs} ms`), prefix: "pk2fa" });
134
+ cache.set(id, rl);
135
+ }
136
+ const r = await rl.limit(key);
137
+ return { ok: r.success, retryAfterSeconds: Math.max(0, Math.ceil((r.reset - Date.now()) / 1000)) };
138
+ };
139
+
140
+ export const handlers = createPasskeyAuthHandlers({ rateLimit /*, onEvent */ });
141
+ ```
142
+
143
+ `RateLimiter` is `(key, limit, windowMs) => RateLimitResult | Promise<RateLimitResult>` — the per-endpoint limit/window are passed in, so one implementation serves every route.
144
+
116
145
  ## Notes
117
146
  - Route handlers run on `runtime = "nodejs"` (the AAL2 token uses `node:crypto`). The middleware is Edge-safe.
118
- - Rate limiting is in-memory / per-instance back it with Upstash/Firewall at scale.
147
+ - The AAL2 session is **bound to the Supabase session id** (fail-closed): a stolen AAL2 cookie can't elevate a different session.
119
148
  - Server validation is enforced; you may also `signUpSchema.safeParse()` client-side for instant feedback.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vc1023/passkey-2fa",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Drop-in password + passkey (WebAuthn) 2FA for Next.js App Router + Supabase.",
5
5
  "license": "MIT",
6
6
  "private": false,
package/src/guard.ts CHANGED
@@ -41,10 +41,13 @@ export async function getAal2UserId(): Promise<string | null> {
41
41
  const store = await cookies();
42
42
  const claims = verifyAal2Token(store.get(AAL2_COOKIE)?.value, mfaSecret());
43
43
  if (!claims || claims.sub !== user.id) return null;
44
- if (claims.sid) {
45
- const sid = await currentSessionId();
46
- if (sid && claims.sid !== sid) return null;
47
- }
44
+ // Session binding is REQUIRED (fail-closed): the AAL2 token must carry a `sid`
45
+ // and it must match the live Supabase session. A stolen AAL2 cookie therefore
46
+ // can't elevate a different session, and a token minted without a binding is
47
+ // rejected. Supabase access tokens always carry `session_id`, so this does not
48
+ // lock out legitimate sessions.
49
+ const sid = await currentSessionId();
50
+ if (!claims.sid || !sid || claims.sid !== sid) return null;
48
51
  return user.id;
49
52
  }
50
53
 
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { rateLimit } from "./rate-limit";
2
+ import { inMemoryRateLimit } from "./rate-limit";
3
3
 
4
- describe("rateLimit", () => {
4
+ describe("inMemoryRateLimit", () => {
5
5
  it("allows up to the limit then blocks within the window", () => {
6
6
  const key = `test-${Math.floor(Math.random() * 1e9)}`;
7
- for (let i = 0; i < 3; i++) expect(rateLimit(key, 3, 60_000).ok).toBe(true);
8
- const blocked = rateLimit(key, 3, 60_000);
7
+ for (let i = 0; i < 3; i++) expect(inMemoryRateLimit(key, 3, 60_000)).toMatchObject({ ok: true });
8
+ const blocked = inMemoryRateLimit(key, 3, 60_000) as { ok: boolean; retryAfterSeconds: number };
9
9
  expect(blocked.ok).toBe(false);
10
10
  expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
11
11
  });
@@ -13,8 +13,8 @@ describe("rateLimit", () => {
13
13
  it("uses independent buckets per key", () => {
14
14
  const a = `a-${Math.floor(Math.random() * 1e9)}`;
15
15
  const b = `b-${Math.floor(Math.random() * 1e9)}`;
16
- expect(rateLimit(a, 1, 60_000).ok).toBe(true);
17
- expect(rateLimit(a, 1, 60_000).ok).toBe(false);
18
- expect(rateLimit(b, 1, 60_000).ok).toBe(true); // different key still allowed
16
+ expect(inMemoryRateLimit(a, 1, 60_000)).toMatchObject({ ok: true });
17
+ expect(inMemoryRateLimit(a, 1, 60_000)).toMatchObject({ ok: false });
18
+ expect(inMemoryRateLimit(b, 1, 60_000)).toMatchObject({ ok: true }); // different key still allowed
19
19
  });
20
20
  });
package/src/rate-limit.ts CHANGED
@@ -1,10 +1,24 @@
1
- // Lightweight in-memory sliding-window rate limiter for the sensitive auth
2
- // routes.
1
+ // Rate limiting for the sensitive auth routes.
3
2
  //
4
- // SCOPE LIMIT (documented, not hidden): this is PER-INSTANCE. On serverless /
5
- // Fluid Compute it protects within a warm instance but is NOT distributed across
6
- // instances/regions. For production at scale, back it with Upstash Redis or a
7
- // platform firewall (the call sites are the integration points).
3
+ // The limiter is PLUGGABLE: `createPasskeyAuthHandlers` accepts a `rateLimit`
4
+ // (RateLimiter). The default is the in-memory sliding window below fine for a
5
+ // single instance, but PER-INSTANCE: on serverless / Fluid Compute it protects
6
+ // within a warm instance, NOT across instances/regions. For production at scale,
7
+ // inject a distributed limiter (e.g. Upstash Redis) — see the README. Because
8
+ // RateLimiter may be async, all call sites await it.
9
+
10
+ export interface RateLimitResult {
11
+ ok: boolean;
12
+ retryAfterSeconds: number;
13
+ }
14
+
15
+ /** A rate limiter. Receives the bucket key + the per-call-site limit/window so a
16
+ * single implementation (memory, Redis, …) can serve every endpoint. */
17
+ export type RateLimiter = (
18
+ key: string,
19
+ limit: number,
20
+ windowMs: number,
21
+ ) => RateLimitResult | Promise<RateLimitResult>;
8
22
 
9
23
  interface Bucket {
10
24
  count: number;
@@ -13,12 +27,8 @@ interface Bucket {
13
27
 
14
28
  const buckets = new Map<string, Bucket>();
15
29
 
16
- export interface RateLimitResult {
17
- ok: boolean;
18
- retryAfterSeconds: number;
19
- }
20
-
21
- export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
30
+ /** Default per-instance in-memory sliding-window limiter. */
31
+ export const inMemoryRateLimit: RateLimiter = (key, limit, windowMs) => {
22
32
  const now = Date.now();
23
33
  const b = buckets.get(key);
24
34
  if (!b || b.resetAt <= now) {
@@ -30,10 +40,11 @@ export function rateLimit(key: string, limit: number, windowMs: number): RateLim
30
40
  }
31
41
  b.count += 1;
32
42
  return { ok: true, retryAfterSeconds: 0 };
33
- }
43
+ };
34
44
 
35
- /** Best-effort client IP from proxy headers (Vercel sets x-forwarded-for).
36
- * Trust only behind a trusted proxy the header is spoofable otherwise. */
45
+ /** Best-effort client IP from proxy headers. SECURITY: `x-forwarded-for` is only
46
+ * trustworthy behind a trusted proxy (e.g. Vercel sets it) it is client-
47
+ * spoofable otherwise, so don't rely on it for anything but coarse throttling. */
37
48
  export function clientIp(req: Request): string {
38
49
  const xff = req.headers.get("x-forwarded-for");
39
50
  return xff?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
package/src/routes.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  verifyAuthentication,
13
13
  verifyRegistration,
14
14
  } from "./webauthn";
15
- import { clientIp, rateLimit, tooManyRequests } from "./rate-limit";
15
+ import { clientIp, inMemoryRateLimit, tooManyRequests, type RateLimiter } from "./rate-limit";
16
16
  import type { OnAuthEvent } from "./events";
17
17
 
18
18
  export type { AuthEvent, OnAuthEvent } from "./events";
@@ -27,6 +27,10 @@ function json(data: unknown, status = 200): Response {
27
27
  export interface PasskeyAuthOptions {
28
28
  /** Hook for audit/analytics/funnel. Errors here are swallowed (best-effort). */
29
29
  onEvent?: OnAuthEvent;
30
+ /** Rate limiter for the credential + ceremony routes. Defaults to a
31
+ * per-instance in-memory limiter; inject a distributed one (e.g. Upstash
32
+ * Redis) for multi-instance production — see the README. */
33
+ rateLimit?: RateLimiter;
30
34
  }
31
35
 
32
36
  export interface PasskeyAuthHandlers {
@@ -40,6 +44,7 @@ export interface PasskeyAuthHandlers {
40
44
  }
41
45
 
42
46
  export function createPasskeyAuthHandlers(opts: PasskeyAuthOptions = {}): PasskeyAuthHandlers {
47
+ const limiter: RateLimiter = opts.rateLimit ?? inMemoryRateLimit;
43
48
  const emit: OnAuthEvent = async (e) => {
44
49
  try {
45
50
  await opts.onEvent?.(e);
@@ -50,7 +55,7 @@ export function createPasskeyAuthHandlers(opts: PasskeyAuthOptions = {}): Passke
50
55
 
51
56
  return {
52
57
  async signUp(req) {
53
- const limit = rateLimit(`signup:${clientIp(req)}`, 5, 10 * 60 * 1000);
58
+ const limit = await limiter(`signup:${clientIp(req)}`, 5, 10 * 60 * 1000);
54
59
  if (!limit.ok) return tooManyRequests(limit.retryAfterSeconds);
55
60
 
56
61
  let body: unknown;
@@ -81,7 +86,7 @@ export function createPasskeyAuthHandlers(opts: PasskeyAuthOptions = {}): Passke
81
86
  },
82
87
 
83
88
  async signIn(req) {
84
- const limit = rateLimit(`signin:${clientIp(req)}`, 10, 5 * 60 * 1000);
89
+ const limit = await limiter(`signin:${clientIp(req)}`, 10, 5 * 60 * 1000);
85
90
  if (!limit.ok) return tooManyRequests(limit.retryAfterSeconds);
86
91
 
87
92
  let body: unknown;
@@ -133,7 +138,7 @@ export function createPasskeyAuthHandlers(opts: PasskeyAuthOptions = {}): Passke
133
138
  async registerVerify(req) {
134
139
  const user = await getSessionUser();
135
140
  if (!user) return json({ ok: false, error: "server" }, 401);
136
- const limit = rateLimit(`mfa-reg:${user.id}`, 10, 5 * 60 * 1000);
141
+ const limit = await limiter(`mfa-reg:${user.id}`, 10, 5 * 60 * 1000);
137
142
  if (!limit.ok) return tooManyRequests(limit.retryAfterSeconds);
138
143
 
139
144
  let response: RegistrationResponseJSON;
@@ -160,7 +165,7 @@ export function createPasskeyAuthHandlers(opts: PasskeyAuthOptions = {}): Passke
160
165
  async authenticateVerify(req) {
161
166
  const user = await getSessionUser();
162
167
  if (!user) return json({ ok: false, error: "server" }, 401);
163
- const limit = rateLimit(`mfa-auth:${user.id}`, 10, 5 * 60 * 1000);
168
+ const limit = await limiter(`mfa-auth:${user.id}`, 10, 5 * 60 * 1000);
164
169
  if (!limit.ok) return tooManyRequests(limit.retryAfterSeconds);
165
170
 
166
171
  let response: AuthenticationResponseJSON;