@passkeykit/server 2.0.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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Built-in store implementations for common backends.
3
+ *
4
+ * @ai_context These are convenience implementations. For production with
5
+ * multiple server instances, use a shared store (Redis, database, Firestore).
6
+ * For single-server apps (like MovieBox, MediaBox), FileStore works great.
7
+ */
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
9
+ import { dirname } from 'path';
10
+ // ============================================================
11
+ // In-Memory Stores (good for development and single-process)
12
+ // ============================================================
13
+ export class MemoryChallengeStore {
14
+ challenges = new Map();
15
+ async save(key, challenge) {
16
+ this.challenges.set(key, challenge);
17
+ // Auto-cleanup expired challenges
18
+ setTimeout(() => this.challenges.delete(key), challenge.expiresAt - Date.now());
19
+ }
20
+ async consume(key) {
21
+ const challenge = this.challenges.get(key);
22
+ if (!challenge)
23
+ return null;
24
+ this.challenges.delete(key);
25
+ if (Date.now() > challenge.expiresAt)
26
+ return null;
27
+ return challenge;
28
+ }
29
+ }
30
+ export class MemoryCredentialStore {
31
+ credentials = [];
32
+ async save(credential) {
33
+ this.credentials.push(credential);
34
+ }
35
+ async getByUserId(userId) {
36
+ return this.credentials.filter(c => c.userId === userId);
37
+ }
38
+ async getByCredentialId(credentialId) {
39
+ return this.credentials.find(c => c.credentialId === credentialId) ?? null;
40
+ }
41
+ async updateCounter(credentialId, newCounter) {
42
+ const cred = this.credentials.find(c => c.credentialId === credentialId);
43
+ if (cred)
44
+ cred.counter = newCounter;
45
+ }
46
+ async delete(credentialId) {
47
+ this.credentials = this.credentials.filter(c => c.credentialId !== credentialId);
48
+ }
49
+ }
50
+ // ============================================================
51
+ // File-Based Stores (good for single-server, persistent)
52
+ // ============================================================
53
+ /**
54
+ * File-based challenge store. Challenges are stored in a JSON file.
55
+ * Auto-cleans expired challenges on every operation.
56
+ *
57
+ * @ai_context Used by MovieBox/MediaBox which store auth in auth.json.
58
+ * Not suitable for multi-process servers (race conditions on file writes).
59
+ */
60
+ export class FileChallengeStore {
61
+ filePath;
62
+ constructor(filePath) {
63
+ this.filePath = filePath;
64
+ const dir = dirname(filePath);
65
+ if (!existsSync(dir))
66
+ mkdirSync(dir, { recursive: true });
67
+ }
68
+ load() {
69
+ try {
70
+ return JSON.parse(readFileSync(this.filePath, 'utf-8'));
71
+ }
72
+ catch {
73
+ return {};
74
+ }
75
+ }
76
+ persist(data) {
77
+ // Clean expired
78
+ const now = Date.now();
79
+ for (const [key, val] of Object.entries(data)) {
80
+ if (now > val.expiresAt)
81
+ delete data[key];
82
+ }
83
+ writeFileSync(this.filePath, JSON.stringify(data, null, 2));
84
+ }
85
+ async save(key, challenge) {
86
+ const data = this.load();
87
+ data[key] = challenge;
88
+ this.persist(data);
89
+ }
90
+ async consume(key) {
91
+ const data = this.load();
92
+ const challenge = data[key];
93
+ if (!challenge)
94
+ return null;
95
+ delete data[key];
96
+ this.persist(data);
97
+ if (Date.now() > challenge.expiresAt)
98
+ return null;
99
+ return challenge;
100
+ }
101
+ }
102
+ /**
103
+ * File-based credential store. Credentials stored in a JSON array file.
104
+ */
105
+ export class FileCredentialStore {
106
+ filePath;
107
+ constructor(filePath) {
108
+ this.filePath = filePath;
109
+ const dir = dirname(filePath);
110
+ if (!existsSync(dir))
111
+ mkdirSync(dir, { recursive: true });
112
+ }
113
+ load() {
114
+ try {
115
+ return JSON.parse(readFileSync(this.filePath, 'utf-8'));
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ }
121
+ persist(data) {
122
+ writeFileSync(this.filePath, JSON.stringify(data, null, 2));
123
+ }
124
+ async save(credential) {
125
+ const data = this.load();
126
+ data.push(credential);
127
+ this.persist(data);
128
+ }
129
+ async getByUserId(userId) {
130
+ return this.load().filter(c => c.userId === userId);
131
+ }
132
+ async getByCredentialId(credentialId) {
133
+ return this.load().find(c => c.credentialId === credentialId) ?? null;
134
+ }
135
+ async updateCounter(credentialId, newCounter) {
136
+ const data = this.load();
137
+ const cred = data.find(c => c.credentialId === credentialId);
138
+ if (cred) {
139
+ cred.counter = newCounter;
140
+ this.persist(data);
141
+ }
142
+ }
143
+ async delete(credentialId) {
144
+ const data = this.load().filter(c => c.credentialId !== credentialId);
145
+ this.persist(data);
146
+ }
147
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Type definitions for passkey-kit-server
3
+ *
4
+ * @ai_context These types define the storage interface abstraction.
5
+ * Apps provide their own ChallengeStore and CredentialStore implementations
6
+ * so the library works with any backend (Firestore, file JSON, SQLite, etc).
7
+ */
8
+ export {};
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Ready-made Express routes for passkey registration and authentication.
3
+ *
4
+ * @ai_context This is a convenience wrapper. Apps that don't use Express
5
+ * can use PasskeyServer directly. These routes implement the standard
6
+ * challenge-response pattern with proper error handling.
7
+ *
8
+ * Usage:
9
+ * const routes = createExpressRoutes(passkeyServer, { getUserInfo });
10
+ * app.use('/api/auth/passkey', routes);
11
+ */
12
+ import { Router } from 'express';
13
+ import { PasskeyServer } from './passkey-server.js';
14
+ import type { UserInfo } from './types.js';
15
+ export interface ExpressRoutesConfig {
16
+ /**
17
+ * Resolve a user ID to UserInfo. Called during registration to get
18
+ * the user's name/displayName for the WebAuthn ceremony.
19
+ * Return null if user not found.
20
+ */
21
+ getUserInfo: (userId: string) => Promise<UserInfo | null>;
22
+ /**
23
+ * Called after successful registration. Use this to update your user
24
+ * record (e.g., mark as activated, store credential reference).
25
+ */
26
+ onRegistrationSuccess?: (userId: string, credentialId: string) => Promise<void>;
27
+ /**
28
+ * Called after successful authentication. Use this to create a session,
29
+ * JWT, or whatever your app uses for auth state.
30
+ * Return an object that will be merged into the response JSON.
31
+ */
32
+ onAuthenticationSuccess?: (userId: string, credentialId: string) => Promise<Record<string, unknown>>;
33
+ }
34
+ /**
35
+ * Create Express router with passkey registration and authentication routes.
36
+ *
37
+ * Routes:
38
+ * POST /register/options — Get registration challenge options
39
+ * POST /register/verify — Verify registration response
40
+ * POST /authenticate/options — Get authentication challenge options
41
+ * POST /authenticate/verify — Verify authentication response
42
+ */
43
+ export declare function createExpressRoutes(server: PasskeyServer, config: ExpressRoutesConfig): Router;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ /**
3
+ * Ready-made Express routes for passkey registration and authentication.
4
+ *
5
+ * @ai_context This is a convenience wrapper. Apps that don't use Express
6
+ * can use PasskeyServer directly. These routes implement the standard
7
+ * challenge-response pattern with proper error handling.
8
+ *
9
+ * Usage:
10
+ * const routes = createExpressRoutes(passkeyServer, { getUserInfo });
11
+ * app.use('/api/auth/passkey', routes);
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.createExpressRoutes = createExpressRoutes;
15
+ const express_1 = require("express");
16
+ /**
17
+ * Create Express router with passkey registration and authentication routes.
18
+ *
19
+ * Routes:
20
+ * POST /register/options — Get registration challenge options
21
+ * POST /register/verify — Verify registration response
22
+ * POST /authenticate/options — Get authentication challenge options
23
+ * POST /authenticate/verify — Verify authentication response
24
+ */
25
+ function createExpressRoutes(server, config) {
26
+ 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
+ router.post('/register/options', async (req, res) => {
33
+ try {
34
+ const { userId, authenticatorAttachment, residentKey, userVerification } = req.body;
35
+ if (!userId) {
36
+ res.status(400).json({ error: 'userId is required' });
37
+ return;
38
+ }
39
+ const user = await config.getUserInfo(userId);
40
+ if (!user) {
41
+ res.status(404).json({ error: 'User not found' });
42
+ return;
43
+ }
44
+ const options = await server.generateRegistrationOptions(user, {
45
+ authenticatorAttachment,
46
+ residentKey,
47
+ userVerification,
48
+ });
49
+ res.json(options);
50
+ }
51
+ catch (err) {
52
+ console.error('[passkey-kit] Registration options error:', err);
53
+ res.status(500).json({ error: 'Failed to generate registration options' });
54
+ }
55
+ });
56
+ /**
57
+ * POST /register/verify
58
+ * Body: { userId: string, response: RegistrationResponseJSON, credentialName?: string, challengeToken?: string }
59
+ * `challengeToken` is required in stateless mode.
60
+ */
61
+ router.post('/register/verify', async (req, res) => {
62
+ try {
63
+ const { userId, response, credentialName, challengeToken } = req.body;
64
+ if (!userId || !response) {
65
+ res.status(400).json({ error: 'userId and response are required' });
66
+ return;
67
+ }
68
+ const result = await server.verifyRegistration(userId, response, credentialName, challengeToken);
69
+ if (config.onRegistrationSuccess) {
70
+ await config.onRegistrationSuccess(userId, result.credential.credentialId);
71
+ }
72
+ res.json({
73
+ verified: result.verified,
74
+ credentialId: result.credential.credentialId,
75
+ credentialName: result.credential.name,
76
+ });
77
+ }
78
+ catch (err) {
79
+ const message = err instanceof Error ? err.message : 'Verification failed';
80
+ console.error('[passkey-kit] Registration verify error:', err);
81
+ res.status(400).json({ error: message });
82
+ }
83
+ });
84
+ /**
85
+ * POST /authenticate/options
86
+ * Body: { userId?: string }
87
+ * Response includes `sessionKey` (which IS the challengeToken in stateless mode).
88
+ */
89
+ router.post('/authenticate/options', async (req, res) => {
90
+ try {
91
+ const { userId, userVerification } = req.body;
92
+ const { options, sessionKey, challengeToken } = await server.generateAuthenticationOptions(userId, { userVerification });
93
+ res.json({ options, sessionKey, challengeToken });
94
+ }
95
+ catch (err) {
96
+ console.error('[passkey-kit] Authentication options error:', err);
97
+ res.status(500).json({ error: 'Failed to generate authentication options' });
98
+ }
99
+ });
100
+ /**
101
+ * POST /authenticate/verify
102
+ * Body: { sessionKey: string, response: AuthenticationResponseJSON }
103
+ */
104
+ router.post('/authenticate/verify', async (req, res) => {
105
+ try {
106
+ const { sessionKey, response } = req.body;
107
+ if (!sessionKey || !response) {
108
+ res.status(400).json({ error: 'sessionKey and response are required' });
109
+ return;
110
+ }
111
+ const result = await server.verifyAuthentication(sessionKey, response);
112
+ let extra = {};
113
+ if (config.onAuthenticationSuccess) {
114
+ extra = await config.onAuthenticationSuccess(result.userId, result.credentialId);
115
+ }
116
+ res.json({
117
+ verified: result.verified,
118
+ userId: result.userId,
119
+ credentialId: result.credentialId,
120
+ ...extra,
121
+ });
122
+ }
123
+ catch (err) {
124
+ const message = err instanceof Error ? err.message : 'Verification failed';
125
+ console.error('[passkey-kit] Authentication verify error:', err);
126
+ res.status(400).json({ error: message });
127
+ }
128
+ });
129
+ return router;
130
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * passkey-kit-server
3
+ *
4
+ * Server-side WebAuthn passkey verification with challenge-response pattern
5
+ * and scrypt password hashing (pure JS, works everywhere).
6
+ *
7
+ * @ai_context This is the core auth library used across all dnldev apps.
8
+ * Challenge generation and verification MUST happen server-side.
9
+ * Client never sees raw challenges — only attestation/assertion responses.
10
+ *
11
+ * Two modes:
12
+ * - **Stateless** (default): No server-side state. Set `encryptionKey` in config.
13
+ * - **Stateful**: Provide a `challengeStore` (memory, file, Redis, etc).
14
+ */
15
+ export { PasskeyServer } from './passkey-server.js';
16
+ export { hashPassword, verifyPassword, needsRehash } from './password.js';
17
+ export { sealChallengeToken, openChallengeToken } from './challenge-token.js';
18
+ export { MemoryChallengeStore, MemoryCredentialStore, FileChallengeStore, FileCredentialStore, } from './stores.js';
19
+ export type { PasskeyServerConfig, StoredCredential, StoredChallenge, ChallengeStore, CredentialStore, RegistrationResult, AuthenticationResult, UserInfo, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * passkey-kit-server
4
+ *
5
+ * Server-side WebAuthn passkey verification with challenge-response pattern
6
+ * and scrypt password hashing (pure JS, works everywhere).
7
+ *
8
+ * @ai_context This is the core auth library used across all dnldev apps.
9
+ * Challenge generation and verification MUST happen server-side.
10
+ * Client never sees raw challenges — only attestation/assertion responses.
11
+ *
12
+ * Two modes:
13
+ * - **Stateless** (default): No server-side state. Set `encryptionKey` in config.
14
+ * - **Stateful**: Provide a `challengeStore` (memory, file, Redis, etc).
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.FileCredentialStore = exports.FileChallengeStore = exports.MemoryCredentialStore = exports.MemoryChallengeStore = exports.openChallengeToken = exports.sealChallengeToken = exports.needsRehash = exports.verifyPassword = exports.hashPassword = exports.PasskeyServer = void 0;
18
+ var passkey_server_js_1 = require("./passkey-server.js");
19
+ Object.defineProperty(exports, "PasskeyServer", { enumerable: true, get: function () { return passkey_server_js_1.PasskeyServer; } });
20
+ var password_js_1 = require("./password.js");
21
+ Object.defineProperty(exports, "hashPassword", { enumerable: true, get: function () { return password_js_1.hashPassword; } });
22
+ Object.defineProperty(exports, "verifyPassword", { enumerable: true, get: function () { return password_js_1.verifyPassword; } });
23
+ Object.defineProperty(exports, "needsRehash", { enumerable: true, get: function () { return password_js_1.needsRehash; } });
24
+ var challenge_token_js_1 = require("./challenge-token.js");
25
+ Object.defineProperty(exports, "sealChallengeToken", { enumerable: true, get: function () { return challenge_token_js_1.sealChallengeToken; } });
26
+ Object.defineProperty(exports, "openChallengeToken", { enumerable: true, get: function () { return challenge_token_js_1.openChallengeToken; } });
27
+ var stores_js_1 = require("./stores.js");
28
+ Object.defineProperty(exports, "MemoryChallengeStore", { enumerable: true, get: function () { return stores_js_1.MemoryChallengeStore; } });
29
+ Object.defineProperty(exports, "MemoryCredentialStore", { enumerable: true, get: function () { return stores_js_1.MemoryCredentialStore; } });
30
+ Object.defineProperty(exports, "FileChallengeStore", { enumerable: true, get: function () { return stores_js_1.FileChallengeStore; } });
31
+ Object.defineProperty(exports, "FileCredentialStore", { enumerable: true, get: function () { return stores_js_1.FileCredentialStore; } });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * PasskeyServer — core WebAuthn server-side logic.
3
+ *
4
+ * @ai_context All challenge generation and attestation/assertion verification
5
+ * happens here. The client NEVER generates challenges — that's the key security
6
+ * fix over the old insecure pattern.
7
+ *
8
+ * Supports two challenge persistence modes:
9
+ * 1. **Stateless** (default): Challenge is encrypted into a signed token returned
10
+ * to the client. No server-side state required — works on Vercel, Cloudflare, etc.
11
+ * 2. **Stateful**: Challenge is stored in a ChallengeStore (memory, file, Redis, etc).
12
+ * Use this when you need server-side challenge revocation.
13
+ *
14
+ * The mode is selected automatically: if `challengeStore` is provided in config,
15
+ * stateful mode is used. Otherwise, `encryptionKey` must be provided for stateless.
16
+ *
17
+ * Flow:
18
+ * Registration: generateRegistrationOptions → client signs → verifyRegistration
19
+ * Authentication: generateAuthenticationOptions → client signs → verifyAuthentication
20
+ */
21
+ import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server';
22
+ import type { PasskeyServerConfig, RegistrationResult, AuthenticationResult, UserInfo } from './types.js';
23
+ export declare class PasskeyServer {
24
+ private rpName;
25
+ private rpId;
26
+ private allowedOrigins;
27
+ private challengeStore?;
28
+ private credentialStore;
29
+ private challengeTTL;
30
+ private encryptionKey?;
31
+ constructor(config: PasskeyServerConfig);
32
+ /**
33
+ * Step 1 of registration: Generate options for the client.
34
+ * Returns PublicKeyCredentialCreationOptions (JSON-serializable)
35
+ * plus a `challengeToken` for stateless verification.
36
+ */
37
+ generateRegistrationOptions(user: UserInfo, opts?: {
38
+ authenticatorAttachment?: 'platform' | 'cross-platform';
39
+ residentKey?: 'required' | 'preferred' | 'discouraged';
40
+ userVerification?: 'required' | 'preferred' | 'discouraged';
41
+ }): Promise<{
42
+ challengeToken: string | undefined;
43
+ rp: import("@simplewebauthn/server").PublicKeyCredentialRpEntity;
44
+ user: import("@simplewebauthn/server").PublicKeyCredentialUserEntityJSON;
45
+ challenge: import("@simplewebauthn/server").Base64URLString;
46
+ pubKeyCredParams: import("@simplewebauthn/server").PublicKeyCredentialParameters[];
47
+ timeout?: number;
48
+ excludeCredentials?: import("@simplewebauthn/server").PublicKeyCredentialDescriptorJSON[];
49
+ authenticatorSelection?: import("@simplewebauthn/server").AuthenticatorSelectionCriteria;
50
+ hints?: import("@simplewebauthn/server").PublicKeyCredentialHint[];
51
+ attestation?: import("@simplewebauthn/server").AttestationConveyancePreference;
52
+ attestationFormats?: import("@simplewebauthn/server").AttestationFormat[];
53
+ extensions?: import("@simplewebauthn/server").AuthenticationExtensionsClientInputs;
54
+ }>;
55
+ /**
56
+ * Step 2 of registration: Verify the client's attestation response.
57
+ *
58
+ * @param userId - User ID
59
+ * @param response - WebAuthn attestation response from the browser
60
+ * @param credentialName - Human-readable name for this credential
61
+ * @param challengeToken - The opaque token from step 1 (stateless mode)
62
+ */
63
+ verifyRegistration(userId: string, response: RegistrationResponseJSON, credentialName?: string, challengeToken?: string): Promise<RegistrationResult>;
64
+ /**
65
+ * Step 1 of authentication: Generate options for the client.
66
+ * If userId is provided, only that user's credentials are allowed.
67
+ * If not provided, uses discoverable credentials (resident keys).
68
+ */
69
+ generateAuthenticationOptions(userId?: string, opts?: {
70
+ userVerification?: 'required' | 'preferred' | 'discouraged';
71
+ }): Promise<{
72
+ options: import("@simplewebauthn/server").PublicKeyCredentialRequestOptionsJSON;
73
+ sessionKey: string;
74
+ challengeToken: string | undefined;
75
+ }>;
76
+ /**
77
+ * Step 2 of authentication: Verify the client's assertion response.
78
+ */
79
+ verifyAuthentication(sessionKey: string, response: AuthenticationResponseJSON): Promise<AuthenticationResult>;
80
+ }