@passkeykit/server 3.0.0 → 3.1.1
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/dist/esm/express-routes.js +56 -30
- package/dist/esm/stores.js +132 -39
- package/dist/express-routes.d.ts +1 -1
- package/dist/express-routes.js +56 -30
- package/dist/stores.d.ts +9 -1
- package/dist/stores.js +131 -38
- package/package.json +5 -4
|
@@ -3,13 +3,50 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @ai_context This is a convenience wrapper. Apps that don't use Express
|
|
5
5
|
* can use PasskeyServer directly. These routes implement the standard
|
|
6
|
-
* challenge-response pattern with proper error handling.
|
|
6
|
+
* challenge-response pattern with proper error handling and Zod validation.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
9
|
* const routes = createExpressRoutes(passkeyServer, { getUserInfo });
|
|
10
10
|
* app.use('/api/auth/passkey', routes);
|
|
11
11
|
*/
|
|
12
12
|
import { Router } from 'express';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
// ============================================================
|
|
15
|
+
// Zod Schemas — strict input validation for every route
|
|
16
|
+
// ============================================================
|
|
17
|
+
const registerOptionsSchema = z.object({
|
|
18
|
+
userId: z.string().min(1),
|
|
19
|
+
authenticatorAttachment: z.enum(['platform', 'cross-platform']).optional(),
|
|
20
|
+
residentKey: z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
21
|
+
userVerification: z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
22
|
+
}).strict();
|
|
23
|
+
const registerVerifySchema = z.object({
|
|
24
|
+
userId: z.string().min(1),
|
|
25
|
+
response: z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
rawId: z.string(),
|
|
28
|
+
type: z.literal('public-key'),
|
|
29
|
+
response: z.record(z.string(), z.unknown()),
|
|
30
|
+
clientExtensionResults: z.record(z.string(), z.unknown()),
|
|
31
|
+
authenticatorAttachment: z.string().optional(),
|
|
32
|
+
}).passthrough(),
|
|
33
|
+
credentialName: z.string().optional(),
|
|
34
|
+
challengeToken: z.string().optional(),
|
|
35
|
+
}).strict();
|
|
36
|
+
const authenticateOptionsSchema = z.object({
|
|
37
|
+
userId: z.string().min(1).optional(),
|
|
38
|
+
userVerification: z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
39
|
+
}).strict();
|
|
40
|
+
const authenticateVerifySchema = z.object({
|
|
41
|
+
sessionKey: z.string().min(1),
|
|
42
|
+
response: z.object({
|
|
43
|
+
id: z.string(),
|
|
44
|
+
rawId: z.string(),
|
|
45
|
+
type: z.literal('public-key'),
|
|
46
|
+
response: z.record(z.string(), z.unknown()),
|
|
47
|
+
clientExtensionResults: z.record(z.string(), z.unknown()),
|
|
48
|
+
}).passthrough(),
|
|
49
|
+
}).strict();
|
|
13
50
|
/**
|
|
14
51
|
* Create Express router with passkey registration and authentication routes.
|
|
15
52
|
*
|
|
@@ -21,18 +58,14 @@ import { Router } from 'express';
|
|
|
21
58
|
*/
|
|
22
59
|
export function createExpressRoutes(server, config) {
|
|
23
60
|
const router = Router();
|
|
24
|
-
/**
|
|
25
|
-
* POST /register/options
|
|
26
|
-
* Body: { userId: string, authenticatorAttachment?: 'platform' | 'cross-platform' }
|
|
27
|
-
* Response includes `challengeToken` in stateless mode.
|
|
28
|
-
*/
|
|
29
61
|
router.post('/register/options', async (req, res) => {
|
|
30
62
|
try {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
33
|
-
res.status(400).json({ error: '
|
|
63
|
+
const parsed = registerOptionsSchema.safeParse(req.body);
|
|
64
|
+
if (!parsed.success) {
|
|
65
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
34
66
|
return;
|
|
35
67
|
}
|
|
68
|
+
const { userId, authenticatorAttachment, residentKey, userVerification } = parsed.data;
|
|
36
69
|
const user = await config.getUserInfo(userId);
|
|
37
70
|
if (!user) {
|
|
38
71
|
res.status(404).json({ error: 'User not found' });
|
|
@@ -50,18 +83,14 @@ export function createExpressRoutes(server, config) {
|
|
|
50
83
|
res.status(500).json({ error: 'Failed to generate registration options' });
|
|
51
84
|
}
|
|
52
85
|
});
|
|
53
|
-
/**
|
|
54
|
-
* POST /register/verify
|
|
55
|
-
* Body: { userId: string, response: RegistrationResponseJSON, credentialName?: string, challengeToken?: string }
|
|
56
|
-
* `challengeToken` is required in stateless mode.
|
|
57
|
-
*/
|
|
58
86
|
router.post('/register/verify', async (req, res) => {
|
|
59
87
|
try {
|
|
60
|
-
const
|
|
61
|
-
if (!
|
|
62
|
-
res.status(400).json({ error: '
|
|
88
|
+
const parsed = registerVerifySchema.safeParse(req.body);
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
63
91
|
return;
|
|
64
92
|
}
|
|
93
|
+
const { userId, response, credentialName, challengeToken } = parsed.data;
|
|
65
94
|
const result = await server.verifyRegistration(userId, response, credentialName, challengeToken);
|
|
66
95
|
if (config.onRegistrationSuccess) {
|
|
67
96
|
await config.onRegistrationSuccess(userId, result.credential.credentialId);
|
|
@@ -78,14 +107,14 @@ export function createExpressRoutes(server, config) {
|
|
|
78
107
|
res.status(400).json({ error: message });
|
|
79
108
|
}
|
|
80
109
|
});
|
|
81
|
-
/**
|
|
82
|
-
* POST /authenticate/options
|
|
83
|
-
* Body: { userId?: string }
|
|
84
|
-
* Response includes `sessionKey` (which IS the challengeToken in stateless mode).
|
|
85
|
-
*/
|
|
86
110
|
router.post('/authenticate/options', async (req, res) => {
|
|
87
111
|
try {
|
|
88
|
-
const
|
|
112
|
+
const parsed = authenticateOptionsSchema.safeParse(req.body);
|
|
113
|
+
if (!parsed.success) {
|
|
114
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const { userId, userVerification } = parsed.data;
|
|
89
118
|
const { options, sessionKey, challengeToken } = await server.generateAuthenticationOptions(userId, { userVerification });
|
|
90
119
|
res.json({ options, sessionKey, challengeToken });
|
|
91
120
|
}
|
|
@@ -94,17 +123,14 @@ export function createExpressRoutes(server, config) {
|
|
|
94
123
|
res.status(500).json({ error: 'Failed to generate authentication options' });
|
|
95
124
|
}
|
|
96
125
|
});
|
|
97
|
-
/**
|
|
98
|
-
* POST /authenticate/verify
|
|
99
|
-
* Body: { sessionKey: string, response: AuthenticationResponseJSON }
|
|
100
|
-
*/
|
|
101
126
|
router.post('/authenticate/verify', async (req, res) => {
|
|
102
127
|
try {
|
|
103
|
-
const
|
|
104
|
-
if (!
|
|
105
|
-
res.status(400).json({ error: '
|
|
128
|
+
const parsed = authenticateVerifySchema.safeParse(req.body);
|
|
129
|
+
if (!parsed.success) {
|
|
130
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
106
131
|
return;
|
|
107
132
|
}
|
|
133
|
+
const { sessionKey, response } = parsed.data;
|
|
108
134
|
const result = await server.verifyAuthentication(sessionKey, response);
|
|
109
135
|
let extra = {};
|
|
110
136
|
if (config.onAuthenticationSuccess) {
|
package/dist/esm/stores.js
CHANGED
|
@@ -4,9 +4,45 @@
|
|
|
4
4
|
* For production with multiple server instances, implement the ChallengeStore
|
|
5
5
|
* and CredentialStore interfaces with a shared backend (Redis, database, etc).
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
8
|
+
import { mkdirSync, existsSync } from 'fs';
|
|
8
9
|
import { dirname } from 'path';
|
|
9
10
|
// ============================================================
|
|
11
|
+
// Async Mutex — serializes read-modify-write file operations
|
|
12
|
+
// ============================================================
|
|
13
|
+
/**
|
|
14
|
+
* @ai_context Prevents async interleaving of read-modify-write file operations
|
|
15
|
+
* without blocking the Node.js event loop.
|
|
16
|
+
*
|
|
17
|
+
* Each file store instance owns its own AsyncMutex. When a method acquires the
|
|
18
|
+
* lock, all other callers queue behind it until the holder releases. This turns
|
|
19
|
+
* concurrent `load() → mutate → persist()` sequences into a serial pipeline,
|
|
20
|
+
* eliminating the lost-update race condition.
|
|
21
|
+
*/
|
|
22
|
+
class AsyncMutex {
|
|
23
|
+
queue = [];
|
|
24
|
+
locked = false;
|
|
25
|
+
acquire() {
|
|
26
|
+
if (!this.locked) {
|
|
27
|
+
this.locked = true;
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
this.queue.push(resolve);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
release() {
|
|
35
|
+
const next = this.queue.shift();
|
|
36
|
+
if (next) {
|
|
37
|
+
// Hand the lock directly to the next waiter (stays locked)
|
|
38
|
+
next();
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
this.locked = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ============================================================
|
|
10
46
|
// In-Memory Stores (good for development and single-process)
|
|
11
47
|
// ============================================================
|
|
12
48
|
export class MemoryChallengeStore {
|
|
@@ -53,93 +89,150 @@ export class MemoryCredentialStore {
|
|
|
53
89
|
* File-based challenge store. Challenges are stored in a JSON file.
|
|
54
90
|
* Auto-cleans expired challenges on every operation.
|
|
55
91
|
*
|
|
56
|
-
*
|
|
92
|
+
* Uses an internal async mutex to serialize concurrent read-modify-write
|
|
93
|
+
* operations within the same process. Not suitable for multi-process servers.
|
|
57
94
|
*/
|
|
58
95
|
export class FileChallengeStore {
|
|
59
96
|
filePath;
|
|
97
|
+
mutex = new AsyncMutex();
|
|
60
98
|
constructor(filePath) {
|
|
61
99
|
this.filePath = filePath;
|
|
62
100
|
const dir = dirname(filePath);
|
|
63
101
|
if (!existsSync(dir))
|
|
64
102
|
mkdirSync(dir, { recursive: true });
|
|
65
103
|
}
|
|
66
|
-
load() {
|
|
104
|
+
async load() {
|
|
67
105
|
try {
|
|
68
|
-
|
|
106
|
+
const raw = await readFile(this.filePath, 'utf-8');
|
|
107
|
+
return JSON.parse(raw);
|
|
69
108
|
}
|
|
70
|
-
catch {
|
|
71
|
-
|
|
109
|
+
catch (err) {
|
|
110
|
+
// File not yet created — valid initial state
|
|
111
|
+
if (err?.code === 'ENOENT')
|
|
112
|
+
return {};
|
|
113
|
+
// Anything else (permission denied, corrupted JSON) must surface
|
|
114
|
+
throw err;
|
|
72
115
|
}
|
|
73
116
|
}
|
|
74
|
-
persist(data) {
|
|
75
|
-
// Clean expired
|
|
117
|
+
async persist(data) {
|
|
76
118
|
const now = Date.now();
|
|
77
119
|
for (const [key, val] of Object.entries(data)) {
|
|
78
120
|
if (now > val.expiresAt)
|
|
79
121
|
delete data[key];
|
|
80
122
|
}
|
|
81
|
-
|
|
123
|
+
await writeFile(this.filePath, JSON.stringify(data, null, 2));
|
|
82
124
|
}
|
|
83
125
|
async save(key, challenge) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
126
|
+
await this.mutex.acquire();
|
|
127
|
+
try {
|
|
128
|
+
const data = await this.load();
|
|
129
|
+
data[key] = challenge;
|
|
130
|
+
await this.persist(data);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
this.mutex.release();
|
|
134
|
+
}
|
|
87
135
|
}
|
|
88
136
|
async consume(key) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
137
|
+
await this.mutex.acquire();
|
|
138
|
+
try {
|
|
139
|
+
const data = await this.load();
|
|
140
|
+
const challenge = data[key];
|
|
141
|
+
if (!challenge)
|
|
142
|
+
return null;
|
|
143
|
+
delete data[key];
|
|
144
|
+
await this.persist(data);
|
|
145
|
+
if (Date.now() > challenge.expiresAt)
|
|
146
|
+
return null;
|
|
147
|
+
return challenge;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
this.mutex.release();
|
|
151
|
+
}
|
|
98
152
|
}
|
|
99
153
|
}
|
|
100
154
|
/**
|
|
101
155
|
* File-based credential store. Credentials stored in a JSON array file.
|
|
156
|
+
*
|
|
157
|
+
* Uses an internal async mutex to serialize concurrent read-modify-write
|
|
158
|
+
* operations within the same process. Read-only operations (getByUserId,
|
|
159
|
+
* getByCredentialId) also acquire the lock to prevent reading a
|
|
160
|
+
* partially-written file from a concurrent persist().
|
|
102
161
|
*/
|
|
103
162
|
export class FileCredentialStore {
|
|
104
163
|
filePath;
|
|
164
|
+
mutex = new AsyncMutex();
|
|
105
165
|
constructor(filePath) {
|
|
106
166
|
this.filePath = filePath;
|
|
107
167
|
const dir = dirname(filePath);
|
|
108
168
|
if (!existsSync(dir))
|
|
109
169
|
mkdirSync(dir, { recursive: true });
|
|
110
170
|
}
|
|
111
|
-
load() {
|
|
171
|
+
async load() {
|
|
112
172
|
try {
|
|
113
|
-
|
|
173
|
+
const raw = await readFile(this.filePath, 'utf-8');
|
|
174
|
+
return JSON.parse(raw);
|
|
114
175
|
}
|
|
115
|
-
catch {
|
|
116
|
-
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (err?.code === 'ENOENT')
|
|
178
|
+
return [];
|
|
179
|
+
throw err;
|
|
117
180
|
}
|
|
118
181
|
}
|
|
119
|
-
persist(data) {
|
|
120
|
-
|
|
182
|
+
async persist(data) {
|
|
183
|
+
await writeFile(this.filePath, JSON.stringify(data, null, 2));
|
|
121
184
|
}
|
|
122
185
|
async save(credential) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
186
|
+
await this.mutex.acquire();
|
|
187
|
+
try {
|
|
188
|
+
const data = await this.load();
|
|
189
|
+
data.push(credential);
|
|
190
|
+
await this.persist(data);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
this.mutex.release();
|
|
194
|
+
}
|
|
126
195
|
}
|
|
127
196
|
async getByUserId(userId) {
|
|
128
|
-
|
|
197
|
+
await this.mutex.acquire();
|
|
198
|
+
try {
|
|
199
|
+
return (await this.load()).filter(c => c.userId === userId);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
this.mutex.release();
|
|
203
|
+
}
|
|
129
204
|
}
|
|
130
205
|
async getByCredentialId(credentialId) {
|
|
131
|
-
|
|
206
|
+
await this.mutex.acquire();
|
|
207
|
+
try {
|
|
208
|
+
return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
this.mutex.release();
|
|
212
|
+
}
|
|
132
213
|
}
|
|
133
214
|
async updateCounter(credentialId, newCounter) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
cred
|
|
138
|
-
|
|
215
|
+
await this.mutex.acquire();
|
|
216
|
+
try {
|
|
217
|
+
const data = await this.load();
|
|
218
|
+
const cred = data.find(c => c.credentialId === credentialId);
|
|
219
|
+
if (cred) {
|
|
220
|
+
cred.counter = newCounter;
|
|
221
|
+
await this.persist(data);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
this.mutex.release();
|
|
139
226
|
}
|
|
140
227
|
}
|
|
141
228
|
async delete(credentialId) {
|
|
142
|
-
|
|
143
|
-
|
|
229
|
+
await this.mutex.acquire();
|
|
230
|
+
try {
|
|
231
|
+
const data = (await this.load()).filter(c => c.credentialId !== credentialId);
|
|
232
|
+
await this.persist(data);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
this.mutex.release();
|
|
236
|
+
}
|
|
144
237
|
}
|
|
145
238
|
}
|
package/dist/express-routes.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @ai_context This is a convenience wrapper. Apps that don't use Express
|
|
5
5
|
* can use PasskeyServer directly. These routes implement the standard
|
|
6
|
-
* challenge-response pattern with proper error handling.
|
|
6
|
+
* challenge-response pattern with proper error handling and Zod validation.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
9
|
* const routes = createExpressRoutes(passkeyServer, { getUserInfo });
|
package/dist/express-routes.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @ai_context This is a convenience wrapper. Apps that don't use Express
|
|
6
6
|
* can use PasskeyServer directly. These routes implement the standard
|
|
7
|
-
* challenge-response pattern with proper error handling.
|
|
7
|
+
* challenge-response pattern with proper error handling and Zod validation.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* const routes = createExpressRoutes(passkeyServer, { getUserInfo });
|
|
@@ -13,6 +13,43 @@
|
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.createExpressRoutes = createExpressRoutes;
|
|
15
15
|
const express_1 = require("express");
|
|
16
|
+
const zod_1 = require("zod");
|
|
17
|
+
// ============================================================
|
|
18
|
+
// Zod Schemas — strict input validation for every route
|
|
19
|
+
// ============================================================
|
|
20
|
+
const registerOptionsSchema = zod_1.z.object({
|
|
21
|
+
userId: zod_1.z.string().min(1),
|
|
22
|
+
authenticatorAttachment: zod_1.z.enum(['platform', 'cross-platform']).optional(),
|
|
23
|
+
residentKey: zod_1.z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
24
|
+
userVerification: zod_1.z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
25
|
+
}).strict();
|
|
26
|
+
const registerVerifySchema = zod_1.z.object({
|
|
27
|
+
userId: zod_1.z.string().min(1),
|
|
28
|
+
response: zod_1.z.object({
|
|
29
|
+
id: zod_1.z.string(),
|
|
30
|
+
rawId: zod_1.z.string(),
|
|
31
|
+
type: zod_1.z.literal('public-key'),
|
|
32
|
+
response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
33
|
+
clientExtensionResults: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
34
|
+
authenticatorAttachment: zod_1.z.string().optional(),
|
|
35
|
+
}).passthrough(),
|
|
36
|
+
credentialName: zod_1.z.string().optional(),
|
|
37
|
+
challengeToken: zod_1.z.string().optional(),
|
|
38
|
+
}).strict();
|
|
39
|
+
const authenticateOptionsSchema = zod_1.z.object({
|
|
40
|
+
userId: zod_1.z.string().min(1).optional(),
|
|
41
|
+
userVerification: zod_1.z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
42
|
+
}).strict();
|
|
43
|
+
const authenticateVerifySchema = zod_1.z.object({
|
|
44
|
+
sessionKey: zod_1.z.string().min(1),
|
|
45
|
+
response: zod_1.z.object({
|
|
46
|
+
id: zod_1.z.string(),
|
|
47
|
+
rawId: zod_1.z.string(),
|
|
48
|
+
type: zod_1.z.literal('public-key'),
|
|
49
|
+
response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
50
|
+
clientExtensionResults: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
51
|
+
}).passthrough(),
|
|
52
|
+
}).strict();
|
|
16
53
|
/**
|
|
17
54
|
* Create Express router with passkey registration and authentication routes.
|
|
18
55
|
*
|
|
@@ -24,18 +61,14 @@ const express_1 = require("express");
|
|
|
24
61
|
*/
|
|
25
62
|
function createExpressRoutes(server, config) {
|
|
26
63
|
const router = (0, express_1.Router)();
|
|
27
|
-
/**
|
|
28
|
-
* POST /register/options
|
|
29
|
-
* Body: { userId: string, authenticatorAttachment?: 'platform' | 'cross-platform' }
|
|
30
|
-
* Response includes `challengeToken` in stateless mode.
|
|
31
|
-
*/
|
|
32
64
|
router.post('/register/options', async (req, res) => {
|
|
33
65
|
try {
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
36
|
-
res.status(400).json({ error: '
|
|
66
|
+
const parsed = registerOptionsSchema.safeParse(req.body);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
37
69
|
return;
|
|
38
70
|
}
|
|
71
|
+
const { userId, authenticatorAttachment, residentKey, userVerification } = parsed.data;
|
|
39
72
|
const user = await config.getUserInfo(userId);
|
|
40
73
|
if (!user) {
|
|
41
74
|
res.status(404).json({ error: 'User not found' });
|
|
@@ -53,18 +86,14 @@ function createExpressRoutes(server, config) {
|
|
|
53
86
|
res.status(500).json({ error: 'Failed to generate registration options' });
|
|
54
87
|
}
|
|
55
88
|
});
|
|
56
|
-
/**
|
|
57
|
-
* POST /register/verify
|
|
58
|
-
* Body: { userId: string, response: RegistrationResponseJSON, credentialName?: string, challengeToken?: string }
|
|
59
|
-
* `challengeToken` is required in stateless mode.
|
|
60
|
-
*/
|
|
61
89
|
router.post('/register/verify', async (req, res) => {
|
|
62
90
|
try {
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
65
|
-
res.status(400).json({ error: '
|
|
91
|
+
const parsed = registerVerifySchema.safeParse(req.body);
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
66
94
|
return;
|
|
67
95
|
}
|
|
96
|
+
const { userId, response, credentialName, challengeToken } = parsed.data;
|
|
68
97
|
const result = await server.verifyRegistration(userId, response, credentialName, challengeToken);
|
|
69
98
|
if (config.onRegistrationSuccess) {
|
|
70
99
|
await config.onRegistrationSuccess(userId, result.credential.credentialId);
|
|
@@ -81,14 +110,14 @@ function createExpressRoutes(server, config) {
|
|
|
81
110
|
res.status(400).json({ error: message });
|
|
82
111
|
}
|
|
83
112
|
});
|
|
84
|
-
/**
|
|
85
|
-
* POST /authenticate/options
|
|
86
|
-
* Body: { userId?: string }
|
|
87
|
-
* Response includes `sessionKey` (which IS the challengeToken in stateless mode).
|
|
88
|
-
*/
|
|
89
113
|
router.post('/authenticate/options', async (req, res) => {
|
|
90
114
|
try {
|
|
91
|
-
const
|
|
115
|
+
const parsed = authenticateOptionsSchema.safeParse(req.body);
|
|
116
|
+
if (!parsed.success) {
|
|
117
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const { userId, userVerification } = parsed.data;
|
|
92
121
|
const { options, sessionKey, challengeToken } = await server.generateAuthenticationOptions(userId, { userVerification });
|
|
93
122
|
res.json({ options, sessionKey, challengeToken });
|
|
94
123
|
}
|
|
@@ -97,17 +126,14 @@ function createExpressRoutes(server, config) {
|
|
|
97
126
|
res.status(500).json({ error: 'Failed to generate authentication options' });
|
|
98
127
|
}
|
|
99
128
|
});
|
|
100
|
-
/**
|
|
101
|
-
* POST /authenticate/verify
|
|
102
|
-
* Body: { sessionKey: string, response: AuthenticationResponseJSON }
|
|
103
|
-
*/
|
|
104
129
|
router.post('/authenticate/verify', async (req, res) => {
|
|
105
130
|
try {
|
|
106
|
-
const
|
|
107
|
-
if (!
|
|
108
|
-
res.status(400).json({ error: '
|
|
131
|
+
const parsed = authenticateVerifySchema.safeParse(req.body);
|
|
132
|
+
if (!parsed.success) {
|
|
133
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
109
134
|
return;
|
|
110
135
|
}
|
|
136
|
+
const { sessionKey, response } = parsed.data;
|
|
111
137
|
const result = await server.verifyAuthentication(sessionKey, response);
|
|
112
138
|
let extra = {};
|
|
113
139
|
if (config.onAuthenticationSuccess) {
|
package/dist/stores.d.ts
CHANGED
|
@@ -22,10 +22,12 @@ export declare class MemoryCredentialStore implements CredentialStore {
|
|
|
22
22
|
* File-based challenge store. Challenges are stored in a JSON file.
|
|
23
23
|
* Auto-cleans expired challenges on every operation.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* Uses an internal async mutex to serialize concurrent read-modify-write
|
|
26
|
+
* operations within the same process. Not suitable for multi-process servers.
|
|
26
27
|
*/
|
|
27
28
|
export declare class FileChallengeStore implements ChallengeStore {
|
|
28
29
|
private filePath;
|
|
30
|
+
private mutex;
|
|
29
31
|
constructor(filePath: string);
|
|
30
32
|
private load;
|
|
31
33
|
private persist;
|
|
@@ -34,9 +36,15 @@ export declare class FileChallengeStore implements ChallengeStore {
|
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
36
38
|
* File-based credential store. Credentials stored in a JSON array file.
|
|
39
|
+
*
|
|
40
|
+
* Uses an internal async mutex to serialize concurrent read-modify-write
|
|
41
|
+
* operations within the same process. Read-only operations (getByUserId,
|
|
42
|
+
* getByCredentialId) also acquire the lock to prevent reading a
|
|
43
|
+
* partially-written file from a concurrent persist().
|
|
37
44
|
*/
|
|
38
45
|
export declare class FileCredentialStore implements CredentialStore {
|
|
39
46
|
private filePath;
|
|
47
|
+
private mutex;
|
|
40
48
|
constructor(filePath: string);
|
|
41
49
|
private load;
|
|
42
50
|
private persist;
|
package/dist/stores.js
CHANGED
|
@@ -7,9 +7,45 @@
|
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.FileCredentialStore = exports.FileChallengeStore = exports.MemoryCredentialStore = exports.MemoryChallengeStore = void 0;
|
|
10
|
+
const promises_1 = require("fs/promises");
|
|
10
11
|
const fs_1 = require("fs");
|
|
11
12
|
const path_1 = require("path");
|
|
12
13
|
// ============================================================
|
|
14
|
+
// Async Mutex — serializes read-modify-write file operations
|
|
15
|
+
// ============================================================
|
|
16
|
+
/**
|
|
17
|
+
* @ai_context Prevents async interleaving of read-modify-write file operations
|
|
18
|
+
* without blocking the Node.js event loop.
|
|
19
|
+
*
|
|
20
|
+
* Each file store instance owns its own AsyncMutex. When a method acquires the
|
|
21
|
+
* lock, all other callers queue behind it until the holder releases. This turns
|
|
22
|
+
* concurrent `load() → mutate → persist()` sequences into a serial pipeline,
|
|
23
|
+
* eliminating the lost-update race condition.
|
|
24
|
+
*/
|
|
25
|
+
class AsyncMutex {
|
|
26
|
+
queue = [];
|
|
27
|
+
locked = false;
|
|
28
|
+
acquire() {
|
|
29
|
+
if (!this.locked) {
|
|
30
|
+
this.locked = true;
|
|
31
|
+
return Promise.resolve();
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
this.queue.push(resolve);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
release() {
|
|
38
|
+
const next = this.queue.shift();
|
|
39
|
+
if (next) {
|
|
40
|
+
// Hand the lock directly to the next waiter (stays locked)
|
|
41
|
+
next();
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
this.locked = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ============================================================
|
|
13
49
|
// In-Memory Stores (good for development and single-process)
|
|
14
50
|
// ============================================================
|
|
15
51
|
class MemoryChallengeStore {
|
|
@@ -58,95 +94,152 @@ exports.MemoryCredentialStore = MemoryCredentialStore;
|
|
|
58
94
|
* File-based challenge store. Challenges are stored in a JSON file.
|
|
59
95
|
* Auto-cleans expired challenges on every operation.
|
|
60
96
|
*
|
|
61
|
-
*
|
|
97
|
+
* Uses an internal async mutex to serialize concurrent read-modify-write
|
|
98
|
+
* operations within the same process. Not suitable for multi-process servers.
|
|
62
99
|
*/
|
|
63
100
|
class FileChallengeStore {
|
|
64
101
|
filePath;
|
|
102
|
+
mutex = new AsyncMutex();
|
|
65
103
|
constructor(filePath) {
|
|
66
104
|
this.filePath = filePath;
|
|
67
105
|
const dir = (0, path_1.dirname)(filePath);
|
|
68
106
|
if (!(0, fs_1.existsSync)(dir))
|
|
69
107
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
70
108
|
}
|
|
71
|
-
load() {
|
|
109
|
+
async load() {
|
|
72
110
|
try {
|
|
73
|
-
|
|
111
|
+
const raw = await (0, promises_1.readFile)(this.filePath, 'utf-8');
|
|
112
|
+
return JSON.parse(raw);
|
|
74
113
|
}
|
|
75
|
-
catch {
|
|
76
|
-
|
|
114
|
+
catch (err) {
|
|
115
|
+
// File not yet created — valid initial state
|
|
116
|
+
if (err?.code === 'ENOENT')
|
|
117
|
+
return {};
|
|
118
|
+
// Anything else (permission denied, corrupted JSON) must surface
|
|
119
|
+
throw err;
|
|
77
120
|
}
|
|
78
121
|
}
|
|
79
|
-
persist(data) {
|
|
80
|
-
// Clean expired
|
|
122
|
+
async persist(data) {
|
|
81
123
|
const now = Date.now();
|
|
82
124
|
for (const [key, val] of Object.entries(data)) {
|
|
83
125
|
if (now > val.expiresAt)
|
|
84
126
|
delete data[key];
|
|
85
127
|
}
|
|
86
|
-
(0,
|
|
128
|
+
await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
|
|
87
129
|
}
|
|
88
130
|
async save(key, challenge) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
131
|
+
await this.mutex.acquire();
|
|
132
|
+
try {
|
|
133
|
+
const data = await this.load();
|
|
134
|
+
data[key] = challenge;
|
|
135
|
+
await this.persist(data);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
this.mutex.release();
|
|
139
|
+
}
|
|
92
140
|
}
|
|
93
141
|
async consume(key) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
142
|
+
await this.mutex.acquire();
|
|
143
|
+
try {
|
|
144
|
+
const data = await this.load();
|
|
145
|
+
const challenge = data[key];
|
|
146
|
+
if (!challenge)
|
|
147
|
+
return null;
|
|
148
|
+
delete data[key];
|
|
149
|
+
await this.persist(data);
|
|
150
|
+
if (Date.now() > challenge.expiresAt)
|
|
151
|
+
return null;
|
|
152
|
+
return challenge;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
this.mutex.release();
|
|
156
|
+
}
|
|
103
157
|
}
|
|
104
158
|
}
|
|
105
159
|
exports.FileChallengeStore = FileChallengeStore;
|
|
106
160
|
/**
|
|
107
161
|
* File-based credential store. Credentials stored in a JSON array file.
|
|
162
|
+
*
|
|
163
|
+
* Uses an internal async mutex to serialize concurrent read-modify-write
|
|
164
|
+
* operations within the same process. Read-only operations (getByUserId,
|
|
165
|
+
* getByCredentialId) also acquire the lock to prevent reading a
|
|
166
|
+
* partially-written file from a concurrent persist().
|
|
108
167
|
*/
|
|
109
168
|
class FileCredentialStore {
|
|
110
169
|
filePath;
|
|
170
|
+
mutex = new AsyncMutex();
|
|
111
171
|
constructor(filePath) {
|
|
112
172
|
this.filePath = filePath;
|
|
113
173
|
const dir = (0, path_1.dirname)(filePath);
|
|
114
174
|
if (!(0, fs_1.existsSync)(dir))
|
|
115
175
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
116
176
|
}
|
|
117
|
-
load() {
|
|
177
|
+
async load() {
|
|
118
178
|
try {
|
|
119
|
-
|
|
179
|
+
const raw = await (0, promises_1.readFile)(this.filePath, 'utf-8');
|
|
180
|
+
return JSON.parse(raw);
|
|
120
181
|
}
|
|
121
|
-
catch {
|
|
122
|
-
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (err?.code === 'ENOENT')
|
|
184
|
+
return [];
|
|
185
|
+
throw err;
|
|
123
186
|
}
|
|
124
187
|
}
|
|
125
|
-
persist(data) {
|
|
126
|
-
(0,
|
|
188
|
+
async persist(data) {
|
|
189
|
+
await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
|
|
127
190
|
}
|
|
128
191
|
async save(credential) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
192
|
+
await this.mutex.acquire();
|
|
193
|
+
try {
|
|
194
|
+
const data = await this.load();
|
|
195
|
+
data.push(credential);
|
|
196
|
+
await this.persist(data);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
this.mutex.release();
|
|
200
|
+
}
|
|
132
201
|
}
|
|
133
202
|
async getByUserId(userId) {
|
|
134
|
-
|
|
203
|
+
await this.mutex.acquire();
|
|
204
|
+
try {
|
|
205
|
+
return (await this.load()).filter(c => c.userId === userId);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
this.mutex.release();
|
|
209
|
+
}
|
|
135
210
|
}
|
|
136
211
|
async getByCredentialId(credentialId) {
|
|
137
|
-
|
|
212
|
+
await this.mutex.acquire();
|
|
213
|
+
try {
|
|
214
|
+
return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
this.mutex.release();
|
|
218
|
+
}
|
|
138
219
|
}
|
|
139
220
|
async updateCounter(credentialId, newCounter) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
cred
|
|
144
|
-
|
|
221
|
+
await this.mutex.acquire();
|
|
222
|
+
try {
|
|
223
|
+
const data = await this.load();
|
|
224
|
+
const cred = data.find(c => c.credentialId === credentialId);
|
|
225
|
+
if (cred) {
|
|
226
|
+
cred.counter = newCounter;
|
|
227
|
+
await this.persist(data);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
this.mutex.release();
|
|
145
232
|
}
|
|
146
233
|
}
|
|
147
234
|
async delete(credentialId) {
|
|
148
|
-
|
|
149
|
-
|
|
235
|
+
await this.mutex.acquire();
|
|
236
|
+
try {
|
|
237
|
+
const data = (await this.load()).filter(c => c.credentialId !== credentialId);
|
|
238
|
+
await this.persist(data);
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
this.mutex.release();
|
|
242
|
+
}
|
|
150
243
|
}
|
|
151
244
|
}
|
|
152
245
|
exports.FileCredentialStore = FileCredentialStore;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passkeykit/server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Server-side WebAuthn passkey verification — stateless or stateful, pure JS, works on serverless",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -54,12 +54,13 @@
|
|
|
54
54
|
],
|
|
55
55
|
"license": "MIT",
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@noble/hashes": "^1.7.0"
|
|
57
|
+
"@noble/hashes": "^1.7.0",
|
|
58
|
+
"zod": "^4.3.6"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
61
|
"@simplewebauthn/server": "^13.0.0",
|
|
61
|
-
"
|
|
62
|
-
"
|
|
62
|
+
"argon2": "^0.41.0",
|
|
63
|
+
"express": "^4.0.0 || ^5.0.0"
|
|
63
64
|
},
|
|
64
65
|
"peerDependenciesMeta": {
|
|
65
66
|
"@simplewebauthn/server": {
|