@vc1023/passkey-2fa 0.1.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/.env.example +19 -0
- package/README.md +119 -0
- package/bin/check-env.mjs +57 -0
- package/migrations/0001_passkey_tables.sql +64 -0
- package/package.json +51 -0
- package/src/aal2.test.ts +37 -0
- package/src/aal2.ts +67 -0
- package/src/api-client.ts +58 -0
- package/src/client.ts +86 -0
- package/src/config.ts +63 -0
- package/src/env.ts +20 -0
- package/src/events.ts +14 -0
- package/src/guard.ts +75 -0
- package/src/index.ts +20 -0
- package/src/middleware.ts +55 -0
- package/src/rate-limit.test.ts +20 -0
- package/src/rate-limit.ts +48 -0
- package/src/routes.ts +182 -0
- package/src/supabase.ts +40 -0
- package/src/types.ts +26 -0
- package/src/validation.test.ts +36 -0
- package/src/validation.ts +36 -0
- package/src/webauthn.ts +186 -0
package/src/guard.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Server-side AAL2 enforcement. Used by protected Server Components + the route
|
|
2
|
+
// handlers. Runs in the Node runtime (node:crypto via ./aal2).
|
|
3
|
+
|
|
4
|
+
import { cookies } from "next/headers";
|
|
5
|
+
import { redirect } from "next/navigation";
|
|
6
|
+
import { AAL2_COOKIE, AAL2_TTL_SECONDS, signAal2Token, verifyAal2Token } from "./aal2";
|
|
7
|
+
import { mfaSecret } from "./config";
|
|
8
|
+
import { createServerSupabase } from "./supabase";
|
|
9
|
+
|
|
10
|
+
/** The current AAL1 (email+password) user, or null. */
|
|
11
|
+
export async function getSessionUser() {
|
|
12
|
+
const supabase = await createServerSupabase();
|
|
13
|
+
const {
|
|
14
|
+
data: { user },
|
|
15
|
+
} = await supabase.auth.getUser();
|
|
16
|
+
return user;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The current Supabase session id (from the access-token `session_id` claim),
|
|
20
|
+
* or null. Stable across token refresh within a session. */
|
|
21
|
+
async function currentSessionId(): Promise<string | null> {
|
|
22
|
+
const supabase = await createServerSupabase();
|
|
23
|
+
const {
|
|
24
|
+
data: { session },
|
|
25
|
+
} = await supabase.auth.getSession();
|
|
26
|
+
const token = session?.access_token;
|
|
27
|
+
if (!token) return null;
|
|
28
|
+
try {
|
|
29
|
+
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
|
|
30
|
+
return typeof payload.session_id === "string" ? payload.session_id : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** User id IFF the session has AAL1 (Supabase) AND a valid AAL2 token whose
|
|
37
|
+
* `sub` matches the user AND whose bound `sid` matches the live session. */
|
|
38
|
+
export async function getAal2UserId(): Promise<string | null> {
|
|
39
|
+
const user = await getSessionUser();
|
|
40
|
+
if (!user) return null;
|
|
41
|
+
const store = await cookies();
|
|
42
|
+
const claims = verifyAal2Token(store.get(AAL2_COOKIE)?.value, mfaSecret());
|
|
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
|
+
}
|
|
48
|
+
return user.id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Redirect to `signInPath` unless the session is fully AAL2. Returns the user id. */
|
|
52
|
+
export async function requireAal2(signInPath = "/sign-in"): Promise<string> {
|
|
53
|
+
const id = await getAal2UserId();
|
|
54
|
+
if (!id) redirect(signInPath);
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Mint the AAL2 marker after a verified passkey ceremony, bound to the live
|
|
59
|
+
* Supabase session. Route-handler only. */
|
|
60
|
+
export async function setAal2Cookie(userId: string): Promise<void> {
|
|
61
|
+
const sid = await currentSessionId();
|
|
62
|
+
const store = await cookies();
|
|
63
|
+
store.set(AAL2_COOKIE, signAal2Token(userId, sid, mfaSecret()), {
|
|
64
|
+
httpOnly: true,
|
|
65
|
+
secure: process.env.NODE_ENV === "production",
|
|
66
|
+
sameSite: "strict",
|
|
67
|
+
path: "/",
|
|
68
|
+
maxAge: AAL2_TTL_SECONDS,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function clearAal2Cookie(): Promise<void> {
|
|
73
|
+
const store = await cookies();
|
|
74
|
+
store.delete(AAL2_COOKIE);
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @vc1023/passkey-2fa — server entry (Node runtime).
|
|
2
|
+
//
|
|
3
|
+
// Drop-in password + passkey (WebAuthn) 2FA for Next.js App Router + Supabase.
|
|
4
|
+
// - server toolkit: import { requireAal2, ... } from "@vc1023/passkey-2fa"
|
|
5
|
+
// - route handlers: import { createPasskeyAuthHandlers } from "@vc1023/passkey-2fa/routes"
|
|
6
|
+
// - middleware: import { createPasskeyMiddleware } from "@vc1023/passkey-2fa/middleware"
|
|
7
|
+
// - browser helpers: import { signUp, enrollPasskey, ... } from "@vc1023/passkey-2fa/client"
|
|
8
|
+
//
|
|
9
|
+
// This barrel uses node:crypto + next/headers — server-only. Import client code
|
|
10
|
+
// from "/client".
|
|
11
|
+
|
|
12
|
+
export * from "./config";
|
|
13
|
+
export * from "./aal2";
|
|
14
|
+
export * from "./validation";
|
|
15
|
+
export * from "./guard";
|
|
16
|
+
export * from "./webauthn";
|
|
17
|
+
export * from "./rate-limit";
|
|
18
|
+
export * from "./supabase";
|
|
19
|
+
export * from "./types";
|
|
20
|
+
export * from "./events";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Edge-safe Next.js middleware factory. Refreshes the Supabase (AAL1) session
|
|
2
|
+
// cookies so sessions survive reload + restart, and does a coarse redirect for
|
|
3
|
+
// unauthenticated hits on protected paths. The full AAL2 (second-factor) gate
|
|
4
|
+
// runs server-side in the protected page via `requireAal2()` — it needs
|
|
5
|
+
// node:crypto, unavailable in the Edge runtime, so it is NOT done here.
|
|
6
|
+
//
|
|
7
|
+
// IMPORTANT: only imports `@supabase/ssr` + `./env` (both Edge-safe). Do not add
|
|
8
|
+
// imports that pull in node:crypto.
|
|
9
|
+
|
|
10
|
+
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
|
11
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
12
|
+
import { SUPABASE_ANON_KEY, SUPABASE_URL } from "./env";
|
|
13
|
+
|
|
14
|
+
export interface PasskeyMiddlewareOptions {
|
|
15
|
+
/** Path prefixes that require an authenticated (AAL1) session. */
|
|
16
|
+
protectedPaths: string[];
|
|
17
|
+
/** Where to send unauthenticated users. Default `/sign-in`. */
|
|
18
|
+
signInPath?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createPasskeyMiddleware(opts: PasskeyMiddlewareOptions) {
|
|
22
|
+
const signInPath = opts.signInPath ?? "/sign-in";
|
|
23
|
+
|
|
24
|
+
return async function middleware(req: NextRequest) {
|
|
25
|
+
let res = NextResponse.next({ request: req });
|
|
26
|
+
|
|
27
|
+
const supabase = createServerClient(SUPABASE_URL(), SUPABASE_ANON_KEY(), {
|
|
28
|
+
cookies: {
|
|
29
|
+
getAll() {
|
|
30
|
+
return req.cookies.getAll();
|
|
31
|
+
},
|
|
32
|
+
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
|
|
33
|
+
for (const { name, value } of cookiesToSet) req.cookies.set(name, value);
|
|
34
|
+
res = NextResponse.next({ request: req });
|
|
35
|
+
for (const { name, value, options } of cookiesToSet)
|
|
36
|
+
res.cookies.set(name, value, options);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
data: { user },
|
|
43
|
+
} = await supabase.auth.getUser();
|
|
44
|
+
|
|
45
|
+
const path = req.nextUrl.pathname;
|
|
46
|
+
const isProtected = opts.protectedPaths.some((p) => path === p || path.startsWith(`${p}/`));
|
|
47
|
+
if (isProtected && !user) {
|
|
48
|
+
const url = req.nextUrl.clone();
|
|
49
|
+
url.pathname = signInPath;
|
|
50
|
+
return NextResponse.redirect(url);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return res;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { rateLimit } from "./rate-limit";
|
|
3
|
+
|
|
4
|
+
describe("rateLimit", () => {
|
|
5
|
+
it("allows up to the limit then blocks within the window", () => {
|
|
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);
|
|
9
|
+
expect(blocked.ok).toBe(false);
|
|
10
|
+
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("uses independent buckets per key", () => {
|
|
14
|
+
const a = `a-${Math.floor(Math.random() * 1e9)}`;
|
|
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
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Lightweight in-memory sliding-window rate limiter for the sensitive auth
|
|
2
|
+
// routes.
|
|
3
|
+
//
|
|
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).
|
|
8
|
+
|
|
9
|
+
interface Bucket {
|
|
10
|
+
count: number;
|
|
11
|
+
resetAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const buckets = new Map<string, Bucket>();
|
|
15
|
+
|
|
16
|
+
export interface RateLimitResult {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
retryAfterSeconds: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const b = buckets.get(key);
|
|
24
|
+
if (!b || b.resetAt <= now) {
|
|
25
|
+
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
26
|
+
return { ok: true, retryAfterSeconds: 0 };
|
|
27
|
+
}
|
|
28
|
+
if (b.count >= limit) {
|
|
29
|
+
return { ok: false, retryAfterSeconds: Math.ceil((b.resetAt - now) / 1000) };
|
|
30
|
+
}
|
|
31
|
+
b.count += 1;
|
|
32
|
+
return { ok: true, retryAfterSeconds: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
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. */
|
|
37
|
+
export function clientIp(req: Request): string {
|
|
38
|
+
const xff = req.headers.get("x-forwarded-for");
|
|
39
|
+
return xff?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Standard 429 JSON response for a tripped limit. */
|
|
43
|
+
export function tooManyRequests(retryAfterSeconds: number): Response {
|
|
44
|
+
return new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
|
|
45
|
+
status: 429,
|
|
46
|
+
headers: { "content-type": "application/json", "retry-after": String(retryAfterSeconds) },
|
|
47
|
+
});
|
|
48
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Next.js App Router route-handler factory. Mount these in your app's
|
|
2
|
+
// app/api/auth/**/route.ts files. Audit/analytics are emitted via the optional
|
|
3
|
+
// `onEvent` hook — the package itself stores no observability data.
|
|
4
|
+
|
|
5
|
+
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
|
|
6
|
+
import { signInSchema, signUpSchema } from "./validation";
|
|
7
|
+
import { createServerSupabase } from "./supabase";
|
|
8
|
+
import { clearAal2Cookie, getSessionUser, setAal2Cookie } from "./guard";
|
|
9
|
+
import {
|
|
10
|
+
createAuthenticationOptions,
|
|
11
|
+
createRegistrationOptions,
|
|
12
|
+
verifyAuthentication,
|
|
13
|
+
verifyRegistration,
|
|
14
|
+
} from "./webauthn";
|
|
15
|
+
import { clientIp, rateLimit, tooManyRequests } from "./rate-limit";
|
|
16
|
+
import type { OnAuthEvent } from "./events";
|
|
17
|
+
|
|
18
|
+
export type { AuthEvent, OnAuthEvent } from "./events";
|
|
19
|
+
|
|
20
|
+
function json(data: unknown, status = 200): Response {
|
|
21
|
+
return new Response(JSON.stringify(data), {
|
|
22
|
+
status,
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PasskeyAuthOptions {
|
|
28
|
+
/** Hook for audit/analytics/funnel. Errors here are swallowed (best-effort). */
|
|
29
|
+
onEvent?: OnAuthEvent;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PasskeyAuthHandlers {
|
|
33
|
+
signUp: (req: Request) => Promise<Response>;
|
|
34
|
+
signIn: (req: Request) => Promise<Response>;
|
|
35
|
+
signOut: (req: Request) => Promise<Response>;
|
|
36
|
+
registerOptions: (req: Request) => Promise<Response>;
|
|
37
|
+
registerVerify: (req: Request) => Promise<Response>;
|
|
38
|
+
authenticateOptions: (req: Request) => Promise<Response>;
|
|
39
|
+
authenticateVerify: (req: Request) => Promise<Response>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createPasskeyAuthHandlers(opts: PasskeyAuthOptions = {}): PasskeyAuthHandlers {
|
|
43
|
+
const emit: OnAuthEvent = async (e) => {
|
|
44
|
+
try {
|
|
45
|
+
await opts.onEvent?.(e);
|
|
46
|
+
} catch {
|
|
47
|
+
// best-effort: never let observability break auth
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
async signUp(req) {
|
|
53
|
+
const limit = rateLimit(`signup:${clientIp(req)}`, 5, 10 * 60 * 1000);
|
|
54
|
+
if (!limit.ok) return tooManyRequests(limit.retryAfterSeconds);
|
|
55
|
+
|
|
56
|
+
let body: unknown;
|
|
57
|
+
try {
|
|
58
|
+
body = await req.json();
|
|
59
|
+
} catch {
|
|
60
|
+
return json({ ok: false, error: "unknown" }, 400);
|
|
61
|
+
}
|
|
62
|
+
const parsed = signUpSchema.safeParse(body);
|
|
63
|
+
if (!parsed.success) {
|
|
64
|
+
const field = parsed.error.issues[0]?.path[0];
|
|
65
|
+
return json(
|
|
66
|
+
{ ok: false, error: field === "email" ? "validation_email" : "validation_password" },
|
|
67
|
+
400,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const supabase = await createServerSupabase();
|
|
71
|
+
const { data, error } = await supabase.auth.signUp({
|
|
72
|
+
email: parsed.data.email,
|
|
73
|
+
password: parsed.data.password,
|
|
74
|
+
});
|
|
75
|
+
if (error || !data.user) return json({ ok: false, error: "server" }, 400);
|
|
76
|
+
if (!data.session) {
|
|
77
|
+
return json({ ok: false, error: "email_confirmation_required" }, 400);
|
|
78
|
+
}
|
|
79
|
+
await emit({ type: "signup", userId: data.user.id });
|
|
80
|
+
return json({ ok: true });
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async signIn(req) {
|
|
84
|
+
const limit = rateLimit(`signin:${clientIp(req)}`, 10, 5 * 60 * 1000);
|
|
85
|
+
if (!limit.ok) return tooManyRequests(limit.retryAfterSeconds);
|
|
86
|
+
|
|
87
|
+
let body: unknown;
|
|
88
|
+
try {
|
|
89
|
+
body = await req.json();
|
|
90
|
+
} catch {
|
|
91
|
+
return json({ ok: false, error: "unknown" }, 400);
|
|
92
|
+
}
|
|
93
|
+
const parsed = signInSchema.safeParse(body);
|
|
94
|
+
if (!parsed.success) {
|
|
95
|
+
const field = parsed.error.issues[0]?.path[0];
|
|
96
|
+
return json(
|
|
97
|
+
{ ok: false, error: field === "email" ? "validation_email" : "validation_password" },
|
|
98
|
+
400,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const supabase = await createServerSupabase();
|
|
102
|
+
const { data, error } = await supabase.auth.signInWithPassword({
|
|
103
|
+
email: parsed.data.email,
|
|
104
|
+
password: parsed.data.password,
|
|
105
|
+
});
|
|
106
|
+
if (error || !data.user) {
|
|
107
|
+
await emit({ type: "signin_failure" });
|
|
108
|
+
return json({ ok: false, error: "invalid_credentials" }, 401);
|
|
109
|
+
}
|
|
110
|
+
// AAL1 only — not app-privileged until the passkey challenge succeeds.
|
|
111
|
+
return json({ ok: true });
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async signOut() {
|
|
115
|
+
const supabase = await createServerSupabase();
|
|
116
|
+
const {
|
|
117
|
+
data: { user },
|
|
118
|
+
} = await supabase.auth.getUser();
|
|
119
|
+
await clearAal2Cookie();
|
|
120
|
+
await supabase.auth.signOut();
|
|
121
|
+
if (user) await emit({ type: "signout", userId: user.id });
|
|
122
|
+
return json({ ok: true });
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async registerOptions() {
|
|
126
|
+
const user = await getSessionUser();
|
|
127
|
+
if (!user) return json({ ok: false, error: "server" }, 401);
|
|
128
|
+
const options = await createRegistrationOptions({ id: user.id, email: user.email ?? "" });
|
|
129
|
+
await emit({ type: "mfa_enroll_started", userId: user.id });
|
|
130
|
+
return json(options);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async registerVerify(req) {
|
|
134
|
+
const user = await getSessionUser();
|
|
135
|
+
if (!user) return json({ ok: false, error: "server" }, 401);
|
|
136
|
+
const limit = rateLimit(`mfa-reg:${user.id}`, 10, 5 * 60 * 1000);
|
|
137
|
+
if (!limit.ok) return tooManyRequests(limit.retryAfterSeconds);
|
|
138
|
+
|
|
139
|
+
let response: RegistrationResponseJSON;
|
|
140
|
+
try {
|
|
141
|
+
response = (await req.json()).response as RegistrationResponseJSON;
|
|
142
|
+
} catch {
|
|
143
|
+
return json({ ok: false, error: "unknown" }, 400);
|
|
144
|
+
}
|
|
145
|
+
const result = await verifyRegistration({ id: user.id, email: user.email ?? "" }, response);
|
|
146
|
+
if (!result.verified) return json({ ok: false, error: "verify" }, 400);
|
|
147
|
+
|
|
148
|
+
await setAal2Cookie(user.id);
|
|
149
|
+
await emit({ type: "mfa_enrolled", userId: user.id });
|
|
150
|
+
return json({ ok: true });
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async authenticateOptions() {
|
|
154
|
+
const user = await getSessionUser();
|
|
155
|
+
if (!user) return json({ ok: false, error: "server" }, 401);
|
|
156
|
+
const options = await createAuthenticationOptions({ id: user.id, email: user.email ?? "" });
|
|
157
|
+
return json(options);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async authenticateVerify(req) {
|
|
161
|
+
const user = await getSessionUser();
|
|
162
|
+
if (!user) return json({ ok: false, error: "server" }, 401);
|
|
163
|
+
const limit = rateLimit(`mfa-auth:${user.id}`, 10, 5 * 60 * 1000);
|
|
164
|
+
if (!limit.ok) return tooManyRequests(limit.retryAfterSeconds);
|
|
165
|
+
|
|
166
|
+
let response: AuthenticationResponseJSON;
|
|
167
|
+
try {
|
|
168
|
+
response = (await req.json()).response as AuthenticationResponseJSON;
|
|
169
|
+
} catch {
|
|
170
|
+
return json({ ok: false, error: "unknown" }, 400);
|
|
171
|
+
}
|
|
172
|
+
const result = await verifyAuthentication({ id: user.id, email: user.email ?? "" }, response);
|
|
173
|
+
if (!result.verified) {
|
|
174
|
+
await emit({ type: "mfa_challenge_failure", userId: user.id });
|
|
175
|
+
return json({ ok: false, error: "verify" }, 401);
|
|
176
|
+
}
|
|
177
|
+
await setAal2Cookie(user.id);
|
|
178
|
+
await emit({ type: "signin_success", userId: user.id });
|
|
179
|
+
return json({ ok: true });
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/supabase.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
|
2
|
+
import { createClient } from "@supabase/supabase-js";
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
import { SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL } from "./env";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Request-scoped server client bound to the Next cookie store (App Router).
|
|
8
|
+
* Carries the user's AAL1 session; RLS keys on auth.uid(). SSR-safe.
|
|
9
|
+
*/
|
|
10
|
+
export async function createServerSupabase() {
|
|
11
|
+
const cookieStore = await cookies();
|
|
12
|
+
return createServerClient(SUPABASE_URL(), SUPABASE_ANON_KEY(), {
|
|
13
|
+
cookies: {
|
|
14
|
+
getAll() {
|
|
15
|
+
return cookieStore.getAll();
|
|
16
|
+
},
|
|
17
|
+
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
|
|
18
|
+
try {
|
|
19
|
+
for (const { name, value, options } of cookiesToSet) {
|
|
20
|
+
cookieStore.set(name, value, options);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// setAll called from a Server Component — safe to ignore; the
|
|
24
|
+
// middleware refresh path owns cookie writes.
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Service-role client — bypasses RLS. SERVER-ONLY. Used strictly for
|
|
33
|
+
* server-controlled writes the user can't make under RLS (webauthn_challenges,
|
|
34
|
+
* credential counter updates). Never expose to the client.
|
|
35
|
+
*/
|
|
36
|
+
export function createServiceSupabase() {
|
|
37
|
+
return createClient(SUPABASE_URL(), SUPABASE_SERVICE_ROLE_KEY(), {
|
|
38
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
39
|
+
});
|
|
40
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Row types for the package's WebAuthn tables
|
|
2
|
+
// (migrations/0001_passkey_tables.sql). Hand-written; replace with generated
|
|
3
|
+
// Supabase types in your app if you prefer.
|
|
4
|
+
|
|
5
|
+
export interface WebAuthnCredentialRow {
|
|
6
|
+
id: string;
|
|
7
|
+
user_id: string;
|
|
8
|
+
credential_id: string; // base64url
|
|
9
|
+
public_key: string; // base64url COSE key
|
|
10
|
+
counter: number;
|
|
11
|
+
transports: string[] | null;
|
|
12
|
+
device_type: string | null;
|
|
13
|
+
backed_up: boolean;
|
|
14
|
+
aaguid: string | null;
|
|
15
|
+
created_at: string;
|
|
16
|
+
updated_at: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WebAuthnChallengeRow {
|
|
20
|
+
id: string;
|
|
21
|
+
user_id: string;
|
|
22
|
+
challenge: string;
|
|
23
|
+
type: "registration" | "authentication";
|
|
24
|
+
expires_at: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { passwordStrength, signInSchema, signUpSchema } from "./validation";
|
|
3
|
+
|
|
4
|
+
describe("signUpSchema", () => {
|
|
5
|
+
it("accepts a valid email + 12+ char password", () => {
|
|
6
|
+
expect(signUpSchema.safeParse({ email: "a@example.com", password: "a strong pass" }).success).toBe(
|
|
7
|
+
true,
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
it("rejects an invalid email", () => {
|
|
11
|
+
const r = signUpSchema.safeParse({ email: "nope", password: "a strong pass" });
|
|
12
|
+
expect(r.success).toBe(false);
|
|
13
|
+
if (!r.success) expect(r.error.issues[0]?.path[0]).toBe("email");
|
|
14
|
+
});
|
|
15
|
+
it("rejects a password under 12 chars", () => {
|
|
16
|
+
const r = signUpSchema.safeParse({ email: "a@example.com", password: "short" });
|
|
17
|
+
expect(r.success).toBe(false);
|
|
18
|
+
if (!r.success) expect(r.error.issues[0]?.path[0]).toBe("password");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("signInSchema", () => {
|
|
23
|
+
it("requires a non-empty password but not the 12-char rule", () => {
|
|
24
|
+
expect(signInSchema.safeParse({ email: "a@example.com", password: "x" }).success).toBe(true);
|
|
25
|
+
expect(signInSchema.safeParse({ email: "a@example.com", password: "" }).success).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("passwordStrength", () => {
|
|
30
|
+
it("scores too-short as 0", () => {
|
|
31
|
+
expect(passwordStrength("short").score).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
it("rewards length + passphrase spaces", () => {
|
|
34
|
+
expect(passwordStrength("correct horse battery staple").score).toBeGreaterThanOrEqual(3);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Password policy: length over complexity (a passphrase works well). ≥12 chars.
|
|
4
|
+
export const PASSWORD_MIN = 12;
|
|
5
|
+
|
|
6
|
+
export const emailSchema = z.string().trim().email();
|
|
7
|
+
export const passwordSchema = z.string().min(PASSWORD_MIN);
|
|
8
|
+
|
|
9
|
+
export const signUpSchema = z.object({
|
|
10
|
+
email: emailSchema,
|
|
11
|
+
password: passwordSchema,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const signInSchema = z.object({
|
|
15
|
+
email: emailSchema,
|
|
16
|
+
password: z.string().min(1),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type SignUpInput = z.infer<typeof signUpSchema>;
|
|
20
|
+
export type SignInInput = z.infer<typeof signInSchema>;
|
|
21
|
+
|
|
22
|
+
/** Password strength score 0–4 for a meter (provide a text equivalent in UI). */
|
|
23
|
+
export function passwordStrength(password: string): {
|
|
24
|
+
score: 0 | 1 | 2 | 3 | 4;
|
|
25
|
+
label: "Too short" | "Weak" | "Fair" | "Good" | "Strong";
|
|
26
|
+
} {
|
|
27
|
+
if (password.length < PASSWORD_MIN) return { score: 0, label: "Too short" };
|
|
28
|
+
let score = 1;
|
|
29
|
+
if (password.length >= 16) score++;
|
|
30
|
+
if (/\s/.test(password)) score++; // passphrase bonus (spaces)
|
|
31
|
+
if (/[^A-Za-z0-9]/.test(password) || (/[A-Za-z]/.test(password) && /[0-9]/.test(password)))
|
|
32
|
+
score++;
|
|
33
|
+
const clamped = Math.min(score, 4) as 0 | 1 | 2 | 3 | 4;
|
|
34
|
+
const label = (["Too short", "Weak", "Fair", "Good", "Strong"] as const)[clamped];
|
|
35
|
+
return { score: clamped, label };
|
|
36
|
+
}
|