@passkeykit/server 3.0.0 → 3.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.
@@ -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,7 +4,8 @@
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
  // ============================================================
10
11
  // In-Memory Stores (good for development and single-process)
@@ -63,35 +64,39 @@ export class FileChallengeStore {
63
64
  if (!existsSync(dir))
64
65
  mkdirSync(dir, { recursive: true });
65
66
  }
66
- load() {
67
+ async load() {
67
68
  try {
68
- return JSON.parse(readFileSync(this.filePath, 'utf-8'));
69
+ const raw = await readFile(this.filePath, 'utf-8');
70
+ return JSON.parse(raw);
69
71
  }
70
- catch {
71
- return {};
72
+ catch (err) {
73
+ // File not yet created — valid initial state
74
+ if (err?.code === 'ENOENT')
75
+ return {};
76
+ // Anything else (permission denied, corrupted JSON) must surface
77
+ throw err;
72
78
  }
73
79
  }
74
- persist(data) {
75
- // Clean expired
80
+ async persist(data) {
76
81
  const now = Date.now();
77
82
  for (const [key, val] of Object.entries(data)) {
78
83
  if (now > val.expiresAt)
79
84
  delete data[key];
80
85
  }
81
- writeFileSync(this.filePath, JSON.stringify(data, null, 2));
86
+ await writeFile(this.filePath, JSON.stringify(data, null, 2));
82
87
  }
83
88
  async save(key, challenge) {
84
- const data = this.load();
89
+ const data = await this.load();
85
90
  data[key] = challenge;
86
- this.persist(data);
91
+ await this.persist(data);
87
92
  }
88
93
  async consume(key) {
89
- const data = this.load();
94
+ const data = await this.load();
90
95
  const challenge = data[key];
91
96
  if (!challenge)
92
97
  return null;
93
98
  delete data[key];
94
- this.persist(data);
99
+ await this.persist(data);
95
100
  if (Date.now() > challenge.expiresAt)
96
101
  return null;
97
102
  return challenge;
@@ -108,38 +113,41 @@ export class FileCredentialStore {
108
113
  if (!existsSync(dir))
109
114
  mkdirSync(dir, { recursive: true });
110
115
  }
111
- load() {
116
+ async load() {
112
117
  try {
113
- return JSON.parse(readFileSync(this.filePath, 'utf-8'));
118
+ const raw = await readFile(this.filePath, 'utf-8');
119
+ return JSON.parse(raw);
114
120
  }
115
- catch {
116
- return [];
121
+ catch (err) {
122
+ if (err?.code === 'ENOENT')
123
+ return [];
124
+ throw err;
117
125
  }
118
126
  }
119
- persist(data) {
120
- writeFileSync(this.filePath, JSON.stringify(data, null, 2));
127
+ async persist(data) {
128
+ await writeFile(this.filePath, JSON.stringify(data, null, 2));
121
129
  }
122
130
  async save(credential) {
123
- const data = this.load();
131
+ const data = await this.load();
124
132
  data.push(credential);
125
- this.persist(data);
133
+ await this.persist(data);
126
134
  }
127
135
  async getByUserId(userId) {
128
- return this.load().filter(c => c.userId === userId);
136
+ return (await this.load()).filter(c => c.userId === userId);
129
137
  }
130
138
  async getByCredentialId(credentialId) {
131
- return this.load().find(c => c.credentialId === credentialId) ?? null;
139
+ return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
132
140
  }
133
141
  async updateCounter(credentialId, newCounter) {
134
- const data = this.load();
142
+ const data = await this.load();
135
143
  const cred = data.find(c => c.credentialId === credentialId);
136
144
  if (cred) {
137
145
  cred.counter = newCounter;
138
- this.persist(data);
146
+ await this.persist(data);
139
147
  }
140
148
  }
141
149
  async delete(credentialId) {
142
- const data = this.load().filter(c => c.credentialId !== credentialId);
143
- this.persist(data);
150
+ const data = (await this.load()).filter(c => c.credentialId !== credentialId);
151
+ await this.persist(data);
144
152
  }
145
153
  }
@@ -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.js CHANGED
@@ -7,6 +7,7 @@
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
  // ============================================================
@@ -68,35 +69,39 @@ class FileChallengeStore {
68
69
  if (!(0, fs_1.existsSync)(dir))
69
70
  (0, fs_1.mkdirSync)(dir, { recursive: true });
70
71
  }
71
- load() {
72
+ async load() {
72
73
  try {
73
- return JSON.parse((0, fs_1.readFileSync)(this.filePath, 'utf-8'));
74
+ const raw = await (0, promises_1.readFile)(this.filePath, 'utf-8');
75
+ return JSON.parse(raw);
74
76
  }
75
- catch {
76
- return {};
77
+ catch (err) {
78
+ // File not yet created — valid initial state
79
+ if (err?.code === 'ENOENT')
80
+ return {};
81
+ // Anything else (permission denied, corrupted JSON) must surface
82
+ throw err;
77
83
  }
78
84
  }
79
- persist(data) {
80
- // Clean expired
85
+ async persist(data) {
81
86
  const now = Date.now();
82
87
  for (const [key, val] of Object.entries(data)) {
83
88
  if (now > val.expiresAt)
84
89
  delete data[key];
85
90
  }
86
- (0, fs_1.writeFileSync)(this.filePath, JSON.stringify(data, null, 2));
91
+ await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
87
92
  }
88
93
  async save(key, challenge) {
89
- const data = this.load();
94
+ const data = await this.load();
90
95
  data[key] = challenge;
91
- this.persist(data);
96
+ await this.persist(data);
92
97
  }
93
98
  async consume(key) {
94
- const data = this.load();
99
+ const data = await this.load();
95
100
  const challenge = data[key];
96
101
  if (!challenge)
97
102
  return null;
98
103
  delete data[key];
99
- this.persist(data);
104
+ await this.persist(data);
100
105
  if (Date.now() > challenge.expiresAt)
101
106
  return null;
102
107
  return challenge;
@@ -114,39 +119,42 @@ class FileCredentialStore {
114
119
  if (!(0, fs_1.existsSync)(dir))
115
120
  (0, fs_1.mkdirSync)(dir, { recursive: true });
116
121
  }
117
- load() {
122
+ async load() {
118
123
  try {
119
- return JSON.parse((0, fs_1.readFileSync)(this.filePath, 'utf-8'));
124
+ const raw = await (0, promises_1.readFile)(this.filePath, 'utf-8');
125
+ return JSON.parse(raw);
120
126
  }
121
- catch {
122
- return [];
127
+ catch (err) {
128
+ if (err?.code === 'ENOENT')
129
+ return [];
130
+ throw err;
123
131
  }
124
132
  }
125
- persist(data) {
126
- (0, fs_1.writeFileSync)(this.filePath, JSON.stringify(data, null, 2));
133
+ async persist(data) {
134
+ await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
127
135
  }
128
136
  async save(credential) {
129
- const data = this.load();
137
+ const data = await this.load();
130
138
  data.push(credential);
131
- this.persist(data);
139
+ await this.persist(data);
132
140
  }
133
141
  async getByUserId(userId) {
134
- return this.load().filter(c => c.userId === userId);
142
+ return (await this.load()).filter(c => c.userId === userId);
135
143
  }
136
144
  async getByCredentialId(credentialId) {
137
- return this.load().find(c => c.credentialId === credentialId) ?? null;
145
+ return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
138
146
  }
139
147
  async updateCounter(credentialId, newCounter) {
140
- const data = this.load();
148
+ const data = await this.load();
141
149
  const cred = data.find(c => c.credentialId === credentialId);
142
150
  if (cred) {
143
151
  cred.counter = newCounter;
144
- this.persist(data);
152
+ await this.persist(data);
145
153
  }
146
154
  }
147
155
  async delete(credentialId) {
148
- const data = this.load().filter(c => c.credentialId !== credentialId);
149
- this.persist(data);
156
+ const data = (await this.load()).filter(c => c.credentialId !== credentialId);
157
+ await this.persist(data);
150
158
  }
151
159
  }
152
160
  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.0",
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": {