@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/webauthn.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// WebAuthn passkey ceremonies — the second factor. All verification happens HERE,
|
|
2
|
+
// server-side. Challenges are single-use + expiring; the signature counter is
|
|
3
|
+
// persisted to resist replay. User verification (face/fingerprint/PIN) is
|
|
4
|
+
// required. Challenges + credential writes use the service-role client.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
generateAuthenticationOptions,
|
|
8
|
+
generateRegistrationOptions,
|
|
9
|
+
verifyAuthenticationResponse,
|
|
10
|
+
verifyRegistrationResponse,
|
|
11
|
+
type AuthenticationResponseJSON,
|
|
12
|
+
type RegistrationResponseJSON,
|
|
13
|
+
} from "@simplewebauthn/server";
|
|
14
|
+
import { expectedOrigin, rpID, rpName } from "./config";
|
|
15
|
+
import { createServiceSupabase } from "./supabase";
|
|
16
|
+
import type { WebAuthnCredentialRow } from "./types";
|
|
17
|
+
|
|
18
|
+
export interface AuthUser {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
24
|
+
type ChallengeType = "registration" | "authentication";
|
|
25
|
+
|
|
26
|
+
function b64urlToBytes(s: string) {
|
|
27
|
+
const b = Buffer.from(s, "base64url");
|
|
28
|
+
const out = new Uint8Array(b.byteLength);
|
|
29
|
+
out.set(b);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function bytesToB64url(b: Uint8Array): string {
|
|
33
|
+
return Buffer.from(b).toString("base64url");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function storeChallenge(userId: string, challenge: string, type: ChallengeType): Promise<void> {
|
|
37
|
+
const svc = createServiceSupabase();
|
|
38
|
+
await svc.from("webauthn_challenges").delete().eq("user_id", userId).eq("type", type);
|
|
39
|
+
await svc.from("webauthn_challenges").insert({
|
|
40
|
+
user_id: userId,
|
|
41
|
+
challenge,
|
|
42
|
+
type,
|
|
43
|
+
expires_at: new Date(Date.now() + CHALLENGE_TTL_MS).toISOString(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fetch + delete (single-use) the latest challenge; null if missing/expired. */
|
|
48
|
+
async function consumeChallenge(userId: string, type: ChallengeType): Promise<string | null> {
|
|
49
|
+
const svc = createServiceSupabase();
|
|
50
|
+
const { data } = await svc
|
|
51
|
+
.from("webauthn_challenges")
|
|
52
|
+
.select("challenge, expires_at")
|
|
53
|
+
.eq("user_id", userId)
|
|
54
|
+
.eq("type", type)
|
|
55
|
+
.order("created_at", { ascending: false })
|
|
56
|
+
.limit(1);
|
|
57
|
+
await svc.from("webauthn_challenges").delete().eq("user_id", userId).eq("type", type);
|
|
58
|
+
const row = data?.[0];
|
|
59
|
+
if (!row) return null;
|
|
60
|
+
if (new Date(row.expires_at).getTime() < Date.now()) return null;
|
|
61
|
+
return row.challenge;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function createRegistrationOptions(user: AuthUser) {
|
|
65
|
+
const svc = createServiceSupabase();
|
|
66
|
+
const { data: existing } = await svc
|
|
67
|
+
.from("webauthn_credentials")
|
|
68
|
+
.select("credential_id, transports")
|
|
69
|
+
.eq("user_id", user.id);
|
|
70
|
+
const options = await generateRegistrationOptions({
|
|
71
|
+
rpName: rpName(),
|
|
72
|
+
rpID: rpID(),
|
|
73
|
+
userName: user.email,
|
|
74
|
+
userID: new TextEncoder().encode(user.id),
|
|
75
|
+
attestationType: "none",
|
|
76
|
+
excludeCredentials: (existing ?? []).map((c) => ({
|
|
77
|
+
id: c.credential_id,
|
|
78
|
+
transports: (c.transports ?? undefined) as never,
|
|
79
|
+
})),
|
|
80
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
|
|
81
|
+
});
|
|
82
|
+
await storeChallenge(user.id, options.challenge, "registration");
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function verifyRegistration(
|
|
87
|
+
user: AuthUser,
|
|
88
|
+
response: RegistrationResponseJSON,
|
|
89
|
+
): Promise<{ verified: boolean; reason?: string }> {
|
|
90
|
+
const expectedChallenge = await consumeChallenge(user.id, "registration");
|
|
91
|
+
if (!expectedChallenge) return { verified: false, reason: "challenge" };
|
|
92
|
+
|
|
93
|
+
let verification;
|
|
94
|
+
try {
|
|
95
|
+
verification = await verifyRegistrationResponse({
|
|
96
|
+
response,
|
|
97
|
+
expectedChallenge,
|
|
98
|
+
expectedOrigin: expectedOrigin(),
|
|
99
|
+
expectedRPID: rpID(),
|
|
100
|
+
requireUserVerification: true,
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
return { verified: false, reason: "verify" };
|
|
104
|
+
}
|
|
105
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
106
|
+
return { verified: false, reason: "verify" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { credential, aaguid, credentialDeviceType, credentialBackedUp } =
|
|
110
|
+
verification.registrationInfo;
|
|
111
|
+
const svc = createServiceSupabase();
|
|
112
|
+
const { error } = await svc.from("webauthn_credentials").insert({
|
|
113
|
+
user_id: user.id,
|
|
114
|
+
credential_id: credential.id,
|
|
115
|
+
public_key: bytesToB64url(credential.publicKey),
|
|
116
|
+
counter: credential.counter,
|
|
117
|
+
transports: credential.transports ?? null,
|
|
118
|
+
device_type: credentialDeviceType,
|
|
119
|
+
backed_up: credentialBackedUp,
|
|
120
|
+
aaguid,
|
|
121
|
+
});
|
|
122
|
+
if (error) return { verified: false, reason: "store" };
|
|
123
|
+
return { verified: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function createAuthenticationOptions(user: AuthUser) {
|
|
127
|
+
const svc = createServiceSupabase();
|
|
128
|
+
const { data: creds } = await svc
|
|
129
|
+
.from("webauthn_credentials")
|
|
130
|
+
.select("credential_id, transports")
|
|
131
|
+
.eq("user_id", user.id);
|
|
132
|
+
const options = await generateAuthenticationOptions({
|
|
133
|
+
rpID: rpID(),
|
|
134
|
+
allowCredentials: (creds ?? []).map((c) => ({
|
|
135
|
+
id: c.credential_id,
|
|
136
|
+
transports: (c.transports ?? undefined) as never,
|
|
137
|
+
})),
|
|
138
|
+
userVerification: "required",
|
|
139
|
+
});
|
|
140
|
+
await storeChallenge(user.id, options.challenge, "authentication");
|
|
141
|
+
return options;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function verifyAuthentication(
|
|
145
|
+
user: AuthUser,
|
|
146
|
+
response: AuthenticationResponseJSON,
|
|
147
|
+
): Promise<{ verified: boolean; reason?: string }> {
|
|
148
|
+
const expectedChallenge = await consumeChallenge(user.id, "authentication");
|
|
149
|
+
if (!expectedChallenge) return { verified: false, reason: "challenge" };
|
|
150
|
+
|
|
151
|
+
const svc = createServiceSupabase();
|
|
152
|
+
const { data: rows } = await svc
|
|
153
|
+
.from("webauthn_credentials")
|
|
154
|
+
.select("*")
|
|
155
|
+
.eq("user_id", user.id)
|
|
156
|
+
.eq("credential_id", response.id)
|
|
157
|
+
.limit(1);
|
|
158
|
+
const cred = rows?.[0] as WebAuthnCredentialRow | undefined;
|
|
159
|
+
if (!cred) return { verified: false, reason: "unknown_credential" };
|
|
160
|
+
|
|
161
|
+
let verification;
|
|
162
|
+
try {
|
|
163
|
+
verification = await verifyAuthenticationResponse({
|
|
164
|
+
response,
|
|
165
|
+
expectedChallenge,
|
|
166
|
+
expectedOrigin: expectedOrigin(),
|
|
167
|
+
expectedRPID: rpID(),
|
|
168
|
+
requireUserVerification: true,
|
|
169
|
+
credential: {
|
|
170
|
+
id: cred.credential_id,
|
|
171
|
+
publicKey: b64urlToBytes(cred.public_key),
|
|
172
|
+
counter: Number(cred.counter),
|
|
173
|
+
transports: (cred.transports ?? undefined) as never,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
return { verified: false, reason: "verify" };
|
|
178
|
+
}
|
|
179
|
+
if (!verification.verified) return { verified: false, reason: "verify" };
|
|
180
|
+
|
|
181
|
+
await svc
|
|
182
|
+
.from("webauthn_credentials")
|
|
183
|
+
.update({ counter: verification.authenticationInfo.newCounter })
|
|
184
|
+
.eq("id", cred.id);
|
|
185
|
+
return { verified: true };
|
|
186
|
+
}
|