@passkeykit/server 2.0.0 → 2.0.2
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/README.md +247 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/password-argon2.js +1 -1
- package/dist/esm/password.js +2 -3
- package/dist/esm/types.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/password-argon2.d.ts +1 -1
- package/dist/password-argon2.js +1 -1
- package/dist/password.d.ts +1 -1
- package/dist/password.js +2 -3
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/package.json +13 -3
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# @passkeykit/server
|
|
2
|
+
|
|
3
|
+
Server-side WebAuthn passkey verification — **stateless by default**. Works on Vercel, Cloudflare Workers, and traditional servers. Zero native dependencies.
|
|
4
|
+
|
|
5
|
+
Handles challenge generation, attestation/assertion verification, and includes scrypt password hashing (pure JS). Optional argon2 support via subpath export.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@passkeykit/server)
|
|
8
|
+
[](https://github.com/dnldev/passkey-kit/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @passkeykit/server
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
### Stateless (Serverless / Vercel / Cloudflare)
|
|
19
|
+
|
|
20
|
+
No database needed for challenges — they're encrypted into signed tokens.
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { PasskeyServer, FileCredentialStore } from '@passkeykit/server';
|
|
24
|
+
import { createExpressRoutes } from '@passkeykit/server/express';
|
|
25
|
+
|
|
26
|
+
const server = new PasskeyServer({
|
|
27
|
+
rpName: 'My App',
|
|
28
|
+
rpId: 'myapp.example.com',
|
|
29
|
+
allowedOrigins: ['https://myapp.example.com'],
|
|
30
|
+
encryptionKey: process.env.PASSKEY_SECRET!, // 32+ char secret
|
|
31
|
+
credentialStore: new FileCredentialStore('./data/credentials.json'),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Mount ready-made Express routes
|
|
35
|
+
app.use('/api/auth/passkey', createExpressRoutes(server, {
|
|
36
|
+
getUserInfo: async (userId) => {
|
|
37
|
+
const user = await db.getUser(userId);
|
|
38
|
+
return user ? { id: user.id, name: user.name } : null;
|
|
39
|
+
},
|
|
40
|
+
onAuthenticationSuccess: async (userId) => {
|
|
41
|
+
return { token: generateSessionToken() };
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Stateful (Traditional Server)
|
|
47
|
+
|
|
48
|
+
Use a challenge store if you need server-side challenge revocation.
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { PasskeyServer, MemoryChallengeStore, FileCredentialStore } from '@passkeykit/server';
|
|
52
|
+
|
|
53
|
+
const server = new PasskeyServer({
|
|
54
|
+
rpName: 'My App',
|
|
55
|
+
rpId: 'myapp.example.com',
|
|
56
|
+
allowedOrigins: ['https://myapp.example.com'],
|
|
57
|
+
challengeStore: new MemoryChallengeStore(),
|
|
58
|
+
credentialStore: new FileCredentialStore('./data/credentials.json'),
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Direct API (without Express)
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// Registration
|
|
66
|
+
const regOptions = await server.generateRegistrationOptions(userId, userName);
|
|
67
|
+
// → send regOptions to client, client runs WebAuthn ceremony
|
|
68
|
+
const regResult = await server.verifyRegistration(attestationResponse, challengeToken);
|
|
69
|
+
|
|
70
|
+
// Authentication
|
|
71
|
+
const authOptions = await server.generateAuthenticationOptions();
|
|
72
|
+
// → send authOptions + sessionKey to client
|
|
73
|
+
const authResult = await server.verifyAuthentication(assertionResponse, sessionKey);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Architecture
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
Client Server
|
|
80
|
+
│ │
|
|
81
|
+
│── POST /register/options ──────▶│ Generate challenge
|
|
82
|
+
│◀── { options, challengeToken } ──│ Seal into AES-256-GCM token
|
|
83
|
+
│ │
|
|
84
|
+
│── WebAuthn ceremony (browser) ──│
|
|
85
|
+
│ │
|
|
86
|
+
│── POST /register/verify ───────▶│ Open token, verify attestation
|
|
87
|
+
│ { response, challengeToken } │ No DB lookup needed
|
|
88
|
+
│◀── { verified: true } ──────────│
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
In **stateless mode**, the `challengeToken` is an encrypted, signed, expiring token. The server needs only the secret key — zero state.
|
|
92
|
+
|
|
93
|
+
In **stateful mode**, challenges are stored in your `ChallengeStore` and consumed on verification.
|
|
94
|
+
|
|
95
|
+
## Express Routes
|
|
96
|
+
|
|
97
|
+
Mount a complete passkey API with one line:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { createExpressRoutes } from '@passkeykit/server/express';
|
|
101
|
+
|
|
102
|
+
const routes = createExpressRoutes(server, {
|
|
103
|
+
getUserInfo: async (userId) => ({ id: userId, name: 'User' }),
|
|
104
|
+
onRegistrationSuccess: async (userId, credentialId) => {
|
|
105
|
+
console.log(`User ${userId} registered passkey ${credentialId}`);
|
|
106
|
+
},
|
|
107
|
+
onAuthenticationSuccess: async (userId) => {
|
|
108
|
+
return { sessionToken: createSession(userId) };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
app.use('/api/auth/passkey', routes);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Routes created:**
|
|
116
|
+
|
|
117
|
+
| Method | Path | Description |
|
|
118
|
+
|--------|------|-------------|
|
|
119
|
+
| POST | `/register/options` | Get registration options + challenge |
|
|
120
|
+
| POST | `/register/verify` | Verify attestation response |
|
|
121
|
+
| POST | `/authenticate/options` | Get authentication options + challenge |
|
|
122
|
+
| POST | `/authenticate/verify` | Verify assertion response |
|
|
123
|
+
| GET | `/credentials/:userId` | List user's credentials |
|
|
124
|
+
| DELETE | `/credentials/:credentialId` | Delete a credential |
|
|
125
|
+
|
|
126
|
+
## Password Hashing
|
|
127
|
+
|
|
128
|
+
Built-in scrypt hashing — pure JS, works everywhere (no native bindings):
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { hashPassword, verifyPassword, needsRehash } from '@passkeykit/server';
|
|
132
|
+
|
|
133
|
+
const hash = await hashPassword('my-passphrase');
|
|
134
|
+
// → $scrypt$ln=17,r=8,p=1$<salt>$<hash>
|
|
135
|
+
|
|
136
|
+
const valid = await verifyPassword(hash, 'my-passphrase'); // true
|
|
137
|
+
|
|
138
|
+
// Check if params have been upgraded since this hash was created
|
|
139
|
+
if (needsRehash(hash)) {
|
|
140
|
+
const newHash = await hashPassword('my-passphrase');
|
|
141
|
+
await db.updateHash(userId, newHash);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### argon2 (optional)
|
|
146
|
+
|
|
147
|
+
For native argon2id hashing, install `argon2` as a peer dependency:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npm install argon2
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { hashPassword, verifyPassword } from '@passkeykit/server/argon2';
|
|
155
|
+
|
|
156
|
+
const hash = await hashPassword('my-passphrase');
|
|
157
|
+
// → $argon2id$v=19$m=65536,t=3,p=4$...
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Storage Backends
|
|
161
|
+
|
|
162
|
+
### Built-in Stores
|
|
163
|
+
|
|
164
|
+
| Store | Use Case |
|
|
165
|
+
|-------|----------|
|
|
166
|
+
| `MemoryChallengeStore` | Development / testing |
|
|
167
|
+
| `MemoryCredentialStore` | Development / testing |
|
|
168
|
+
| `FileChallengeStore` | Single-server deployments |
|
|
169
|
+
| `FileCredentialStore` | Single-server deployments |
|
|
170
|
+
|
|
171
|
+
### Custom Stores
|
|
172
|
+
|
|
173
|
+
Implement the `ChallengeStore` and/or `CredentialStore` interfaces for your backend:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import type { CredentialStore, StoredCredential } from '@passkeykit/server';
|
|
177
|
+
|
|
178
|
+
class FirestoreCredentialStore implements CredentialStore {
|
|
179
|
+
async save(credential: StoredCredential) { /* ... */ }
|
|
180
|
+
async getByUserId(userId: string) { /* ... */ }
|
|
181
|
+
async getByCredentialId(credentialId: string) { /* ... */ }
|
|
182
|
+
async updateCounter(credentialId: string, newCounter: number) { /* ... */ }
|
|
183
|
+
async delete(credentialId: string) { /* ... */ }
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import type { ChallengeStore, StoredChallenge } from '@passkeykit/server';
|
|
189
|
+
|
|
190
|
+
class RedisChallengeStore implements ChallengeStore {
|
|
191
|
+
async save(key: string, challenge: StoredChallenge) { /* ... */ }
|
|
192
|
+
async consume(key: string) { /* ... */ }
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
In **stateless mode**, you don't need a `ChallengeStore` at all — just set `encryptionKey`.
|
|
197
|
+
|
|
198
|
+
## Configuration
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
interface PasskeyServerConfig {
|
|
202
|
+
rpName: string; // Shown to users during WebAuthn ceremony
|
|
203
|
+
rpId: string; // Must match the domain (e.g. 'example.com')
|
|
204
|
+
allowedOrigins: string[]; // e.g. ['https://example.com']
|
|
205
|
+
credentialStore: CredentialStore;
|
|
206
|
+
|
|
207
|
+
// Stateless mode (default — pick one):
|
|
208
|
+
encryptionKey?: string; // 32+ char secret for AES-256-GCM challenge tokens
|
|
209
|
+
|
|
210
|
+
// Stateful mode (alternative):
|
|
211
|
+
challengeStore?: ChallengeStore;
|
|
212
|
+
|
|
213
|
+
// Optional:
|
|
214
|
+
challengeTTL?: number; // Challenge expiry in ms (default: 5 minutes)
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Exports
|
|
219
|
+
|
|
220
|
+
| Import Path | Contents |
|
|
221
|
+
|-------------|----------|
|
|
222
|
+
| `@passkeykit/server` | `PasskeyServer`, stores, password hashing, types |
|
|
223
|
+
| `@passkeykit/server/express` | `createExpressRoutes()` — ready-made Express router |
|
|
224
|
+
| `@passkeykit/server/argon2` | `hashPassword()`, `verifyPassword()` — native argon2id |
|
|
225
|
+
|
|
226
|
+
## Client Pairing
|
|
227
|
+
|
|
228
|
+
Use [`@passkeykit/client`](https://www.npmjs.com/package/@passkeykit/client) for the browser side. It handles the WebAuthn ceremony and `challengeToken` round-tripping automatically.
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
import { PasskeyClient } from '@passkeykit/client';
|
|
232
|
+
|
|
233
|
+
const client = new PasskeyClient({ serverUrl: '/api/auth/passkey' });
|
|
234
|
+
await client.register(userId, 'My Device');
|
|
235
|
+
await client.authenticate();
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Testing
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
npm test
|
|
242
|
+
npm run test:coverage
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT — [GitHub](https://github.com/dnldev/passkey-kit)
|
package/dist/esm/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. Run on a platform with native module support (Node.js, not serverless edge)
|
|
6
6
|
* 2. Want the absolute strongest password hash (argon2id > scrypt)
|
|
7
7
|
*
|
|
8
|
-
* Import: import { hashPassword, verifyPassword } from '
|
|
8
|
+
* Import: import { hashPassword, verifyPassword } from '@passkeykit/server/argon2'
|
|
9
9
|
*
|
|
10
10
|
* Most users should use the default scrypt export which works everywhere.
|
|
11
11
|
*/
|
package/dist/esm/password.js
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
|
|
7
7
|
*
|
|
8
8
|
* For users who want argon2id (requires native bindings), see the
|
|
9
|
-
*
|
|
9
|
+
* `@passkeykit/server/argon2` subpath export.
|
|
10
10
|
*
|
|
11
11
|
* Output format is PHC-like:
|
|
12
12
|
* $scrypt$ln=17,r=8,p=1$<base64salt>$<base64hash>
|
|
13
13
|
*/
|
|
14
14
|
import { scrypt as scryptSync } from '@noble/hashes/scrypt';
|
|
15
|
-
import { randomBytes } from 'crypto';
|
|
15
|
+
import { randomBytes, timingSafeEqual as tse } from 'crypto';
|
|
16
16
|
/** Default scrypt parameters (OWASP recommendations for interactive login) */
|
|
17
17
|
const DEFAULTS = {
|
|
18
18
|
N: 2 ** 17, // 131072 — CPU/memory cost
|
|
@@ -76,6 +76,5 @@ function parsePhc(phc) {
|
|
|
76
76
|
function timingSafeEqual(a, b) {
|
|
77
77
|
if (a.length !== b.length)
|
|
78
78
|
return false;
|
|
79
|
-
const { timingSafeEqual: tse } = require('crypto');
|
|
80
79
|
return tse(a, b);
|
|
81
80
|
}
|
package/dist/esm/types.js
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. Run on a platform with native module support (Node.js, not serverless edge)
|
|
6
6
|
* 2. Want the absolute strongest password hash (argon2id > scrypt)
|
|
7
7
|
*
|
|
8
|
-
* Import: import { hashPassword, verifyPassword } from '
|
|
8
|
+
* Import: import { hashPassword, verifyPassword } from '@passkeykit/server/argon2'
|
|
9
9
|
*
|
|
10
10
|
* Most users should use the default scrypt export which works everywhere.
|
|
11
11
|
*/
|
package/dist/password-argon2.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 1. Run on a platform with native module support (Node.js, not serverless edge)
|
|
7
7
|
* 2. Want the absolute strongest password hash (argon2id > scrypt)
|
|
8
8
|
*
|
|
9
|
-
* Import: import { hashPassword, verifyPassword } from '
|
|
9
|
+
* Import: import { hashPassword, verifyPassword } from '@passkeykit/server/argon2'
|
|
10
10
|
*
|
|
11
11
|
* Most users should use the default scrypt export which works everywhere.
|
|
12
12
|
*/
|
package/dist/password.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
|
|
7
7
|
*
|
|
8
8
|
* For users who want argon2id (requires native bindings), see the
|
|
9
|
-
*
|
|
9
|
+
* `@passkeykit/server/argon2` subpath export.
|
|
10
10
|
*
|
|
11
11
|
* Output format is PHC-like:
|
|
12
12
|
* $scrypt$ln=17,r=8,p=1$<base64salt>$<base64hash>
|
package/dist/password.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
|
|
8
8
|
*
|
|
9
9
|
* For users who want argon2id (requires native bindings), see the
|
|
10
|
-
*
|
|
10
|
+
* `@passkeykit/server/argon2` subpath export.
|
|
11
11
|
*
|
|
12
12
|
* Output format is PHC-like:
|
|
13
13
|
* $scrypt$ln=17,r=8,p=1$<base64salt>$<base64hash>
|
|
@@ -81,6 +81,5 @@ function parsePhc(phc) {
|
|
|
81
81
|
function timingSafeEqual(a, b) {
|
|
82
82
|
if (a.length !== b.length)
|
|
83
83
|
return false;
|
|
84
|
-
|
|
85
|
-
return tse(a, b);
|
|
84
|
+
return (0, crypto_1.timingSafeEqual)(a, b);
|
|
86
85
|
}
|
package/dist/types.d.ts
CHANGED
package/dist/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passkeykit/server",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "Server-side WebAuthn passkey verification
|
|
3
|
+
"version": "2.0.2",
|
|
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",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -23,8 +23,18 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
-
"dist"
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md"
|
|
27
28
|
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/dnldev/passkey-kit.git",
|
|
32
|
+
"directory": "packages/server"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/dnldev/passkey-kit/tree/main/packages/server#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/dnldev/passkey-kit/issues"
|
|
37
|
+
},
|
|
28
38
|
"scripts": {
|
|
29
39
|
"build": "tsc && tsc -p tsconfig.esm.json && echo '{\"type\":\"module\"}' > dist/esm/package.json",
|
|
30
40
|
"prepublishOnly": "npm run build"
|