@vc1023/passkey-2fa 0.1.0 → 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 +30 -1
- package/package.json +2 -2
- package/src/guard.ts +7 -4
- package/src/rate-limit.test.ts +7 -7
- package/src/rate-limit.ts +26 -15
- package/src/routes.ts +10 -5
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
|
-
-
|
|
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.
|
|
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,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"./migrations/*": "./migrations/*"
|
|
19
19
|
},
|
|
20
20
|
"bin": {
|
|
21
|
-
"passkey-2fa": "
|
|
21
|
+
"passkey-2fa": "bin/check-env.mjs"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"src",
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
package/src/rate-limit.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { inMemoryRateLimit } from "./rate-limit";
|
|
3
3
|
|
|
4
|
-
describe("
|
|
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(
|
|
8
|
-
const blocked =
|
|
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(
|
|
17
|
-
expect(
|
|
18
|
-
expect(
|
|
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
|
-
//
|
|
2
|
-
// routes.
|
|
1
|
+
// Rate limiting for the sensitive auth routes.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
36
|
-
*
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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;
|