@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/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @vc1023/passkey-2fa — required environment variables.
|
|
2
|
+
# Copy the ones you need into your app's .env.local (and your host's env for
|
|
3
|
+
# production). Run `npx passkey-2fa check-env` to verify them.
|
|
4
|
+
|
|
5
|
+
# ─── Supabase (Settings → API) ───
|
|
6
|
+
NEXT_PUBLIC_SUPABASE_URL= # Project URL, e.g. https://abcd.supabase.co
|
|
7
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY= # anon / publishable key
|
|
8
|
+
SUPABASE_SERVICE_ROLE_KEY= # service_role / secret key — SERVER ONLY
|
|
9
|
+
|
|
10
|
+
# ─── WebAuthn / passkey (your app's domain) ───
|
|
11
|
+
# In production these are REQUIRED and validated (origin must be https, and
|
|
12
|
+
# WEBAUTHN_RP_ID must equal the origin host). In local dev they default to
|
|
13
|
+
# http://localhost:3000 / localhost.
|
|
14
|
+
WEBAUTHN_ORIGIN= # e.g. https://yourapp.com
|
|
15
|
+
WEBAUTHN_RP_ID= # e.g. yourapp.com (registrable domain, no scheme/port)
|
|
16
|
+
WEBAUTHN_RP_NAME= # display name shown in the passkey prompt, e.g. "Your App"
|
|
17
|
+
|
|
18
|
+
# ─── AAL2 session token ───
|
|
19
|
+
AUTH_MFA_SECRET= # HMAC secret for the 2FA session cookie — `openssl rand -hex 32`
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @vc1023/passkey-2fa
|
|
2
|
+
|
|
3
|
+
Drop-in **password + passkey (WebAuthn) 2FA** for **Next.js App Router + Supabase**.
|
|
4
|
+
|
|
5
|
+
- Email + password = first factor (Supabase Auth, AAL1)
|
|
6
|
+
- A **passkey** = mandatory second factor (custom WebAuthn, AAL2), enforced server-side
|
|
7
|
+
- Single-use expiring challenges · replay-protected counter · session-bound AAL2 cookie · per-route rate limiting · fail-loud config
|
|
8
|
+
|
|
9
|
+
It ships server route-handler factories, an Edge middleware factory, browser helpers, and the SQL migration. Audit/analytics stay yours via an `onEvent` hook.
|
|
10
|
+
|
|
11
|
+
## 1. Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @vc1023/passkey-2fa
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Add it to `transpilePackages` (it ships TypeScript source):
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// next.config.ts
|
|
21
|
+
const nextConfig = { transpilePackages: ["@vc1023/passkey-2fa"] };
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 2. Environment
|
|
25
|
+
|
|
26
|
+
Copy `node_modules/@vc1023/passkey-2fa/.env.example` into `.env.local` and fill it. Then verify:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx passkey-2fa check-env
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Var | Where |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` | Supabase → Settings → API |
|
|
35
|
+
| `WEBAUTHN_ORIGIN` (`https://yourapp.com`) · `WEBAUTHN_RP_ID` (`yourapp.com`) · `WEBAUTHN_RP_NAME` | your app's domain |
|
|
36
|
+
| `AUTH_MFA_SECRET` | `openssl rand -hex 32` |
|
|
37
|
+
|
|
38
|
+
In **production** these are required and validated (origin must be https; RP-ID must equal the origin host) — the app fails loud if any is missing. In dev they default to `localhost`.
|
|
39
|
+
|
|
40
|
+
> **Supabase setting:** disable email confirmation (Auth → Email) so the user is signed in immediately and can enroll a passkey in the same sign-up flow.
|
|
41
|
+
|
|
42
|
+
## 3. Database
|
|
43
|
+
|
|
44
|
+
Apply the migration to your Supabase project (SQL editor or `supabase db push`):
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
node_modules/@vc1023/passkey-2fa/migrations/0001_passkey_tables.sql
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 4. Mount the route handlers
|
|
51
|
+
|
|
52
|
+
Create one file per endpoint under `app/api/auth/…`, all delegating to a shared instance:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// app/lib/auth.ts
|
|
56
|
+
import { createPasskeyAuthHandlers } from "@vc1023/passkey-2fa/routes";
|
|
57
|
+
|
|
58
|
+
export const handlers = createPasskeyAuthHandlers({
|
|
59
|
+
// optional: audit / analytics / funnel — never required
|
|
60
|
+
onEvent: async (e) => { /* e.type: "signup" | "signin_success" | "mfa_enrolled" | … */ },
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// app/api/auth/sign-up/route.ts
|
|
66
|
+
import { handlers } from "@/app/lib/auth";
|
|
67
|
+
export const runtime = "nodejs";
|
|
68
|
+
export const POST = handlers.signUp;
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Repeat for: `sign-in` → `handlers.signIn`, `sign-out` → `handlers.signOut`,
|
|
72
|
+
`webauthn/register/options` → `handlers.registerOptions`, `webauthn/register/verify` → `handlers.registerVerify`,
|
|
73
|
+
`webauthn/authenticate/options` → `handlers.authenticateOptions`, `webauthn/authenticate/verify` → `handlers.authenticateVerify`.
|
|
74
|
+
|
|
75
|
+
## 5. Middleware
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// middleware.ts
|
|
79
|
+
import { createPasskeyMiddleware } from "@vc1023/passkey-2fa/middleware";
|
|
80
|
+
|
|
81
|
+
export const middleware = createPasskeyMiddleware({ protectedPaths: ["/dashboard"] });
|
|
82
|
+
export const config = {
|
|
83
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"],
|
|
84
|
+
};
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 6. Protect a page (server-side AAL2 gate)
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
// app/dashboard/page.tsx
|
|
91
|
+
import { requireAal2, getSessionUser } from "@vc1023/passkey-2fa";
|
|
92
|
+
export const dynamic = "force-dynamic";
|
|
93
|
+
|
|
94
|
+
export default async function Dashboard() {
|
|
95
|
+
await requireAal2(); // redirects to /sign-in unless fully AAL2
|
|
96
|
+
const user = await getSessionUser();
|
|
97
|
+
return <p>Signed in as {user?.email}</p>;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 7. Build your UI with the client helpers
|
|
102
|
+
|
|
103
|
+
You own the screens/copy; the package gives the network + ceremony:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
"use client";
|
|
107
|
+
import {
|
|
108
|
+
signUp, enrollPasskey, signIn, challengePasskey, signOut, browserSupportsPasskeys,
|
|
109
|
+
} from "@vc1023/passkey-2fa/client";
|
|
110
|
+
|
|
111
|
+
// sign-up: await signUp(email, password) → if ok, await enrollPasskey()
|
|
112
|
+
// sign-in: await signIn(email, password) → if ok, await challengePasskey()
|
|
113
|
+
// each ceremony returns { ok:true } | { ok:false, reason:"cancelled"|"unsupported"|"error" }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Notes
|
|
117
|
+
- 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.
|
|
119
|
+
- Server validation is enforced; you may also `signUpSchema.safeParse()` client-side for instant feedback.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npx passkey-2fa check-env` — verify the env @vc1023/passkey-2fa needs.
|
|
3
|
+
// Reads the project's .env.local (if present) merged over process.env, then
|
|
4
|
+
// reports which required vars are set/missing. Exit 1 if any required is missing.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
const REQUIRED = [
|
|
10
|
+
"NEXT_PUBLIC_SUPABASE_URL",
|
|
11
|
+
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
12
|
+
"SUPABASE_SERVICE_ROLE_KEY",
|
|
13
|
+
"WEBAUTHN_ORIGIN",
|
|
14
|
+
"WEBAUTHN_RP_ID",
|
|
15
|
+
"AUTH_MFA_SECRET",
|
|
16
|
+
];
|
|
17
|
+
const OPTIONAL = ["WEBAUTHN_RP_NAME", "NEXT_PUBLIC_APP_URL"];
|
|
18
|
+
|
|
19
|
+
function loadEnvLocal() {
|
|
20
|
+
const out = {};
|
|
21
|
+
const file = resolve(process.cwd(), ".env.local");
|
|
22
|
+
if (!existsSync(file)) return out;
|
|
23
|
+
for (const raw of readFileSync(file, "utf8").split("\n")) {
|
|
24
|
+
const line = raw.trim();
|
|
25
|
+
if (!line || line.startsWith("#")) continue;
|
|
26
|
+
const eq = line.indexOf("=");
|
|
27
|
+
if (eq <= 0) continue;
|
|
28
|
+
out[line.slice(0, eq).trim()] = line
|
|
29
|
+
.slice(eq + 1)
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/\r$/, "");
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const env = { ...loadEnvLocal(), ...process.env };
|
|
37
|
+
const isSet = (k) => typeof env[k] === "string" && env[k].length > 0;
|
|
38
|
+
|
|
39
|
+
console.log("\n@vc1023/passkey-2fa — env check\n");
|
|
40
|
+
let missing = 0;
|
|
41
|
+
for (const k of REQUIRED) {
|
|
42
|
+
const ok = isSet(k);
|
|
43
|
+
if (!ok) missing++;
|
|
44
|
+
console.log(` ${ok ? "✓" : "✗"} ${k}${ok ? "" : " (required, missing)"}`);
|
|
45
|
+
}
|
|
46
|
+
for (const k of OPTIONAL) {
|
|
47
|
+
console.log(` ${isSet(k) ? "✓" : "·"} ${k}${isSet(k) ? "" : " (optional)"}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (missing > 0) {
|
|
51
|
+
console.error(
|
|
52
|
+
`\n${missing} required variable(s) missing. Copy .env.example, fill them, and re-run.` +
|
|
53
|
+
`\nSee node_modules/@vc1023/passkey-2fa/.env.example\n`,
|
|
54
|
+
);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
console.log("\nAll required env present.\n");
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- @vc1023/passkey-2fa — apply this to your Supabase project (SQL editor
|
|
2
|
+
-- or `supabase db push`). Creates the two tables the passkey 2FA layer needs,
|
|
3
|
+
-- with default-deny RLS keyed on auth.uid(). Audit/analytics tables are NOT
|
|
4
|
+
-- created here — wire those via the route handlers' onEvent hook in your app.
|
|
5
|
+
|
|
6
|
+
create extension if not exists pgcrypto;
|
|
7
|
+
|
|
8
|
+
-- updated_at trigger helper (idempotent).
|
|
9
|
+
create or replace function set_updated_at()
|
|
10
|
+
returns trigger language plpgsql as $$
|
|
11
|
+
begin
|
|
12
|
+
new.updated_at = now();
|
|
13
|
+
return new;
|
|
14
|
+
end;
|
|
15
|
+
$$;
|
|
16
|
+
|
|
17
|
+
-- ─── webauthn_credentials: a user's registered passkeys (the 2nd factor) ───
|
|
18
|
+
create table if not exists webauthn_credentials (
|
|
19
|
+
id uuid primary key default gen_random_uuid(),
|
|
20
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
21
|
+
credential_id text not null unique,
|
|
22
|
+
public_key text not null,
|
|
23
|
+
counter bigint not null default 0,
|
|
24
|
+
transports text[],
|
|
25
|
+
device_type text,
|
|
26
|
+
backed_up boolean not null default false,
|
|
27
|
+
aaguid text,
|
|
28
|
+
created_at timestamptz not null default now(),
|
|
29
|
+
updated_at timestamptz not null default now()
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
create index if not exists idx_webauthn_credentials_user on webauthn_credentials (user_id);
|
|
33
|
+
|
|
34
|
+
drop trigger if exists trg_webauthn_credentials_updated_at on webauthn_credentials;
|
|
35
|
+
create trigger trg_webauthn_credentials_updated_at
|
|
36
|
+
before update on webauthn_credentials
|
|
37
|
+
for each row execute function set_updated_at();
|
|
38
|
+
|
|
39
|
+
alter table webauthn_credentials enable row level security;
|
|
40
|
+
|
|
41
|
+
drop policy if exists webauthn_credentials_select_own on webauthn_credentials;
|
|
42
|
+
drop policy if exists webauthn_credentials_insert_own on webauthn_credentials;
|
|
43
|
+
drop policy if exists webauthn_credentials_delete_own on webauthn_credentials;
|
|
44
|
+
create policy webauthn_credentials_select_own on webauthn_credentials
|
|
45
|
+
for select using (auth.uid() = user_id);
|
|
46
|
+
create policy webauthn_credentials_insert_own on webauthn_credentials
|
|
47
|
+
for insert with check (auth.uid() = user_id);
|
|
48
|
+
create policy webauthn_credentials_delete_own on webauthn_credentials
|
|
49
|
+
for delete using (auth.uid() = user_id);
|
|
50
|
+
|
|
51
|
+
-- ─── webauthn_challenges: single-use server-issued ceremony nonces ───
|
|
52
|
+
create table if not exists webauthn_challenges (
|
|
53
|
+
id uuid primary key default gen_random_uuid(),
|
|
54
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
55
|
+
challenge text not null,
|
|
56
|
+
type text not null check (type in ('registration', 'authentication')),
|
|
57
|
+
expires_at timestamptz not null,
|
|
58
|
+
created_at timestamptz not null default now()
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
create index if not exists idx_webauthn_challenges_user_type on webauthn_challenges (user_id, type);
|
|
62
|
+
|
|
63
|
+
-- Default-deny: RLS on, NO policies. Only the service role (server) touches it.
|
|
64
|
+
alter table webauthn_challenges enable row level security;
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vc1023/passkey-2fa",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drop-in password + passkey (WebAuthn) 2FA for Next.js App Router + Supabase.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./routes": "./src/routes.ts",
|
|
16
|
+
"./middleware": "./src/middleware.ts",
|
|
17
|
+
"./client": "./src/client.ts",
|
|
18
|
+
"./migrations/*": "./migrations/*"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"passkey-2fa": "./bin/check-env.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"migrations",
|
|
26
|
+
"bin",
|
|
27
|
+
".env.example",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"passkey",
|
|
32
|
+
"webauthn",
|
|
33
|
+
"2fa",
|
|
34
|
+
"mfa",
|
|
35
|
+
"supabase",
|
|
36
|
+
"next",
|
|
37
|
+
"auth"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@simplewebauthn/browser": "^13.1.0",
|
|
41
|
+
"@simplewebauthn/server": "^13.1.1",
|
|
42
|
+
"@supabase/ssr": "^0.5.2",
|
|
43
|
+
"@supabase/supabase-js": "^2.45.4",
|
|
44
|
+
"zod": "^3.23.8"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"next": ">=15",
|
|
48
|
+
"react": ">=19",
|
|
49
|
+
"react-dom": ">=19"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/aal2.test.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { signAal2Token, verifyAal2Token } from "./aal2";
|
|
3
|
+
|
|
4
|
+
const SECRET = "test-secret";
|
|
5
|
+
const USER = "11111111-1111-1111-1111-111111111111";
|
|
6
|
+
const SID = "sess-abc";
|
|
7
|
+
const NOW = 1_700_000_000;
|
|
8
|
+
|
|
9
|
+
describe("AAL2 token (session guard)", () => {
|
|
10
|
+
it("round-trips a valid token with sub + sid", () => {
|
|
11
|
+
const token = signAal2Token(USER, SID, SECRET, 3600, NOW);
|
|
12
|
+
expect(verifyAal2Token(token, SECRET, NOW + 10)).toEqual({ sub: USER, sid: SID });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects an expired token", () => {
|
|
16
|
+
const token = signAal2Token(USER, SID, SECRET, 60, NOW);
|
|
17
|
+
expect(verifyAal2Token(token, SECRET, NOW + 120)).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects a wrong secret", () => {
|
|
21
|
+
const token = signAal2Token(USER, SID, SECRET, 3600, NOW);
|
|
22
|
+
expect(verifyAal2Token(token, "other-secret", NOW + 10)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects a tampered payload", () => {
|
|
26
|
+
const token = signAal2Token(USER, SID, SECRET, 3600, NOW);
|
|
27
|
+
const [, sig] = token.split(".");
|
|
28
|
+
const forged = `${Buffer.from(JSON.stringify({ sub: "attacker", sid: SID, exp: NOW + 3600 })).toString("base64url")}.${sig}`;
|
|
29
|
+
expect(verifyAal2Token(forged, SECRET, NOW + 10)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects malformed / empty tokens", () => {
|
|
33
|
+
expect(verifyAal2Token(undefined, SECRET, NOW)).toBeNull();
|
|
34
|
+
expect(verifyAal2Token("", SECRET, NOW)).toBeNull();
|
|
35
|
+
expect(verifyAal2Token("no-dot", SECRET, NOW)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/aal2.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// AAL2 session marker. After a passkey ceremony verifies server-side, the app
|
|
4
|
+
// issues a short-lived HMAC-signed token in an httpOnly cookie. The guard
|
|
5
|
+
// requires BOTH a valid Supabase (AAL1) session AND a valid AAL2 token whose
|
|
6
|
+
// `sub` matches the user (and, when present, whose `sid` matches the live
|
|
7
|
+
// Supabase session) before granting app access. This is NOT an API bearer
|
|
8
|
+
// credential — it only attests "this session completed the second factor".
|
|
9
|
+
|
|
10
|
+
export const AAL2_COOKIE = "wlt_mfa";
|
|
11
|
+
export const AAL2_TTL_SECONDS = 60 * 60; // 1h; re-challenge after.
|
|
12
|
+
|
|
13
|
+
function b64url(input: Buffer | string): string {
|
|
14
|
+
return Buffer.from(input).toString("base64url");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Aal2Claims {
|
|
18
|
+
sub: string; // user id
|
|
19
|
+
sid: string | null; // Supabase session id this AAL2 is bound to
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Aal2Payload extends Aal2Claims {
|
|
23
|
+
exp: number; // epoch seconds
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Mint a signed AAL2 token for `userId`, bound to the Supabase session `sid`. */
|
|
27
|
+
export function signAal2Token(
|
|
28
|
+
userId: string,
|
|
29
|
+
sid: string | null,
|
|
30
|
+
secret: string,
|
|
31
|
+
ttlSeconds: number = AAL2_TTL_SECONDS,
|
|
32
|
+
nowSeconds: number = Math.floor(Date.now() / 1000),
|
|
33
|
+
): string {
|
|
34
|
+
const payload: Aal2Payload = { sub: userId, sid, exp: nowSeconds + ttlSeconds };
|
|
35
|
+
const body = b64url(JSON.stringify(payload));
|
|
36
|
+
const sig = createHmac("sha256", secret).update(body).digest("base64url");
|
|
37
|
+
return `${body}.${sig}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Verify an AAL2 token. Returns its claims (`sub`, `sid`) if the signature is
|
|
42
|
+
* valid (timing-safe) and unexpired; otherwise null. Never throws. Callers must
|
|
43
|
+
* still check `sub` against the signed-in user and `sid` against the live session.
|
|
44
|
+
*/
|
|
45
|
+
export function verifyAal2Token(
|
|
46
|
+
token: string | undefined | null,
|
|
47
|
+
secret: string,
|
|
48
|
+
nowSeconds: number = Math.floor(Date.now() / 1000),
|
|
49
|
+
): Aal2Claims | null {
|
|
50
|
+
if (!token) return null;
|
|
51
|
+
const dot = token.indexOf(".");
|
|
52
|
+
if (dot <= 0) return null;
|
|
53
|
+
const body = token.slice(0, dot);
|
|
54
|
+
const sig = token.slice(dot + 1);
|
|
55
|
+
const expected = createHmac("sha256", secret).update(body).digest("base64url");
|
|
56
|
+
const sigBuf = Buffer.from(sig);
|
|
57
|
+
const expBuf = Buffer.from(expected);
|
|
58
|
+
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return null;
|
|
59
|
+
try {
|
|
60
|
+
const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as Aal2Payload;
|
|
61
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number") return null;
|
|
62
|
+
if (payload.exp < nowSeconds) return null;
|
|
63
|
+
return { sub: payload.sub, sid: payload.sid ?? null };
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Client-side POST helpers used by ./client. `postJSON` discriminates network
|
|
2
|
+
// vs server failures; `postForOptions` returns raw JSON for the WebAuthn options
|
|
3
|
+
// endpoints (which return the ceremony options object directly).
|
|
4
|
+
|
|
5
|
+
export type ApiErrorCode =
|
|
6
|
+
| "validation_email"
|
|
7
|
+
| "validation_password"
|
|
8
|
+
| "invalid_credentials"
|
|
9
|
+
| "email_confirmation_required"
|
|
10
|
+
| "network"
|
|
11
|
+
| "server"
|
|
12
|
+
| "unknown"
|
|
13
|
+
| "verify"
|
|
14
|
+
| "rate_limited";
|
|
15
|
+
|
|
16
|
+
export interface ApiResult {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
error?: ApiErrorCode;
|
|
19
|
+
data?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function postJSON(url: string, body?: unknown): Promise<ApiResult> {
|
|
23
|
+
let res: Response;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetch(url, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json" },
|
|
28
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
return { ok: false, error: "network" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let data: Record<string, unknown> = {};
|
|
35
|
+
try {
|
|
36
|
+
data = (await res.json()) as Record<string, unknown>;
|
|
37
|
+
} catch {
|
|
38
|
+
// non-JSON
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (res.ok && data.ok) return { ok: true, data };
|
|
42
|
+
return { ok: false, error: (data.error as ApiErrorCode) ?? "server" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** POST returning the raw parsed JSON body on a 2xx, or null on failure. Used for
|
|
46
|
+
* the WebAuthn options endpoints (no `{ ok: true }` envelope). */
|
|
47
|
+
export async function postForOptions(url: string): Promise<Record<string, unknown> | null> {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(url, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) return null;
|
|
54
|
+
return (await res.json()) as Record<string, unknown>;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Browser helpers. They assume the route handlers are mounted under /api/auth
|
|
4
|
+
// (the documented convention). signUp/signIn/signOut wrap the credential
|
|
5
|
+
// endpoints; enrollPasskey/challengePasskey run the full WebAuthn ceremony +
|
|
6
|
+
// verify and return a UI-friendly result.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
browserSupportsWebAuthn,
|
|
10
|
+
startAuthentication,
|
|
11
|
+
startRegistration,
|
|
12
|
+
type PublicKeyCredentialCreationOptionsJSON,
|
|
13
|
+
type PublicKeyCredentialRequestOptionsJSON,
|
|
14
|
+
} from "@simplewebauthn/browser";
|
|
15
|
+
import { postForOptions, postJSON, type ApiResult } from "./api-client";
|
|
16
|
+
|
|
17
|
+
const BASE = "/api/auth";
|
|
18
|
+
|
|
19
|
+
export type { ApiResult, ApiErrorCode } from "./api-client";
|
|
20
|
+
|
|
21
|
+
// Isomorphic validation re-exported here so client components get it without
|
|
22
|
+
// importing the server barrel (which pulls node:crypto + next/headers).
|
|
23
|
+
export {
|
|
24
|
+
signUpSchema,
|
|
25
|
+
signInSchema,
|
|
26
|
+
passwordStrength,
|
|
27
|
+
PASSWORD_MIN,
|
|
28
|
+
type SignUpInput,
|
|
29
|
+
type SignInInput,
|
|
30
|
+
} from "./validation";
|
|
31
|
+
|
|
32
|
+
export type CeremonyResult = { ok: true } | { ok: false; reason: "cancelled" | "unsupported" | "error" };
|
|
33
|
+
|
|
34
|
+
/** True if this browser can do WebAuthn / passkeys. */
|
|
35
|
+
export function browserSupportsPasskeys(): boolean {
|
|
36
|
+
return browserSupportsWebAuthn();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function signUp(email: string, password: string): Promise<ApiResult> {
|
|
40
|
+
return postJSON(`${BASE}/sign-up`, { email, password });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function signIn(email: string, password: string): Promise<ApiResult> {
|
|
44
|
+
return postJSON(`${BASE}/sign-in`, { email, password });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function signOut(): Promise<ApiResult> {
|
|
48
|
+
return postJSON(`${BASE}/sign-out`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Enroll a passkey (sign-up step 2). Runs the registration ceremony + verify. */
|
|
52
|
+
export async function enrollPasskey(): Promise<CeremonyResult> {
|
|
53
|
+
const options = await postForOptions(`${BASE}/webauthn/register/options`);
|
|
54
|
+
if (!options) return { ok: false, reason: "error" };
|
|
55
|
+
let attResp;
|
|
56
|
+
try {
|
|
57
|
+
attResp = await startRegistration({
|
|
58
|
+
optionsJSON: options as unknown as PublicKeyCredentialCreationOptionsJSON,
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const name = (err as Error)?.name;
|
|
62
|
+
if (name === "NotAllowedError" || name === "AbortError") return { ok: false, reason: "cancelled" };
|
|
63
|
+
if (name === "NotSupportedError") return { ok: false, reason: "unsupported" };
|
|
64
|
+
return { ok: false, reason: "error" };
|
|
65
|
+
}
|
|
66
|
+
const verify = await postJSON(`${BASE}/webauthn/register/verify`, { response: attResp });
|
|
67
|
+
return verify.ok ? { ok: true } : { ok: false, reason: "error" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Run the passkey challenge (sign-in step 2). */
|
|
71
|
+
export async function challengePasskey(): Promise<CeremonyResult> {
|
|
72
|
+
const options = await postForOptions(`${BASE}/webauthn/authenticate/options`);
|
|
73
|
+
if (!options) return { ok: false, reason: "error" };
|
|
74
|
+
let authResp;
|
|
75
|
+
try {
|
|
76
|
+
authResp = await startAuthentication({
|
|
77
|
+
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const name = (err as Error)?.name;
|
|
81
|
+
if (name === "NotAllowedError" || name === "AbortError") return { ok: false, reason: "cancelled" };
|
|
82
|
+
return { ok: false, reason: "error" };
|
|
83
|
+
}
|
|
84
|
+
const verify = await postJSON(`${BASE}/webauthn/authenticate/verify`, { response: authResp });
|
|
85
|
+
return verify.ok ? { ok: true } : { ok: false, reason: "error" };
|
|
86
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// WebAuthn + AAL2 configuration, read from env (env-only convention). Dev
|
|
2
|
+
// defaults are baked in; in production a missing/invalid value throws at use
|
|
3
|
+
// (fail-loud) rather than shipping a localhost RP-ID or an insecure origin.
|
|
4
|
+
|
|
5
|
+
function isProd(): boolean {
|
|
6
|
+
return process.env.VERCEL_ENV === "production" || process.env.NODE_ENV === "production";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fromEnvOrDevDefault(name: string, devDefault: string): string {
|
|
10
|
+
const value = process.env[name];
|
|
11
|
+
if (value && value.length > 0) return value;
|
|
12
|
+
if (isProd()) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`[@vc1023/passkey-2fa] Missing required production env: ${name}. ` +
|
|
15
|
+
`A dev default (${devDefault}) is only used outside production.`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return devDefault;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Expected origin of the WebAuthn ceremony (scheme + host + port). https in prod. */
|
|
22
|
+
export function expectedOrigin(): string {
|
|
23
|
+
const origin = fromEnvOrDevDefault("WEBAUTHN_ORIGIN", "http://localhost:3000");
|
|
24
|
+
if (isProd() && !origin.startsWith("https://")) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`[@vc1023/passkey-2fa] WEBAUTHN_ORIGIN must be https:// in production (got "${origin}").`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return origin;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** WebAuthn Relying Party ID — registrable domain (no scheme/port). In prod it
|
|
33
|
+
* MUST equal the WEBAUTHN_ORIGIN host. */
|
|
34
|
+
export function rpID(): string {
|
|
35
|
+
const id = fromEnvOrDevDefault("WEBAUTHN_RP_ID", "localhost");
|
|
36
|
+
if (isProd()) {
|
|
37
|
+
let host = "";
|
|
38
|
+
try {
|
|
39
|
+
host = new URL(expectedOrigin()).hostname;
|
|
40
|
+
} catch {
|
|
41
|
+
host = "";
|
|
42
|
+
}
|
|
43
|
+
if (id !== host) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`[@vc1023/passkey-2fa] WEBAUTHN_RP_ID ("${id}") must equal the WEBAUTHN_ORIGIN host ("${host}") in production.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function rpName(): string {
|
|
53
|
+
return process.env.WEBAUTHN_RP_NAME || "Passkey 2FA";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function appUrl(): string {
|
|
57
|
+
return fromEnvOrDevDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3000");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** HMAC secret for the AAL2 session token. MUST be set in production. */
|
|
61
|
+
export function mfaSecret(): string {
|
|
62
|
+
return fromEnvOrDevDefault("AUTH_MFA_SECRET", "dev-insecure-mfa-secret-do-not-use-in-prod");
|
|
63
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Env access with fail-loud semantics — a missing required value throws at call
|
|
2
|
+
// time with a clear, named message rather than letting the app boot broken.
|
|
3
|
+
|
|
4
|
+
export function requireEnv(name: string, value: string | undefined): string {
|
|
5
|
+
if (!value || value.length === 0) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
`[@vc1023/passkey-2fa] Missing required environment variable: ${name}. ` +
|
|
8
|
+
`Set it in .env.local (local) and your host's env (deployed). ` +
|
|
9
|
+
`Run \`npx passkey-2fa check-env\` to see everything that's required.`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SUPABASE_URL = () =>
|
|
16
|
+
requireEnv("NEXT_PUBLIC_SUPABASE_URL", process.env.NEXT_PUBLIC_SUPABASE_URL);
|
|
17
|
+
export const SUPABASE_ANON_KEY = () =>
|
|
18
|
+
requireEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY", process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
|
|
19
|
+
export const SUPABASE_SERVICE_ROLE_KEY = () =>
|
|
20
|
+
requireEnv("SUPABASE_SERVICE_ROLE_KEY", process.env.SUPABASE_SERVICE_ROLE_KEY);
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Auth lifecycle events emitted by the route handlers. Wire an `onEvent` handler
|
|
2
|
+
// (see ./routes) to plug in your own audit log, analytics, or funnel — the
|
|
3
|
+
// package stays free of any app-specific observability contract.
|
|
4
|
+
|
|
5
|
+
export type AuthEvent =
|
|
6
|
+
| { type: "signup"; userId: string }
|
|
7
|
+
| { type: "signin_failure" }
|
|
8
|
+
| { type: "signin_success"; userId: string }
|
|
9
|
+
| { type: "mfa_enroll_started"; userId: string }
|
|
10
|
+
| { type: "mfa_enrolled"; userId: string }
|
|
11
|
+
| { type: "mfa_challenge_failure"; userId: string }
|
|
12
|
+
| { type: "signout"; userId: string };
|
|
13
|
+
|
|
14
|
+
export type OnAuthEvent = (event: AuthEvent) => void | Promise<void>;
|