@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,245 @@
1
+ "use strict";
2
+ /**
3
+ * PasskeyServer — core WebAuthn server-side logic.
4
+ *
5
+ * @ai_context All challenge generation and attestation/assertion verification
6
+ * happens here. The client NEVER generates challenges — that's the key security
7
+ * fix over the old insecure pattern.
8
+ *
9
+ * Supports two challenge persistence modes:
10
+ * 1. **Stateless** (default): Challenge is encrypted into a signed token returned
11
+ * to the client. No server-side state required — works on Vercel, Cloudflare, etc.
12
+ * 2. **Stateful**: Challenge is stored in a ChallengeStore (memory, file, Redis, etc).
13
+ * Use this when you need server-side challenge revocation.
14
+ *
15
+ * The mode is selected automatically: if `challengeStore` is provided in config,
16
+ * stateful mode is used. Otherwise, `encryptionKey` must be provided for stateless.
17
+ *
18
+ * Flow:
19
+ * Registration: generateRegistrationOptions → client signs → verifyRegistration
20
+ * Authentication: generateAuthenticationOptions → client signs → verifyAuthentication
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.PasskeyServer = void 0;
24
+ const server_1 = require("@simplewebauthn/server");
25
+ const helpers_1 = require("@simplewebauthn/server/helpers");
26
+ const challenge_token_js_1 = require("./challenge-token.js");
27
+ const DEFAULT_CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes
28
+ class PasskeyServer {
29
+ rpName;
30
+ rpId;
31
+ allowedOrigins;
32
+ challengeStore;
33
+ credentialStore;
34
+ challengeTTL;
35
+ encryptionKey;
36
+ constructor(config) {
37
+ this.rpName = config.rpName;
38
+ this.rpId = config.rpId;
39
+ this.allowedOrigins = config.allowedOrigins;
40
+ this.challengeStore = config.challengeStore;
41
+ this.credentialStore = config.credentialStore;
42
+ this.challengeTTL = config.challengeTTL ?? DEFAULT_CHALLENGE_TTL;
43
+ this.encryptionKey = config.encryptionKey;
44
+ if (!this.challengeStore && !this.encryptionKey) {
45
+ throw new Error('passkey-kit: Provide either `challengeStore` (stateful) or `encryptionKey` (stateless). ' +
46
+ 'For serverless, set encryptionKey to a random 32+ character secret.');
47
+ }
48
+ }
49
+ /**
50
+ * Step 1 of registration: Generate options for the client.
51
+ * Returns PublicKeyCredentialCreationOptions (JSON-serializable)
52
+ * plus a `challengeToken` for stateless verification.
53
+ */
54
+ async generateRegistrationOptions(user, opts) {
55
+ const existingCredentials = await this.credentialStore.getByUserId(user.id);
56
+ const options = await (0, server_1.generateRegistrationOptions)({
57
+ rpName: this.rpName,
58
+ rpID: this.rpId,
59
+ userName: user.name,
60
+ userDisplayName: user.displayName ?? user.name,
61
+ userID: new TextEncoder().encode(user.id),
62
+ attestationType: 'none',
63
+ excludeCredentials: existingCredentials.map(c => ({
64
+ id: c.credentialId,
65
+ transports: c.transports,
66
+ })),
67
+ authenticatorSelection: {
68
+ authenticatorAttachment: opts?.authenticatorAttachment,
69
+ residentKey: opts?.residentKey ?? 'preferred',
70
+ userVerification: opts?.userVerification ?? 'preferred',
71
+ },
72
+ });
73
+ let challengeToken;
74
+ if (this.challengeStore) {
75
+ // Stateful: persist challenge in store
76
+ await this.challengeStore.save(user.id, {
77
+ challenge: options.challenge,
78
+ userId: user.id,
79
+ expiresAt: Date.now() + this.challengeTTL,
80
+ type: 'registration',
81
+ });
82
+ }
83
+ else {
84
+ // Stateless: encrypt challenge into token
85
+ challengeToken = (0, challenge_token_js_1.sealChallengeToken)({
86
+ challenge: options.challenge,
87
+ userId: user.id,
88
+ type: 'registration',
89
+ exp: Date.now() + this.challengeTTL,
90
+ }, this.encryptionKey);
91
+ }
92
+ return { ...options, challengeToken };
93
+ }
94
+ /**
95
+ * Step 2 of registration: Verify the client's attestation response.
96
+ *
97
+ * @param userId - User ID
98
+ * @param response - WebAuthn attestation response from the browser
99
+ * @param credentialName - Human-readable name for this credential
100
+ * @param challengeToken - The opaque token from step 1 (stateless mode)
101
+ */
102
+ async verifyRegistration(userId, response, credentialName, challengeToken) {
103
+ let expectedChallenge;
104
+ if (this.challengeStore) {
105
+ const storedChallenge = await this.challengeStore.consume(userId);
106
+ if (!storedChallenge)
107
+ throw new Error('Challenge not found or expired');
108
+ if (storedChallenge.type !== 'registration')
109
+ throw new Error('Challenge type mismatch');
110
+ if (Date.now() > storedChallenge.expiresAt)
111
+ throw new Error('Challenge expired');
112
+ expectedChallenge = storedChallenge.challenge;
113
+ }
114
+ else {
115
+ if (!challengeToken)
116
+ throw new Error('challengeToken is required in stateless mode');
117
+ const payload = (0, challenge_token_js_1.openChallengeToken)(challengeToken, this.encryptionKey);
118
+ if (!payload)
119
+ throw new Error('Invalid or expired challenge token');
120
+ if (payload.type !== 'registration')
121
+ throw new Error('Challenge type mismatch');
122
+ if (payload.userId !== userId)
123
+ throw new Error('Challenge userId mismatch');
124
+ expectedChallenge = payload.challenge;
125
+ }
126
+ const verification = await (0, server_1.verifyRegistrationResponse)({
127
+ response,
128
+ expectedChallenge,
129
+ expectedOrigin: this.allowedOrigins,
130
+ expectedRPID: this.rpId,
131
+ });
132
+ if (!verification.verified || !verification.registrationInfo) {
133
+ throw new Error('Registration verification failed');
134
+ }
135
+ const { credential } = verification.registrationInfo;
136
+ const storedCredential = {
137
+ credentialId: credential.id,
138
+ publicKey: helpers_1.isoBase64URL.fromBuffer(credential.publicKey),
139
+ counter: credential.counter,
140
+ transports: response.response.transports ?? [],
141
+ name: credentialName ?? 'Passkey',
142
+ registeredAt: new Date().toISOString(),
143
+ userId,
144
+ };
145
+ await this.credentialStore.save(storedCredential);
146
+ return { credential: storedCredential, verified: true };
147
+ }
148
+ /**
149
+ * Step 1 of authentication: Generate options for the client.
150
+ * If userId is provided, only that user's credentials are allowed.
151
+ * If not provided, uses discoverable credentials (resident keys).
152
+ */
153
+ async generateAuthenticationOptions(userId, opts) {
154
+ let allowCredentials;
155
+ if (userId) {
156
+ const credentials = await this.credentialStore.getByUserId(userId);
157
+ allowCredentials = credentials.map(c => ({
158
+ id: c.credentialId,
159
+ transports: c.transports,
160
+ }));
161
+ }
162
+ const options = await (0, server_1.generateAuthenticationOptions)({
163
+ rpID: this.rpId,
164
+ allowCredentials,
165
+ userVerification: opts?.userVerification ?? 'preferred',
166
+ });
167
+ let sessionKey;
168
+ let challengeToken;
169
+ if (this.challengeStore) {
170
+ // Stateful: persist challenge
171
+ sessionKey = userId ?? `auth:${options.challenge}`;
172
+ await this.challengeStore.save(sessionKey, {
173
+ challenge: options.challenge,
174
+ userId,
175
+ expiresAt: Date.now() + this.challengeTTL,
176
+ type: 'authentication',
177
+ });
178
+ }
179
+ else {
180
+ // Stateless: encrypt into token (sessionKey IS the token)
181
+ challengeToken = (0, challenge_token_js_1.sealChallengeToken)({
182
+ challenge: options.challenge,
183
+ userId,
184
+ type: 'authentication',
185
+ exp: Date.now() + this.challengeTTL,
186
+ }, this.encryptionKey);
187
+ sessionKey = challengeToken;
188
+ }
189
+ return { options, sessionKey, challengeToken };
190
+ }
191
+ /**
192
+ * Step 2 of authentication: Verify the client's assertion response.
193
+ */
194
+ async verifyAuthentication(sessionKey, response) {
195
+ let expectedChallenge;
196
+ if (this.challengeStore) {
197
+ const storedChallenge = await this.challengeStore.consume(sessionKey);
198
+ if (!storedChallenge)
199
+ throw new Error('Challenge not found or expired');
200
+ if (storedChallenge.type !== 'authentication')
201
+ throw new Error('Challenge type mismatch');
202
+ if (Date.now() > storedChallenge.expiresAt)
203
+ throw new Error('Challenge expired');
204
+ expectedChallenge = storedChallenge.challenge;
205
+ }
206
+ else {
207
+ // In stateless mode, sessionKey IS the challengeToken
208
+ const payload = (0, challenge_token_js_1.openChallengeToken)(sessionKey, this.encryptionKey);
209
+ if (!payload)
210
+ throw new Error('Invalid or expired challenge token');
211
+ if (payload.type !== 'authentication')
212
+ throw new Error('Challenge type mismatch');
213
+ expectedChallenge = payload.challenge;
214
+ }
215
+ const credentialId = response.id;
216
+ const credential = await this.credentialStore.getByCredentialId(credentialId);
217
+ if (!credential) {
218
+ throw new Error('Credential not found');
219
+ }
220
+ const verification = await (0, server_1.verifyAuthenticationResponse)({
221
+ response,
222
+ expectedChallenge,
223
+ expectedOrigin: this.allowedOrigins,
224
+ expectedRPID: this.rpId,
225
+ credential: {
226
+ id: credential.credentialId,
227
+ publicKey: helpers_1.isoBase64URL.toBuffer(credential.publicKey),
228
+ counter: credential.counter,
229
+ transports: credential.transports,
230
+ },
231
+ });
232
+ if (!verification.verified) {
233
+ throw new Error('Authentication verification failed');
234
+ }
235
+ const newCounter = verification.authenticationInfo.newCounter;
236
+ await this.credentialStore.updateCounter(credentialId, newCounter);
237
+ return {
238
+ credentialId,
239
+ userId: credential.userId,
240
+ verified: true,
241
+ newCounter,
242
+ };
243
+ }
244
+ }
245
+ exports.PasskeyServer = PasskeyServer;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Password hashing using argon2id (native C++ bindings).
3
+ *
4
+ * @ai_context This is an OPTIONAL subpath export for users who:
5
+ * 1. Run on a platform with native module support (Node.js, not serverless edge)
6
+ * 2. Want the absolute strongest password hash (argon2id > scrypt)
7
+ *
8
+ * Import: import { hashPassword, verifyPassword } from 'passkey-kit-server/argon2'
9
+ *
10
+ * Most users should use the default scrypt export which works everywhere.
11
+ */
12
+ export declare function hashPassword(password: string, options?: {
13
+ memoryCost?: number;
14
+ timeCost?: number;
15
+ parallelism?: number;
16
+ }): Promise<string>;
17
+ export declare function verifyPassword(storedHash: string, password: string): Promise<boolean>;
18
+ export declare function needsRehash(storedHash: string, options?: {
19
+ memoryCost?: number;
20
+ timeCost?: number;
21
+ parallelism?: number;
22
+ }): boolean;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ /**
3
+ * Password hashing using argon2id (native C++ bindings).
4
+ *
5
+ * @ai_context This is an OPTIONAL subpath export for users who:
6
+ * 1. Run on a platform with native module support (Node.js, not serverless edge)
7
+ * 2. Want the absolute strongest password hash (argon2id > scrypt)
8
+ *
9
+ * Import: import { hashPassword, verifyPassword } from 'passkey-kit-server/argon2'
10
+ *
11
+ * Most users should use the default scrypt export which works everywhere.
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.hashPassword = hashPassword;
18
+ exports.verifyPassword = verifyPassword;
19
+ exports.needsRehash = needsRehash;
20
+ const argon2_1 = __importDefault(require("argon2"));
21
+ async function hashPassword(password, options) {
22
+ return argon2_1.default.hash(password, {
23
+ type: argon2_1.default.argon2id,
24
+ memoryCost: options?.memoryCost ?? 65536,
25
+ timeCost: options?.timeCost ?? 3,
26
+ parallelism: options?.parallelism ?? 4,
27
+ });
28
+ }
29
+ async function verifyPassword(storedHash, password) {
30
+ return argon2_1.default.verify(storedHash, password);
31
+ }
32
+ function needsRehash(storedHash, options) {
33
+ return argon2_1.default.needsRehash(storedHash, {
34
+ memoryCost: options?.memoryCost ?? 65536,
35
+ timeCost: options?.timeCost ?? 3,
36
+ parallelism: options?.parallelism ?? 4,
37
+ });
38
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Password hashing using scrypt (pure JavaScript via @noble/hashes).
3
+ *
4
+ * @ai_context Replaces the native argon2 module as the default.
5
+ * @noble/hashes is audited by Trail of Bits and works on every runtime:
6
+ * Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
7
+ *
8
+ * For users who want argon2id (requires native bindings), see the
9
+ * `passkey-kit-server/argon2` subpath export.
10
+ *
11
+ * Output format is PHC-like:
12
+ * $scrypt$ln=17,r=8,p=1$<base64salt>$<base64hash>
13
+ */
14
+ export interface ScryptOptions {
15
+ /** CPU/memory cost parameter (power of 2). Default: 2^17 */
16
+ N?: number;
17
+ /** Block size. Default: 8 */
18
+ r?: number;
19
+ /** Parallelism. Default: 1 */
20
+ p?: number;
21
+ }
22
+ /**
23
+ * Hash a password using scrypt.
24
+ * Returns a PHC-format string: $scrypt$ln=<log2N>,r=<r>,p=<p>$<salt>$<hash>
25
+ */
26
+ export declare function hashPassword(password: string, options?: ScryptOptions): Promise<string>;
27
+ /**
28
+ * Verify a password against a stored scrypt hash.
29
+ */
30
+ export declare function verifyPassword(storedHash: string, password: string): Promise<boolean>;
31
+ /**
32
+ * Check if a hash needs rehashing (params differ from current defaults).
33
+ */
34
+ export declare function needsRehash(storedHash: string, options?: ScryptOptions): boolean;
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * Password hashing using scrypt (pure JavaScript via @noble/hashes).
4
+ *
5
+ * @ai_context Replaces the native argon2 module as the default.
6
+ * @noble/hashes is audited by Trail of Bits and works on every runtime:
7
+ * Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
8
+ *
9
+ * For users who want argon2id (requires native bindings), see the
10
+ * `passkey-kit-server/argon2` subpath export.
11
+ *
12
+ * Output format is PHC-like:
13
+ * $scrypt$ln=17,r=8,p=1$<base64salt>$<base64hash>
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.hashPassword = hashPassword;
17
+ exports.verifyPassword = verifyPassword;
18
+ exports.needsRehash = needsRehash;
19
+ const scrypt_1 = require("@noble/hashes/scrypt");
20
+ const crypto_1 = require("crypto");
21
+ /** Default scrypt parameters (OWASP recommendations for interactive login) */
22
+ const DEFAULTS = {
23
+ N: 2 ** 17, // 131072 — CPU/memory cost
24
+ r: 8, // Block size
25
+ p: 1, // Parallelism
26
+ dkLen: 32, // Output key length
27
+ saltLen: 16, // Salt length
28
+ };
29
+ /**
30
+ * Hash a password using scrypt.
31
+ * Returns a PHC-format string: $scrypt$ln=<log2N>,r=<r>,p=<p>$<salt>$<hash>
32
+ */
33
+ async function hashPassword(password, options) {
34
+ const N = options?.N ?? DEFAULTS.N;
35
+ const r = options?.r ?? DEFAULTS.r;
36
+ const p = options?.p ?? DEFAULTS.p;
37
+ const salt = (0, crypto_1.randomBytes)(DEFAULTS.saltLen);
38
+ const hash = (0, scrypt_1.scrypt)(password, salt, { N, r, p, dkLen: DEFAULTS.dkLen });
39
+ const ln = Math.log2(N);
40
+ const saltB64 = Buffer.from(salt).toString('base64');
41
+ const hashB64 = Buffer.from(hash).toString('base64');
42
+ return `$scrypt$ln=${ln},r=${r},p=${p}$${saltB64}$${hashB64}`;
43
+ }
44
+ /**
45
+ * Verify a password against a stored scrypt hash.
46
+ */
47
+ async function verifyPassword(storedHash, password) {
48
+ const parsed = parsePhc(storedHash);
49
+ if (!parsed)
50
+ return false;
51
+ const { N, r, p, salt, hash } = parsed;
52
+ const derived = (0, scrypt_1.scrypt)(password, salt, { N, r, p, dkLen: hash.length });
53
+ return timingSafeEqual(Buffer.from(derived), hash);
54
+ }
55
+ /**
56
+ * Check if a hash needs rehashing (params differ from current defaults).
57
+ */
58
+ function needsRehash(storedHash, options) {
59
+ const parsed = parsePhc(storedHash);
60
+ if (!parsed)
61
+ return true;
62
+ const N = options?.N ?? DEFAULTS.N;
63
+ const r = options?.r ?? DEFAULTS.r;
64
+ const p = options?.p ?? DEFAULTS.p;
65
+ return parsed.N !== N || parsed.r !== r || parsed.p !== p;
66
+ }
67
+ // --- Internal helpers ---
68
+ function parsePhc(phc) {
69
+ // $scrypt$ln=17,r=8,p=1$<salt>$<hash>
70
+ const match = phc.match(/^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/);
71
+ if (!match)
72
+ return null;
73
+ return {
74
+ N: 2 ** parseInt(match[1], 10),
75
+ r: parseInt(match[2], 10),
76
+ p: parseInt(match[3], 10),
77
+ salt: Buffer.from(match[4], 'base64'),
78
+ hash: Buffer.from(match[5], 'base64'),
79
+ };
80
+ }
81
+ function timingSafeEqual(a, b) {
82
+ if (a.length !== b.length)
83
+ return false;
84
+ const { timingSafeEqual: tse } = require('crypto');
85
+ return tse(a, b);
86
+ }
@@ -0,0 +1,50 @@
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 type { ChallengeStore, CredentialStore, StoredChallenge, StoredCredential } from './types.js';
9
+ export declare class MemoryChallengeStore implements ChallengeStore {
10
+ private challenges;
11
+ save(key: string, challenge: StoredChallenge): Promise<void>;
12
+ consume(key: string): Promise<StoredChallenge | null>;
13
+ }
14
+ export declare class MemoryCredentialStore implements CredentialStore {
15
+ private credentials;
16
+ save(credential: StoredCredential): Promise<void>;
17
+ getByUserId(userId: string): Promise<StoredCredential[]>;
18
+ getByCredentialId(credentialId: string): Promise<StoredCredential | null>;
19
+ updateCounter(credentialId: string, newCounter: number): Promise<void>;
20
+ delete(credentialId: string): Promise<void>;
21
+ }
22
+ /**
23
+ * File-based challenge store. Challenges are stored in a JSON file.
24
+ * Auto-cleans expired challenges on every operation.
25
+ *
26
+ * @ai_context Used by MovieBox/MediaBox which store auth in auth.json.
27
+ * Not suitable for multi-process servers (race conditions on file writes).
28
+ */
29
+ export declare class FileChallengeStore implements ChallengeStore {
30
+ private filePath;
31
+ constructor(filePath: string);
32
+ private load;
33
+ private persist;
34
+ save(key: string, challenge: StoredChallenge): Promise<void>;
35
+ consume(key: string): Promise<StoredChallenge | null>;
36
+ }
37
+ /**
38
+ * File-based credential store. Credentials stored in a JSON array file.
39
+ */
40
+ export declare class FileCredentialStore implements CredentialStore {
41
+ private filePath;
42
+ constructor(filePath: string);
43
+ private load;
44
+ private persist;
45
+ save(credential: StoredCredential): Promise<void>;
46
+ getByUserId(userId: string): Promise<StoredCredential[]>;
47
+ getByCredentialId(credentialId: string): Promise<StoredCredential | null>;
48
+ updateCounter(credentialId: string, newCounter: number): Promise<void>;
49
+ delete(credentialId: string): Promise<void>;
50
+ }
package/dist/stores.js ADDED
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ /**
3
+ * Built-in store implementations for common backends.
4
+ *
5
+ * @ai_context These are convenience implementations. For production with
6
+ * multiple server instances, use a shared store (Redis, database, Firestore).
7
+ * For single-server apps (like MovieBox, MediaBox), FileStore works great.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.FileCredentialStore = exports.FileChallengeStore = exports.MemoryCredentialStore = exports.MemoryChallengeStore = void 0;
11
+ const fs_1 = require("fs");
12
+ const path_1 = require("path");
13
+ // ============================================================
14
+ // In-Memory Stores (good for development and single-process)
15
+ // ============================================================
16
+ class MemoryChallengeStore {
17
+ challenges = new Map();
18
+ async save(key, challenge) {
19
+ this.challenges.set(key, challenge);
20
+ // Auto-cleanup expired challenges
21
+ setTimeout(() => this.challenges.delete(key), challenge.expiresAt - Date.now());
22
+ }
23
+ async consume(key) {
24
+ const challenge = this.challenges.get(key);
25
+ if (!challenge)
26
+ return null;
27
+ this.challenges.delete(key);
28
+ if (Date.now() > challenge.expiresAt)
29
+ return null;
30
+ return challenge;
31
+ }
32
+ }
33
+ exports.MemoryChallengeStore = MemoryChallengeStore;
34
+ class MemoryCredentialStore {
35
+ credentials = [];
36
+ async save(credential) {
37
+ this.credentials.push(credential);
38
+ }
39
+ async getByUserId(userId) {
40
+ return this.credentials.filter(c => c.userId === userId);
41
+ }
42
+ async getByCredentialId(credentialId) {
43
+ return this.credentials.find(c => c.credentialId === credentialId) ?? null;
44
+ }
45
+ async updateCounter(credentialId, newCounter) {
46
+ const cred = this.credentials.find(c => c.credentialId === credentialId);
47
+ if (cred)
48
+ cred.counter = newCounter;
49
+ }
50
+ async delete(credentialId) {
51
+ this.credentials = this.credentials.filter(c => c.credentialId !== credentialId);
52
+ }
53
+ }
54
+ exports.MemoryCredentialStore = MemoryCredentialStore;
55
+ // ============================================================
56
+ // File-Based Stores (good for single-server, persistent)
57
+ // ============================================================
58
+ /**
59
+ * File-based challenge store. Challenges are stored in a JSON file.
60
+ * Auto-cleans expired challenges on every operation.
61
+ *
62
+ * @ai_context Used by MovieBox/MediaBox which store auth in auth.json.
63
+ * Not suitable for multi-process servers (race conditions on file writes).
64
+ */
65
+ class FileChallengeStore {
66
+ filePath;
67
+ constructor(filePath) {
68
+ this.filePath = filePath;
69
+ const dir = (0, path_1.dirname)(filePath);
70
+ if (!(0, fs_1.existsSync)(dir))
71
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
72
+ }
73
+ load() {
74
+ try {
75
+ return JSON.parse((0, fs_1.readFileSync)(this.filePath, 'utf-8'));
76
+ }
77
+ catch {
78
+ return {};
79
+ }
80
+ }
81
+ persist(data) {
82
+ // Clean expired
83
+ const now = Date.now();
84
+ for (const [key, val] of Object.entries(data)) {
85
+ if (now > val.expiresAt)
86
+ delete data[key];
87
+ }
88
+ (0, fs_1.writeFileSync)(this.filePath, JSON.stringify(data, null, 2));
89
+ }
90
+ async save(key, challenge) {
91
+ const data = this.load();
92
+ data[key] = challenge;
93
+ this.persist(data);
94
+ }
95
+ async consume(key) {
96
+ const data = this.load();
97
+ const challenge = data[key];
98
+ if (!challenge)
99
+ return null;
100
+ delete data[key];
101
+ this.persist(data);
102
+ if (Date.now() > challenge.expiresAt)
103
+ return null;
104
+ return challenge;
105
+ }
106
+ }
107
+ exports.FileChallengeStore = FileChallengeStore;
108
+ /**
109
+ * File-based credential store. Credentials stored in a JSON array file.
110
+ */
111
+ class FileCredentialStore {
112
+ filePath;
113
+ constructor(filePath) {
114
+ this.filePath = filePath;
115
+ const dir = (0, path_1.dirname)(filePath);
116
+ if (!(0, fs_1.existsSync)(dir))
117
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
118
+ }
119
+ load() {
120
+ try {
121
+ return JSON.parse((0, fs_1.readFileSync)(this.filePath, 'utf-8'));
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ persist(data) {
128
+ (0, fs_1.writeFileSync)(this.filePath, JSON.stringify(data, null, 2));
129
+ }
130
+ async save(credential) {
131
+ const data = this.load();
132
+ data.push(credential);
133
+ this.persist(data);
134
+ }
135
+ async getByUserId(userId) {
136
+ return this.load().filter(c => c.userId === userId);
137
+ }
138
+ async getByCredentialId(credentialId) {
139
+ return this.load().find(c => c.credentialId === credentialId) ?? null;
140
+ }
141
+ async updateCounter(credentialId, newCounter) {
142
+ const data = this.load();
143
+ const cred = data.find(c => c.credentialId === credentialId);
144
+ if (cred) {
145
+ cred.counter = newCounter;
146
+ this.persist(data);
147
+ }
148
+ }
149
+ async delete(credentialId) {
150
+ const data = this.load().filter(c => c.credentialId !== credentialId);
151
+ this.persist(data);
152
+ }
153
+ }
154
+ exports.FileCredentialStore = FileCredentialStore;