@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.
@@ -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 { userId, authenticatorAttachment, residentKey, userVerification } = req.body;
32
- if (!userId) {
33
- res.status(400).json({ error: 'userId is required' });
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 { userId, response, credentialName, challengeToken } = req.body;
61
- if (!userId || !response) {
62
- res.status(400).json({ error: 'userId and response are required' });
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 { userId, userVerification } = req.body;
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 { sessionKey, response } = req.body;
104
- if (!sessionKey || !response) {
105
- res.status(400).json({ error: 'sessionKey and response are required' });
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) {
@@ -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 { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
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
- * Not suitable for multi-process servers (race conditions on file writes).
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
- return JSON.parse(readFileSync(this.filePath, 'utf-8'));
106
+ const raw = await readFile(this.filePath, 'utf-8');
107
+ return JSON.parse(raw);
69
108
  }
70
- catch {
71
- return {};
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
- writeFileSync(this.filePath, JSON.stringify(data, null, 2));
123
+ await writeFile(this.filePath, JSON.stringify(data, null, 2));
82
124
  }
83
125
  async save(key, challenge) {
84
- const data = this.load();
85
- data[key] = challenge;
86
- this.persist(data);
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
- const data = this.load();
90
- const challenge = data[key];
91
- if (!challenge)
92
- return null;
93
- delete data[key];
94
- this.persist(data);
95
- if (Date.now() > challenge.expiresAt)
96
- return null;
97
- return challenge;
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
- return JSON.parse(readFileSync(this.filePath, 'utf-8'));
173
+ const raw = await readFile(this.filePath, 'utf-8');
174
+ return JSON.parse(raw);
114
175
  }
115
- catch {
116
- return [];
176
+ catch (err) {
177
+ if (err?.code === 'ENOENT')
178
+ return [];
179
+ throw err;
117
180
  }
118
181
  }
119
- persist(data) {
120
- writeFileSync(this.filePath, JSON.stringify(data, null, 2));
182
+ async persist(data) {
183
+ await writeFile(this.filePath, JSON.stringify(data, null, 2));
121
184
  }
122
185
  async save(credential) {
123
- const data = this.load();
124
- data.push(credential);
125
- this.persist(data);
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
- return this.load().filter(c => c.userId === userId);
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
- return this.load().find(c => c.credentialId === credentialId) ?? null;
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
- const data = this.load();
135
- const cred = data.find(c => c.credentialId === credentialId);
136
- if (cred) {
137
- cred.counter = newCounter;
138
- this.persist(data);
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
- const data = this.load().filter(c => c.credentialId !== credentialId);
143
- this.persist(data);
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
  }
@@ -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 });
@@ -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 { userId, authenticatorAttachment, residentKey, userVerification } = req.body;
35
- if (!userId) {
36
- res.status(400).json({ error: 'userId is required' });
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 { userId, response, credentialName, challengeToken } = req.body;
64
- if (!userId || !response) {
65
- res.status(400).json({ error: 'userId and response are required' });
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 { userId, userVerification } = req.body;
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 { sessionKey, response } = req.body;
107
- if (!sessionKey || !response) {
108
- res.status(400).json({ error: 'sessionKey and response are required' });
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
- * Not suitable for multi-process servers (race conditions on file writes).
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
- * Not suitable for multi-process servers (race conditions on file writes).
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
- return JSON.parse((0, fs_1.readFileSync)(this.filePath, 'utf-8'));
111
+ const raw = await (0, promises_1.readFile)(this.filePath, 'utf-8');
112
+ return JSON.parse(raw);
74
113
  }
75
- catch {
76
- return {};
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, fs_1.writeFileSync)(this.filePath, JSON.stringify(data, null, 2));
128
+ await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
87
129
  }
88
130
  async save(key, challenge) {
89
- const data = this.load();
90
- data[key] = challenge;
91
- this.persist(data);
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
- const data = this.load();
95
- const challenge = data[key];
96
- if (!challenge)
97
- return null;
98
- delete data[key];
99
- this.persist(data);
100
- if (Date.now() > challenge.expiresAt)
101
- return null;
102
- return challenge;
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
- return JSON.parse((0, fs_1.readFileSync)(this.filePath, 'utf-8'));
179
+ const raw = await (0, promises_1.readFile)(this.filePath, 'utf-8');
180
+ return JSON.parse(raw);
120
181
  }
121
- catch {
122
- return [];
182
+ catch (err) {
183
+ if (err?.code === 'ENOENT')
184
+ return [];
185
+ throw err;
123
186
  }
124
187
  }
125
- persist(data) {
126
- (0, fs_1.writeFileSync)(this.filePath, JSON.stringify(data, null, 2));
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
- const data = this.load();
130
- data.push(credential);
131
- this.persist(data);
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
- return this.load().filter(c => c.userId === userId);
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
- return this.load().find(c => c.credentialId === credentialId) ?? null;
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
- const data = this.load();
141
- const cred = data.find(c => c.credentialId === credentialId);
142
- if (cred) {
143
- cred.counter = newCounter;
144
- this.persist(data);
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
- const data = this.load().filter(c => c.credentialId !== credentialId);
149
- this.persist(data);
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.0.0",
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
- "express": "^4.0.0 || ^5.0.0",
62
- "argon2": "^0.41.0"
62
+ "argon2": "^0.41.0",
63
+ "express": "^4.0.0 || ^5.0.0"
63
64
  },
64
65
  "peerDependenciesMeta": {
65
66
  "@simplewebauthn/server": {