@ursalock/server 0.2.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.
- package/dist/chunk-BYTMLM3Q.js +1048 -0
- package/dist/index.js +1251 -0
- package/dist/server.js +45 -0
- package/package.json +66 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
// src/app.ts
|
|
2
|
+
import { Hono as Hono5 } from "hono";
|
|
3
|
+
import { cors } from "hono/cors";
|
|
4
|
+
import { logger } from "hono/logger";
|
|
5
|
+
import { ZodError } from "zod";
|
|
6
|
+
|
|
7
|
+
// src/api/auth/router.ts
|
|
8
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
9
|
+
import { Hono as Hono3 } from "hono";
|
|
10
|
+
import { zValidator as zValidator3 } from "@hono/zod-validator";
|
|
11
|
+
|
|
12
|
+
// src/db/client.ts
|
|
13
|
+
import Database from "better-sqlite3";
|
|
14
|
+
|
|
15
|
+
// src/env.ts
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
var numeric = z.string().transform((val) => Number.parseInt(val, 10));
|
|
18
|
+
var envSchema = {
|
|
19
|
+
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
|
20
|
+
PORT: numeric.default("3456"),
|
|
21
|
+
/** SQLite database file path */
|
|
22
|
+
DATABASE_PATH: z.string().default("./data/vault.db"),
|
|
23
|
+
/** JWT secret for signing tokens (32+ bytes recommended) */
|
|
24
|
+
JWT_SECRET: z.string().min(32),
|
|
25
|
+
/** JWT token expiry in seconds (default: 7 days) */
|
|
26
|
+
JWT_EXPIRY: numeric.default("604800"),
|
|
27
|
+
/** Relying Party name shown during passkey registration */
|
|
28
|
+
RP_NAME: z.string().default("ursalock"),
|
|
29
|
+
/**
|
|
30
|
+
* Allowed origins for WebAuthn (comma-separated)
|
|
31
|
+
* e.g., "https://app1.example.com,https://app2.example.com"
|
|
32
|
+
* The RP_ID is derived from the hostname of the validated origin
|
|
33
|
+
*/
|
|
34
|
+
RP_ORIGINS: z.string().default("http://localhost:5173")
|
|
35
|
+
};
|
|
36
|
+
var skipEnvValidation = process.env["NODE_ENV"] === "test";
|
|
37
|
+
var env = (() => {
|
|
38
|
+
if (skipEnvValidation) {
|
|
39
|
+
return Object.fromEntries(
|
|
40
|
+
Object.entries(envSchema).map(([key, schema]) => {
|
|
41
|
+
const result = schema.safeParse(process.env[key]);
|
|
42
|
+
return [key, result.success ? result.data : void 0];
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const parsed = z.object(envSchema).safeParse(process.env);
|
|
47
|
+
if (!parsed.success) {
|
|
48
|
+
const errors2 = parsed.error.errors.map((e) => ` ${e.path.join(".")}: ${e.message}`).join("\n");
|
|
49
|
+
throw new Error(`Invalid environment variables:
|
|
50
|
+
${errors2}`);
|
|
51
|
+
}
|
|
52
|
+
return parsed.data;
|
|
53
|
+
})();
|
|
54
|
+
|
|
55
|
+
// src/db/schema.ts
|
|
56
|
+
var CREATE_TABLES_SQL = `
|
|
57
|
+
-- Users table
|
|
58
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
uid TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
|
|
61
|
+
email TEXT UNIQUE,
|
|
62
|
+
password_hash TEXT,
|
|
63
|
+
opaque_id TEXT UNIQUE,
|
|
64
|
+
display_name TEXT,
|
|
65
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
66
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_users_uid ON users(uid);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_users_opaque_id ON users(opaque_id);
|
|
72
|
+
|
|
73
|
+
-- Passkeys table (WebAuthn credentials) - kept for legacy support
|
|
74
|
+
CREATE TABLE IF NOT EXISTS passkeys (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
77
|
+
credential_id TEXT NOT NULL UNIQUE,
|
|
78
|
+
public_key TEXT NOT NULL,
|
|
79
|
+
counter INTEGER NOT NULL DEFAULT 0,
|
|
80
|
+
device_type TEXT NOT NULL DEFAULT 'singleDevice',
|
|
81
|
+
backed_up INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
transports TEXT,
|
|
83
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id);
|
|
88
|
+
|
|
89
|
+
-- Sessions table
|
|
90
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
91
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
93
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
94
|
+
expires_at INTEGER NOT NULL,
|
|
95
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
|
100
|
+
|
|
101
|
+
-- Vaults table (encrypted blobs)
|
|
102
|
+
CREATE TABLE IF NOT EXISTS vaults (
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
uid TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
|
|
105
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
106
|
+
name TEXT NOT NULL,
|
|
107
|
+
data TEXT NOT NULL,
|
|
108
|
+
salt TEXT NOT NULL,
|
|
109
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
110
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
111
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_vaults_uid ON vaults(uid);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_vaults_user_id ON vaults(user_id);
|
|
116
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_vaults_user_name ON vaults(user_id, name);
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
// src/db/client.ts
|
|
120
|
+
var _db = null;
|
|
121
|
+
function getDb() {
|
|
122
|
+
if (!_db) {
|
|
123
|
+
_db = new Database(env.DATABASE_PATH);
|
|
124
|
+
_db.pragma("journal_mode = WAL");
|
|
125
|
+
_db.pragma("foreign_keys = ON");
|
|
126
|
+
_db.exec(CREATE_TABLES_SQL);
|
|
127
|
+
runMigrations(_db);
|
|
128
|
+
}
|
|
129
|
+
return _db;
|
|
130
|
+
}
|
|
131
|
+
function runMigrations(db) {
|
|
132
|
+
const columns = db.pragma("table_info(users)");
|
|
133
|
+
const hasOpaqueId = columns.some((col) => col.name === "opaque_id");
|
|
134
|
+
if (!hasOpaqueId) {
|
|
135
|
+
try {
|
|
136
|
+
db.exec(`
|
|
137
|
+
ALTER TABLE users ADD COLUMN opaque_id TEXT UNIQUE;
|
|
138
|
+
ALTER TABLE users ADD COLUMN display_name TEXT;
|
|
139
|
+
`);
|
|
140
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_users_opaque_id ON users(opaque_id);");
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function closeDb() {
|
|
146
|
+
if (_db) {
|
|
147
|
+
_db.close();
|
|
148
|
+
_db = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function createUser(input) {
|
|
152
|
+
const db = getDb();
|
|
153
|
+
const stmt = db.prepare(`
|
|
154
|
+
INSERT INTO users (email, password_hash, opaque_id, display_name)
|
|
155
|
+
VALUES (?, ?, ?, ?)
|
|
156
|
+
RETURNING id, uid, email, password_hash as passwordHash,
|
|
157
|
+
opaque_id as opaqueId, display_name as displayName,
|
|
158
|
+
created_at as createdAt, updated_at as updatedAt
|
|
159
|
+
`);
|
|
160
|
+
return stmt.get(
|
|
161
|
+
input.email ?? null,
|
|
162
|
+
input.passwordHash ?? null,
|
|
163
|
+
input.opaqueId ?? null,
|
|
164
|
+
input.displayName ?? null
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
function getUserByEmail(email) {
|
|
168
|
+
const db = getDb();
|
|
169
|
+
const stmt = db.prepare(`
|
|
170
|
+
SELECT id, uid, email, password_hash as passwordHash,
|
|
171
|
+
opaque_id as opaqueId, display_name as displayName,
|
|
172
|
+
created_at as createdAt, updated_at as updatedAt
|
|
173
|
+
FROM users WHERE email = ?
|
|
174
|
+
`);
|
|
175
|
+
return stmt.get(email);
|
|
176
|
+
}
|
|
177
|
+
function getUserByOpaqueId(opaqueId) {
|
|
178
|
+
const db = getDb();
|
|
179
|
+
const stmt = db.prepare(`
|
|
180
|
+
SELECT id, uid, email, password_hash as passwordHash,
|
|
181
|
+
opaque_id as opaqueId, display_name as displayName,
|
|
182
|
+
created_at as createdAt, updated_at as updatedAt
|
|
183
|
+
FROM users WHERE opaque_id = ?
|
|
184
|
+
`);
|
|
185
|
+
return stmt.get(opaqueId);
|
|
186
|
+
}
|
|
187
|
+
function createPasskey(input) {
|
|
188
|
+
const db = getDb();
|
|
189
|
+
const stmt = db.prepare(`
|
|
190
|
+
INSERT INTO passkeys (user_id, credential_id, public_key, counter, device_type, backed_up, transports)
|
|
191
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
192
|
+
RETURNING id, user_id as userId, credential_id as credentialId, public_key as publicKey,
|
|
193
|
+
counter, device_type as deviceType, backed_up as backedUp, transports, created_at as createdAt
|
|
194
|
+
`);
|
|
195
|
+
const result = stmt.get(
|
|
196
|
+
input.userId,
|
|
197
|
+
input.credentialId,
|
|
198
|
+
input.publicKey,
|
|
199
|
+
input.counter,
|
|
200
|
+
input.deviceType,
|
|
201
|
+
input.backedUp ? 1 : 0,
|
|
202
|
+
input.transports?.join(",") ?? null
|
|
203
|
+
);
|
|
204
|
+
return { ...result, backedUp: Boolean(result.backedUp) };
|
|
205
|
+
}
|
|
206
|
+
function getPasskeyByCredentialId(credentialId) {
|
|
207
|
+
const db = getDb();
|
|
208
|
+
const stmt = db.prepare(`
|
|
209
|
+
SELECT
|
|
210
|
+
p.id, p.user_id as userId, p.credential_id as credentialId, p.public_key as publicKey,
|
|
211
|
+
p.counter, p.device_type as deviceType, p.backed_up as backedUp, p.transports, p.created_at as createdAt,
|
|
212
|
+
u.id as "user.id", u.uid as "user.uid", u.email as "user.email"
|
|
213
|
+
FROM passkeys p
|
|
214
|
+
JOIN users u ON p.user_id = u.id
|
|
215
|
+
WHERE p.credential_id = ?
|
|
216
|
+
`);
|
|
217
|
+
const row = stmt.get(credentialId);
|
|
218
|
+
if (!row) return void 0;
|
|
219
|
+
return {
|
|
220
|
+
id: row["id"],
|
|
221
|
+
userId: row["userId"],
|
|
222
|
+
credentialId: row["credentialId"],
|
|
223
|
+
publicKey: row["publicKey"],
|
|
224
|
+
counter: row["counter"],
|
|
225
|
+
deviceType: row["deviceType"],
|
|
226
|
+
backedUp: Boolean(row["backedUp"]),
|
|
227
|
+
transports: row["transports"],
|
|
228
|
+
createdAt: row["createdAt"],
|
|
229
|
+
user: {
|
|
230
|
+
id: row["user.id"],
|
|
231
|
+
uid: row["user.uid"],
|
|
232
|
+
email: row["user.email"],
|
|
233
|
+
passwordHash: null,
|
|
234
|
+
opaqueId: null,
|
|
235
|
+
displayName: null,
|
|
236
|
+
createdAt: 0,
|
|
237
|
+
updatedAt: 0
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function getPasskeysByUserId(userId) {
|
|
242
|
+
const db = getDb();
|
|
243
|
+
const stmt = db.prepare(`
|
|
244
|
+
SELECT id, user_id as userId, credential_id as credentialId, public_key as publicKey,
|
|
245
|
+
counter, device_type as deviceType, backed_up as backedUp, transports, created_at as createdAt
|
|
246
|
+
FROM passkeys WHERE user_id = ?
|
|
247
|
+
`);
|
|
248
|
+
return stmt.all(userId).map((p) => ({ ...p, backedUp: Boolean(p.backedUp) }));
|
|
249
|
+
}
|
|
250
|
+
function updatePasskeyCounter(credentialId, counter) {
|
|
251
|
+
const db = getDb();
|
|
252
|
+
const stmt = db.prepare(`UPDATE passkeys SET counter = ? WHERE credential_id = ?`);
|
|
253
|
+
stmt.run(counter, credentialId);
|
|
254
|
+
}
|
|
255
|
+
function createSession(input) {
|
|
256
|
+
const db = getDb();
|
|
257
|
+
const stmt = db.prepare(`
|
|
258
|
+
INSERT INTO sessions (user_id, token_hash, expires_at)
|
|
259
|
+
VALUES (?, ?, ?)
|
|
260
|
+
RETURNING id, user_id as userId, token_hash as tokenHash, expires_at as expiresAt, created_at as createdAt
|
|
261
|
+
`);
|
|
262
|
+
return stmt.get(input.userId, input.tokenHash, input.expiresAt);
|
|
263
|
+
}
|
|
264
|
+
function getSessionByTokenHash(tokenHash) {
|
|
265
|
+
const db = getDb();
|
|
266
|
+
const stmt = db.prepare(`
|
|
267
|
+
SELECT
|
|
268
|
+
s.id, s.user_id as userId, s.token_hash as tokenHash, s.expires_at as expiresAt, s.created_at as createdAt,
|
|
269
|
+
u.id as "user.id", u.uid as "user.uid", u.email as "user.email"
|
|
270
|
+
FROM sessions s
|
|
271
|
+
JOIN users u ON s.user_id = u.id
|
|
272
|
+
WHERE s.token_hash = ? AND s.expires_at > unixepoch()
|
|
273
|
+
`);
|
|
274
|
+
const row = stmt.get(tokenHash);
|
|
275
|
+
if (!row) return void 0;
|
|
276
|
+
return {
|
|
277
|
+
id: row["id"],
|
|
278
|
+
userId: row["userId"],
|
|
279
|
+
tokenHash: row["tokenHash"],
|
|
280
|
+
expiresAt: row["expiresAt"],
|
|
281
|
+
createdAt: row["createdAt"],
|
|
282
|
+
user: {
|
|
283
|
+
id: row["user.id"],
|
|
284
|
+
uid: row["user.uid"],
|
|
285
|
+
email: row["user.email"],
|
|
286
|
+
passwordHash: null,
|
|
287
|
+
opaqueId: null,
|
|
288
|
+
displayName: null,
|
|
289
|
+
createdAt: 0,
|
|
290
|
+
updatedAt: 0
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function deleteSession(tokenHash) {
|
|
295
|
+
const db = getDb();
|
|
296
|
+
const stmt = db.prepare(`DELETE FROM sessions WHERE token_hash = ?`);
|
|
297
|
+
stmt.run(tokenHash);
|
|
298
|
+
}
|
|
299
|
+
function createVault(input) {
|
|
300
|
+
const db = getDb();
|
|
301
|
+
const stmt = db.prepare(`
|
|
302
|
+
INSERT INTO vaults (user_id, name, data, salt, version)
|
|
303
|
+
VALUES (?, ?, ?, ?, ?)
|
|
304
|
+
RETURNING id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
|
|
305
|
+
`);
|
|
306
|
+
return stmt.get(input.userId, input.name, input.data, input.salt, input.version ?? 1);
|
|
307
|
+
}
|
|
308
|
+
function getVaultByUid(uid, userId) {
|
|
309
|
+
const db = getDb();
|
|
310
|
+
const stmt = db.prepare(`
|
|
311
|
+
SELECT id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
|
|
312
|
+
FROM vaults WHERE uid = ? AND user_id = ?
|
|
313
|
+
`);
|
|
314
|
+
return stmt.get(uid, userId);
|
|
315
|
+
}
|
|
316
|
+
function getVaultByName(name, userId) {
|
|
317
|
+
const db = getDb();
|
|
318
|
+
const stmt = db.prepare(`
|
|
319
|
+
SELECT id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
|
|
320
|
+
FROM vaults WHERE name = ? AND user_id = ?
|
|
321
|
+
`);
|
|
322
|
+
return stmt.get(name, userId);
|
|
323
|
+
}
|
|
324
|
+
function getVaultsByUserId(userId) {
|
|
325
|
+
const db = getDb();
|
|
326
|
+
const stmt = db.prepare(`
|
|
327
|
+
SELECT id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
|
|
328
|
+
FROM vaults WHERE user_id = ?
|
|
329
|
+
ORDER BY updated_at DESC
|
|
330
|
+
`);
|
|
331
|
+
return stmt.all(userId);
|
|
332
|
+
}
|
|
333
|
+
function updateVault(uid, userId, input) {
|
|
334
|
+
const db = getDb();
|
|
335
|
+
const stmt = db.prepare(`
|
|
336
|
+
UPDATE vaults SET data = ?, salt = ?, version = COALESCE(?, version), updated_at = unixepoch()
|
|
337
|
+
WHERE uid = ? AND user_id = ?
|
|
338
|
+
RETURNING id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
|
|
339
|
+
`);
|
|
340
|
+
return stmt.get(input.data, input.salt, input.version ?? null, uid, userId);
|
|
341
|
+
}
|
|
342
|
+
function deleteVault(uid, userId) {
|
|
343
|
+
const db = getDb();
|
|
344
|
+
const stmt = db.prepare(`DELETE FROM vaults WHERE uid = ? AND user_id = ?`);
|
|
345
|
+
return stmt.run(uid, userId).changes > 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/features/auth/jwt.ts
|
|
349
|
+
import { createHash } from "crypto";
|
|
350
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
351
|
+
function getSecretKey() {
|
|
352
|
+
return new TextEncoder().encode(env.JWT_SECRET);
|
|
353
|
+
}
|
|
354
|
+
async function createToken(payload) {
|
|
355
|
+
const jti = crypto.randomUUID();
|
|
356
|
+
const jwt = new SignJWT({ email: payload.email }).setSubject(payload.userId).setJti(jti).setIssuedAt().setExpirationTime(`${env.JWT_EXPIRY}s`).setProtectedHeader({ alg: "HS256" });
|
|
357
|
+
return jwt.sign(getSecretKey());
|
|
358
|
+
}
|
|
359
|
+
async function verifyToken(token) {
|
|
360
|
+
try {
|
|
361
|
+
const { payload } = await jwtVerify(token, getSecretKey());
|
|
362
|
+
if (!payload.sub) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return payload;
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function hashToken(token) {
|
|
371
|
+
return createHash("sha256").update(token).digest("hex");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/features/auth/middleware.ts
|
|
375
|
+
import { createMiddleware } from "hono/factory";
|
|
376
|
+
|
|
377
|
+
// src/errors.ts
|
|
378
|
+
import { z as z2 } from "zod";
|
|
379
|
+
var ErrorCode = z2.enum([
|
|
380
|
+
// Auth errors
|
|
381
|
+
"unauthorized",
|
|
382
|
+
"invalid_credentials",
|
|
383
|
+
"email_already_exists",
|
|
384
|
+
"passkey_not_found",
|
|
385
|
+
"session_expired",
|
|
386
|
+
"invalid_origin",
|
|
387
|
+
// Vault errors
|
|
388
|
+
"vault_not_found",
|
|
389
|
+
"vault_already_exists",
|
|
390
|
+
"invalid_vault_data",
|
|
391
|
+
// Validation errors
|
|
392
|
+
"validation_error",
|
|
393
|
+
"invalid_request",
|
|
394
|
+
// Server errors
|
|
395
|
+
"internal_error"
|
|
396
|
+
]);
|
|
397
|
+
var ApiException = class extends Error {
|
|
398
|
+
constructor(error, status = 400) {
|
|
399
|
+
super(error.message);
|
|
400
|
+
this.error = error;
|
|
401
|
+
this.status = status;
|
|
402
|
+
this.name = "ApiException";
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
var errors = {
|
|
406
|
+
// Auth errors
|
|
407
|
+
unauthorized: { code: "unauthorized", message: "Unauthorized" },
|
|
408
|
+
invalid_credentials: { code: "invalid_credentials", message: "Invalid email or password" },
|
|
409
|
+
email_already_exists: { code: "email_already_exists", message: "Email already registered" },
|
|
410
|
+
passkey_not_found: { code: "passkey_not_found", message: "Passkey not found" },
|
|
411
|
+
session_expired: { code: "session_expired", message: "Session expired" },
|
|
412
|
+
invalid_origin: { code: "invalid_origin", message: "Origin not allowed" },
|
|
413
|
+
// Vault errors
|
|
414
|
+
vault_not_found: { code: "vault_not_found", message: "Vault not found" },
|
|
415
|
+
vault_already_exists: (name) => ({
|
|
416
|
+
code: "vault_already_exists",
|
|
417
|
+
message: `Vault "${name}" already exists`
|
|
418
|
+
}),
|
|
419
|
+
invalid_vault_data: { code: "invalid_vault_data", message: "Invalid vault data" },
|
|
420
|
+
// Validation errors
|
|
421
|
+
validation_error: (details) => ({
|
|
422
|
+
code: "validation_error",
|
|
423
|
+
message: details
|
|
424
|
+
}),
|
|
425
|
+
invalid_request: { code: "invalid_request", message: "Invalid request" },
|
|
426
|
+
// Server errors
|
|
427
|
+
internal_error: { code: "internal_error", message: "Internal server error" }
|
|
428
|
+
};
|
|
429
|
+
function getError(code, arg) {
|
|
430
|
+
const factory = errors[code];
|
|
431
|
+
if (typeof factory === "function") {
|
|
432
|
+
return factory(arg ?? "");
|
|
433
|
+
}
|
|
434
|
+
return factory;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/features/auth/middleware.ts
|
|
438
|
+
var optionalAuthMiddleware = createMiddleware(async (c, next) => {
|
|
439
|
+
const authHeader = c.req.header("Authorization");
|
|
440
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
441
|
+
c.set("session", null);
|
|
442
|
+
return next();
|
|
443
|
+
}
|
|
444
|
+
const token = authHeader.slice(7);
|
|
445
|
+
const payload = await verifyToken(token);
|
|
446
|
+
if (!payload?.sub) {
|
|
447
|
+
c.set("session", null);
|
|
448
|
+
return next();
|
|
449
|
+
}
|
|
450
|
+
const tokenHash = hashToken(token);
|
|
451
|
+
const session = getSessionByTokenHash(tokenHash);
|
|
452
|
+
if (!session) {
|
|
453
|
+
c.set("session", null);
|
|
454
|
+
return next();
|
|
455
|
+
}
|
|
456
|
+
c.set("session", {
|
|
457
|
+
user: {
|
|
458
|
+
id: session.user.id,
|
|
459
|
+
uid: session.user.uid,
|
|
460
|
+
email: session.user.email
|
|
461
|
+
},
|
|
462
|
+
sessionId: session.id
|
|
463
|
+
});
|
|
464
|
+
return next();
|
|
465
|
+
});
|
|
466
|
+
var requireAuthMiddleware = createMiddleware(async (c, next) => {
|
|
467
|
+
const authHeader = c.req.header("Authorization");
|
|
468
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
469
|
+
throw new ApiException(getError("unauthorized"), 401);
|
|
470
|
+
}
|
|
471
|
+
const token = authHeader.slice(7);
|
|
472
|
+
const payload = await verifyToken(token);
|
|
473
|
+
if (!payload?.sub) {
|
|
474
|
+
throw new ApiException(getError("unauthorized"), 401);
|
|
475
|
+
}
|
|
476
|
+
const tokenHash = hashToken(token);
|
|
477
|
+
const session = getSessionByTokenHash(tokenHash);
|
|
478
|
+
if (!session) {
|
|
479
|
+
throw new ApiException(getError("session_expired"), 401);
|
|
480
|
+
}
|
|
481
|
+
c.set("session", {
|
|
482
|
+
user: {
|
|
483
|
+
id: session.user.id,
|
|
484
|
+
uid: session.user.uid,
|
|
485
|
+
email: session.user.email
|
|
486
|
+
},
|
|
487
|
+
sessionId: session.id
|
|
488
|
+
});
|
|
489
|
+
return next();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// src/api/schemas.ts
|
|
493
|
+
import { z as z3 } from "zod";
|
|
494
|
+
var UserResponse = z3.object({
|
|
495
|
+
id: z3.string(),
|
|
496
|
+
email: z3.string().email().nullable(),
|
|
497
|
+
createdAt: z3.number()
|
|
498
|
+
});
|
|
499
|
+
var EmailRegisterRequest = z3.object({
|
|
500
|
+
email: z3.string().email(),
|
|
501
|
+
password: z3.string().min(8, "Password must be at least 8 characters")
|
|
502
|
+
});
|
|
503
|
+
var EmailLoginRequest = z3.object({
|
|
504
|
+
email: z3.string().email(),
|
|
505
|
+
password: z3.string().min(1)
|
|
506
|
+
});
|
|
507
|
+
var AuthResponse = z3.object({
|
|
508
|
+
user: UserResponse,
|
|
509
|
+
token: z3.string(),
|
|
510
|
+
recoveryKey: z3.string().optional()
|
|
511
|
+
});
|
|
512
|
+
var RefreshResponse = z3.object({
|
|
513
|
+
token: z3.string(),
|
|
514
|
+
expiresIn: z3.number()
|
|
515
|
+
});
|
|
516
|
+
var MeResponse = z3.object({
|
|
517
|
+
user: UserResponse
|
|
518
|
+
});
|
|
519
|
+
var PasskeyRegisterOptionsRequest = z3.object({
|
|
520
|
+
email: z3.string().email().optional()
|
|
521
|
+
});
|
|
522
|
+
var PasskeyRegisterVerifyRequest = z3.object({
|
|
523
|
+
email: z3.string().email().optional(),
|
|
524
|
+
credential: z3.record(z3.unknown())
|
|
525
|
+
});
|
|
526
|
+
var PasskeyLoginOptionsRequest = z3.object({
|
|
527
|
+
email: z3.string().email().optional()
|
|
528
|
+
});
|
|
529
|
+
var PasskeyLoginVerifyRequest = z3.object({
|
|
530
|
+
credential: z3.record(z3.unknown())
|
|
531
|
+
});
|
|
532
|
+
var PasskeyCheckResponse = z3.object({
|
|
533
|
+
hasPasskey: z3.boolean()
|
|
534
|
+
});
|
|
535
|
+
var CreateVaultRequest = z3.object({
|
|
536
|
+
name: z3.string().min(1).max(255),
|
|
537
|
+
data: z3.string(),
|
|
538
|
+
// Encrypted blob (base64)
|
|
539
|
+
salt: z3.string()
|
|
540
|
+
// Salt (base64)
|
|
541
|
+
});
|
|
542
|
+
var UpdateVaultRequest = z3.object({
|
|
543
|
+
data: z3.string(),
|
|
544
|
+
salt: z3.string(),
|
|
545
|
+
version: z3.number().optional()
|
|
546
|
+
});
|
|
547
|
+
var VaultResponse = z3.object({
|
|
548
|
+
uid: z3.string(),
|
|
549
|
+
name: z3.string(),
|
|
550
|
+
data: z3.string(),
|
|
551
|
+
salt: z3.string(),
|
|
552
|
+
version: z3.number(),
|
|
553
|
+
updatedAt: z3.number()
|
|
554
|
+
});
|
|
555
|
+
var VaultsListResponse = z3.object({
|
|
556
|
+
vaults: z3.array(VaultResponse)
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// src/api/auth/passkey.ts
|
|
560
|
+
import { Hono } from "hono";
|
|
561
|
+
import { zValidator } from "@hono/zod-validator";
|
|
562
|
+
import { z as z4 } from "zod";
|
|
563
|
+
import {
|
|
564
|
+
generateRegistrationOptions,
|
|
565
|
+
verifyRegistrationResponse,
|
|
566
|
+
generateAuthenticationOptions,
|
|
567
|
+
verifyAuthenticationResponse
|
|
568
|
+
} from "@simplewebauthn/server";
|
|
569
|
+
|
|
570
|
+
// src/features/auth/origin.ts
|
|
571
|
+
var allowedOrigins = null;
|
|
572
|
+
function getAllowedOrigins() {
|
|
573
|
+
if (!allowedOrigins) {
|
|
574
|
+
allowedOrigins = env.RP_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean);
|
|
575
|
+
}
|
|
576
|
+
return allowedOrigins;
|
|
577
|
+
}
|
|
578
|
+
function validateOrigin(origin) {
|
|
579
|
+
if (!origin) {
|
|
580
|
+
throw new ApiException(errors.invalid_origin, 403);
|
|
581
|
+
}
|
|
582
|
+
const allowed = getAllowedOrigins();
|
|
583
|
+
if (!allowed.includes(origin)) {
|
|
584
|
+
console.warn(`[auth] Rejected origin: ${origin}. Allowed: ${allowed.join(", ")}`);
|
|
585
|
+
throw new ApiException(errors.invalid_origin, 403);
|
|
586
|
+
}
|
|
587
|
+
const url = new URL(origin);
|
|
588
|
+
const rpId = url.hostname;
|
|
589
|
+
return { rpId, rpOrigin: origin };
|
|
590
|
+
}
|
|
591
|
+
function getRpConfigFromRequest(c) {
|
|
592
|
+
const origin = c.req.header("origin");
|
|
593
|
+
return validateOrigin(origin);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/api/auth/passkey.ts
|
|
597
|
+
var challengeStore = /* @__PURE__ */ new Map();
|
|
598
|
+
setInterval(() => {
|
|
599
|
+
const now = Date.now();
|
|
600
|
+
for (const [key, value] of challengeStore) {
|
|
601
|
+
if (value.expiresAt < now) {
|
|
602
|
+
challengeStore.delete(key);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}, 6e4);
|
|
606
|
+
var PasskeyRegisterOptionsRequest2 = z4.object({
|
|
607
|
+
email: z4.string().email().optional()
|
|
608
|
+
});
|
|
609
|
+
var PasskeyRegisterVerifyRequest2 = z4.object({
|
|
610
|
+
email: z4.string().email().optional(),
|
|
611
|
+
credential: z4.any()
|
|
612
|
+
// RegistrationResponseJSON
|
|
613
|
+
});
|
|
614
|
+
var PasskeyLoginOptionsRequest2 = z4.object({
|
|
615
|
+
email: z4.string().email().optional()
|
|
616
|
+
});
|
|
617
|
+
var PasskeyLoginVerifyRequest2 = z4.object({
|
|
618
|
+
credential: z4.any()
|
|
619
|
+
// AuthenticationResponseJSON
|
|
620
|
+
});
|
|
621
|
+
var PasskeyCheckRequest = z4.object({
|
|
622
|
+
email: z4.string().email()
|
|
623
|
+
});
|
|
624
|
+
var passkeyRouter = new Hono().post(
|
|
625
|
+
"/register/options",
|
|
626
|
+
zValidator("json", PasskeyRegisterOptionsRequest2),
|
|
627
|
+
async (c) => {
|
|
628
|
+
const { email } = c.req.valid("json");
|
|
629
|
+
if (email) {
|
|
630
|
+
const existing = getUserByEmail(email);
|
|
631
|
+
if (existing) {
|
|
632
|
+
throw new ApiException(errors.email_already_exists, 409);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const tempUserId = crypto.randomUUID();
|
|
636
|
+
const { rpId } = getRpConfigFromRequest(c);
|
|
637
|
+
const options = await generateRegistrationOptions({
|
|
638
|
+
rpName: env.RP_NAME,
|
|
639
|
+
rpID: rpId,
|
|
640
|
+
userName: email ?? tempUserId,
|
|
641
|
+
userDisplayName: email ?? "Anonymous User",
|
|
642
|
+
attestationType: "none",
|
|
643
|
+
authenticatorSelection: {
|
|
644
|
+
residentKey: "preferred",
|
|
645
|
+
userVerification: "preferred"
|
|
646
|
+
// No authenticatorAttachment = allow both platform (TouchID) AND cross-platform (Proton Pass, security keys)
|
|
647
|
+
},
|
|
648
|
+
timeout: 6e4
|
|
649
|
+
});
|
|
650
|
+
challengeStore.set(options.challenge, {
|
|
651
|
+
challenge: options.challenge,
|
|
652
|
+
email: email ?? void 0,
|
|
653
|
+
expiresAt: Date.now() + 12e4
|
|
654
|
+
// 2 min expiry
|
|
655
|
+
});
|
|
656
|
+
return c.json(options);
|
|
657
|
+
}
|
|
658
|
+
).post(
|
|
659
|
+
"/register/verify",
|
|
660
|
+
zValidator("json", PasskeyRegisterVerifyRequest2),
|
|
661
|
+
async (c) => {
|
|
662
|
+
const { email, credential } = c.req.valid("json");
|
|
663
|
+
const response = credential;
|
|
664
|
+
const clientDataJSON = JSON.parse(
|
|
665
|
+
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf-8")
|
|
666
|
+
);
|
|
667
|
+
const challenge = clientDataJSON.challenge;
|
|
668
|
+
const stored = challengeStore.get(challenge);
|
|
669
|
+
if (!stored) {
|
|
670
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
671
|
+
}
|
|
672
|
+
const userEmail = email ?? stored.email;
|
|
673
|
+
if (userEmail) {
|
|
674
|
+
const existing = getUserByEmail(userEmail);
|
|
675
|
+
if (existing) {
|
|
676
|
+
throw new ApiException(errors.email_already_exists, 409);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const { rpId, rpOrigin } = getRpConfigFromRequest(c);
|
|
680
|
+
try {
|
|
681
|
+
const verification = await verifyRegistrationResponse({
|
|
682
|
+
response,
|
|
683
|
+
expectedChallenge: stored.challenge,
|
|
684
|
+
expectedOrigin: rpOrigin,
|
|
685
|
+
expectedRPID: rpId
|
|
686
|
+
});
|
|
687
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
688
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
689
|
+
}
|
|
690
|
+
const { credentialID, credentialPublicKey, counter, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
691
|
+
const user = createUser({ email: userEmail ?? void 0, passwordHash: void 0 });
|
|
692
|
+
createPasskey({
|
|
693
|
+
userId: user.id,
|
|
694
|
+
credentialId: credentialID,
|
|
695
|
+
publicKey: Buffer.from(credentialPublicKey).toString("base64"),
|
|
696
|
+
counter,
|
|
697
|
+
deviceType: credentialDeviceType,
|
|
698
|
+
backedUp: credentialBackedUp,
|
|
699
|
+
transports: response.response.transports
|
|
700
|
+
});
|
|
701
|
+
const token = await createToken({ userId: user.uid, email: userEmail ?? void 0 });
|
|
702
|
+
const tokenHash = hashToken(token);
|
|
703
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
|
|
704
|
+
createSession({ userId: user.id, tokenHash, expiresAt });
|
|
705
|
+
challengeStore.delete(challenge);
|
|
706
|
+
return c.json({
|
|
707
|
+
user: {
|
|
708
|
+
id: user.uid,
|
|
709
|
+
email: user.email,
|
|
710
|
+
createdAt: user.createdAt
|
|
711
|
+
},
|
|
712
|
+
token
|
|
713
|
+
});
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (error instanceof ApiException) throw error;
|
|
716
|
+
console.error("Passkey registration error:", error);
|
|
717
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
).post(
|
|
721
|
+
"/login/options",
|
|
722
|
+
zValidator("json", PasskeyLoginOptionsRequest2),
|
|
723
|
+
async (c) => {
|
|
724
|
+
const { email } = c.req.valid("json");
|
|
725
|
+
let allowCredentials;
|
|
726
|
+
if (email) {
|
|
727
|
+
const user = getUserByEmail(email);
|
|
728
|
+
if (user) {
|
|
729
|
+
const passkeys = getPasskeysByUserId(user.id);
|
|
730
|
+
allowCredentials = passkeys.map((p) => ({
|
|
731
|
+
id: p.credentialId,
|
|
732
|
+
transports: p.transports?.split(",")
|
|
733
|
+
}));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const { rpId } = getRpConfigFromRequest(c);
|
|
737
|
+
const options = await generateAuthenticationOptions({
|
|
738
|
+
rpID: rpId,
|
|
739
|
+
allowCredentials,
|
|
740
|
+
userVerification: "preferred",
|
|
741
|
+
timeout: 6e4
|
|
742
|
+
});
|
|
743
|
+
const challengeKey = `auth:${options.challenge}`;
|
|
744
|
+
challengeStore.set(challengeKey, {
|
|
745
|
+
challenge: options.challenge,
|
|
746
|
+
expiresAt: Date.now() + 12e4
|
|
747
|
+
});
|
|
748
|
+
return c.json(options);
|
|
749
|
+
}
|
|
750
|
+
).post(
|
|
751
|
+
"/login/verify",
|
|
752
|
+
zValidator("json", PasskeyLoginVerifyRequest2),
|
|
753
|
+
async (c) => {
|
|
754
|
+
const { credential } = c.req.valid("json");
|
|
755
|
+
const response = credential;
|
|
756
|
+
const passkey = getPasskeyByCredentialId(response.id);
|
|
757
|
+
if (!passkey) {
|
|
758
|
+
throw new ApiException(errors.passkey_not_found, 401);
|
|
759
|
+
}
|
|
760
|
+
let storedChallenge = null;
|
|
761
|
+
for (const [key, value] of challengeStore) {
|
|
762
|
+
if (key.startsWith("auth:") && value.expiresAt > Date.now()) {
|
|
763
|
+
storedChallenge = value.challenge;
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (!storedChallenge) {
|
|
768
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
769
|
+
}
|
|
770
|
+
const { rpId, rpOrigin } = getRpConfigFromRequest(c);
|
|
771
|
+
try {
|
|
772
|
+
const verification = await verifyAuthenticationResponse({
|
|
773
|
+
response,
|
|
774
|
+
expectedChallenge: storedChallenge,
|
|
775
|
+
expectedOrigin: rpOrigin,
|
|
776
|
+
expectedRPID: rpId,
|
|
777
|
+
authenticator: {
|
|
778
|
+
credentialID: passkey.credentialId,
|
|
779
|
+
credentialPublicKey: Buffer.from(passkey.publicKey, "base64"),
|
|
780
|
+
counter: passkey.counter,
|
|
781
|
+
transports: passkey.transports?.split(",")
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
if (!verification.verified) {
|
|
785
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
786
|
+
}
|
|
787
|
+
updatePasskeyCounter(passkey.credentialId, verification.authenticationInfo.newCounter);
|
|
788
|
+
const token = await createToken({
|
|
789
|
+
userId: passkey.user.uid,
|
|
790
|
+
email: passkey.user.email ?? void 0
|
|
791
|
+
});
|
|
792
|
+
const tokenHash = hashToken(token);
|
|
793
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
|
|
794
|
+
createSession({ userId: passkey.user.id, tokenHash, expiresAt });
|
|
795
|
+
challengeStore.delete(`auth:${storedChallenge}`);
|
|
796
|
+
return c.json({
|
|
797
|
+
user: {
|
|
798
|
+
id: passkey.user.uid,
|
|
799
|
+
email: passkey.user.email,
|
|
800
|
+
createdAt: passkey.user.createdAt
|
|
801
|
+
},
|
|
802
|
+
token
|
|
803
|
+
});
|
|
804
|
+
} catch (error) {
|
|
805
|
+
if (error instanceof ApiException) throw error;
|
|
806
|
+
console.error("Passkey authentication error:", error);
|
|
807
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
).post(
|
|
811
|
+
"/check",
|
|
812
|
+
zValidator("json", PasskeyCheckRequest),
|
|
813
|
+
(c) => {
|
|
814
|
+
const { email } = c.req.valid("json");
|
|
815
|
+
const user = getUserByEmail(email);
|
|
816
|
+
if (!user) {
|
|
817
|
+
return c.json({ hasPasskey: false });
|
|
818
|
+
}
|
|
819
|
+
const passkeys = getPasskeysByUserId(user.id);
|
|
820
|
+
return c.json({ hasPasskey: passkeys.length > 0 });
|
|
821
|
+
}
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
// src/api/auth/zkc.ts
|
|
825
|
+
import { Hono as Hono2 } from "hono";
|
|
826
|
+
import { zValidator as zValidator2 } from "@hono/zod-validator";
|
|
827
|
+
import { z as z5 } from "zod";
|
|
828
|
+
var ZkcRegisterRequest = z5.object({
|
|
829
|
+
/** Opaque identifier from ZKCredentials */
|
|
830
|
+
opaqueId: z5.string().min(1),
|
|
831
|
+
/** Optional display name */
|
|
832
|
+
displayName: z5.string().optional()
|
|
833
|
+
});
|
|
834
|
+
var ZkcAuthenticateRequest = z5.object({
|
|
835
|
+
/** Opaque identifier from ZKCredentials */
|
|
836
|
+
opaqueId: z5.string().min(1)
|
|
837
|
+
});
|
|
838
|
+
var ZkcCheckRequest = z5.object({
|
|
839
|
+
/** Opaque identifier to check */
|
|
840
|
+
opaqueId: z5.string().min(1)
|
|
841
|
+
});
|
|
842
|
+
var zkcRouter = new Hono2().post(
|
|
843
|
+
"/register",
|
|
844
|
+
zValidator2("json", ZkcRegisterRequest),
|
|
845
|
+
async (c) => {
|
|
846
|
+
const { opaqueId, displayName } = c.req.valid("json");
|
|
847
|
+
const existing = getUserByOpaqueId(opaqueId);
|
|
848
|
+
if (existing) {
|
|
849
|
+
throw new ApiException(errors.email_already_exists, 409);
|
|
850
|
+
}
|
|
851
|
+
const user = createUser({
|
|
852
|
+
opaqueId,
|
|
853
|
+
displayName
|
|
854
|
+
});
|
|
855
|
+
const token = await createToken({ userId: user.uid });
|
|
856
|
+
const tokenHash = hashToken(token);
|
|
857
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
|
|
858
|
+
createSession({ userId: user.id, tokenHash, expiresAt });
|
|
859
|
+
return c.json({
|
|
860
|
+
user: {
|
|
861
|
+
id: user.uid,
|
|
862
|
+
createdAt: user.createdAt
|
|
863
|
+
},
|
|
864
|
+
token
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
).post(
|
|
868
|
+
"/authenticate",
|
|
869
|
+
zValidator2("json", ZkcAuthenticateRequest),
|
|
870
|
+
async (c) => {
|
|
871
|
+
const { opaqueId } = c.req.valid("json");
|
|
872
|
+
const user = getUserByOpaqueId(opaqueId);
|
|
873
|
+
if (!user) {
|
|
874
|
+
throw new ApiException(errors.passkey_not_found, 401);
|
|
875
|
+
}
|
|
876
|
+
const token = await createToken({ userId: user.uid });
|
|
877
|
+
const tokenHash = hashToken(token);
|
|
878
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
|
|
879
|
+
createSession({ userId: user.id, tokenHash, expiresAt });
|
|
880
|
+
return c.json({
|
|
881
|
+
user: {
|
|
882
|
+
id: user.uid,
|
|
883
|
+
createdAt: user.createdAt
|
|
884
|
+
},
|
|
885
|
+
token
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
).post(
|
|
889
|
+
"/check",
|
|
890
|
+
zValidator2("json", ZkcCheckRequest),
|
|
891
|
+
(c) => {
|
|
892
|
+
const { opaqueId } = c.req.valid("json");
|
|
893
|
+
const user = getUserByOpaqueId(opaqueId);
|
|
894
|
+
return c.json({ hasPasskey: user !== void 0 });
|
|
895
|
+
}
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
// src/api/auth/router.ts
|
|
899
|
+
function hashPassword(password, salt) {
|
|
900
|
+
const crypto2 = createHash2("sha256");
|
|
901
|
+
return crypto2.update(password + salt).digest("hex");
|
|
902
|
+
}
|
|
903
|
+
function generateSalt() {
|
|
904
|
+
return randomBytes(16).toString("hex");
|
|
905
|
+
}
|
|
906
|
+
var authRouter = new Hono3().post(
|
|
907
|
+
"/email/register",
|
|
908
|
+
zValidator3("json", EmailRegisterRequest),
|
|
909
|
+
async (c) => {
|
|
910
|
+
const { email, password } = c.req.valid("json");
|
|
911
|
+
const existing = getUserByEmail(email);
|
|
912
|
+
if (existing) {
|
|
913
|
+
throw new ApiException(errors.email_already_exists, 409);
|
|
914
|
+
}
|
|
915
|
+
const salt = generateSalt();
|
|
916
|
+
const passwordHash = hashPassword(password, salt) + ":" + salt;
|
|
917
|
+
const user = createUser({ email, passwordHash });
|
|
918
|
+
const token = await createToken({ userId: user.uid, email });
|
|
919
|
+
const tokenHash = hashToken(token);
|
|
920
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
|
|
921
|
+
createSession({ userId: user.id, tokenHash, expiresAt });
|
|
922
|
+
return c.json({
|
|
923
|
+
user: {
|
|
924
|
+
id: user.uid,
|
|
925
|
+
email: user.email,
|
|
926
|
+
createdAt: user.createdAt
|
|
927
|
+
},
|
|
928
|
+
token
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
).post(
|
|
932
|
+
"/email/login",
|
|
933
|
+
zValidator3("json", EmailLoginRequest),
|
|
934
|
+
async (c) => {
|
|
935
|
+
const { email, password } = c.req.valid("json");
|
|
936
|
+
const user = getUserByEmail(email);
|
|
937
|
+
if (!user || !user.passwordHash) {
|
|
938
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
939
|
+
}
|
|
940
|
+
const [storedHash, salt] = user.passwordHash.split(":");
|
|
941
|
+
if (!salt) {
|
|
942
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
943
|
+
}
|
|
944
|
+
const inputHash = hashPassword(password, salt);
|
|
945
|
+
if (inputHash !== storedHash) {
|
|
946
|
+
throw new ApiException(errors.invalid_credentials, 401);
|
|
947
|
+
}
|
|
948
|
+
const token = await createToken({ userId: user.uid, email: user.email ?? void 0 });
|
|
949
|
+
const tokenHash = hashToken(token);
|
|
950
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
|
|
951
|
+
createSession({ userId: user.id, tokenHash, expiresAt });
|
|
952
|
+
return c.json({
|
|
953
|
+
user: {
|
|
954
|
+
id: user.uid,
|
|
955
|
+
email: user.email,
|
|
956
|
+
createdAt: user.createdAt
|
|
957
|
+
},
|
|
958
|
+
token
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
).get(
|
|
962
|
+
"/me",
|
|
963
|
+
requireAuthMiddleware,
|
|
964
|
+
(c) => {
|
|
965
|
+
const session = c.get("session");
|
|
966
|
+
return c.json({
|
|
967
|
+
user: {
|
|
968
|
+
id: session.user.uid,
|
|
969
|
+
email: session.user.email,
|
|
970
|
+
createdAt: 0
|
|
971
|
+
// Not needed for /me
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
).post(
|
|
976
|
+
"/refresh",
|
|
977
|
+
requireAuthMiddleware,
|
|
978
|
+
async (c) => {
|
|
979
|
+
const session = c.get("session");
|
|
980
|
+
const token = await createToken({
|
|
981
|
+
userId: session.user.uid,
|
|
982
|
+
email: session.user.email ?? void 0
|
|
983
|
+
});
|
|
984
|
+
const tokenHash = hashToken(token);
|
|
985
|
+
const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
|
|
986
|
+
const oldToken = c.req.header("Authorization")?.slice(7);
|
|
987
|
+
if (oldToken) {
|
|
988
|
+
deleteSession(hashToken(oldToken));
|
|
989
|
+
}
|
|
990
|
+
createSession({ userId: session.user.id, tokenHash, expiresAt });
|
|
991
|
+
return c.json({
|
|
992
|
+
token,
|
|
993
|
+
expiresIn: env.JWT_EXPIRY
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
).post(
|
|
997
|
+
"/logout",
|
|
998
|
+
requireAuthMiddleware,
|
|
999
|
+
(c) => {
|
|
1000
|
+
const token = c.req.header("Authorization")?.slice(7);
|
|
1001
|
+
if (token) {
|
|
1002
|
+
deleteSession(hashToken(token));
|
|
1003
|
+
}
|
|
1004
|
+
return c.json({ success: true });
|
|
1005
|
+
}
|
|
1006
|
+
).route("/passkey", passkeyRouter).route("/zkc", zkcRouter);
|
|
1007
|
+
|
|
1008
|
+
// src/api/vault/router.ts
|
|
1009
|
+
import { Hono as Hono4 } from "hono";
|
|
1010
|
+
import { zValidator as zValidator4 } from "@hono/zod-validator";
|
|
1011
|
+
|
|
1012
|
+
// src/services/vault-service.ts
|
|
1013
|
+
function toVaultResponse(vault) {
|
|
1014
|
+
return {
|
|
1015
|
+
uid: vault.uid,
|
|
1016
|
+
name: vault.name,
|
|
1017
|
+
data: vault.data,
|
|
1018
|
+
salt: vault.salt,
|
|
1019
|
+
version: vault.version,
|
|
1020
|
+
updatedAt: vault.updatedAt
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
var VaultService = class {
|
|
1024
|
+
constructor(vaultRepo2) {
|
|
1025
|
+
this.vaultRepo = vaultRepo2;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* List all vaults for a user
|
|
1029
|
+
*/
|
|
1030
|
+
listVaults(userId) {
|
|
1031
|
+
const vaults = this.vaultRepo.findByUserId(userId);
|
|
1032
|
+
return {
|
|
1033
|
+
vaults: vaults.map(toVaultResponse)
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Get vault by name
|
|
1038
|
+
*/
|
|
1039
|
+
getVaultByName(name, userId) {
|
|
1040
|
+
const vault = this.vaultRepo.findByName(name, userId);
|
|
1041
|
+
if (!vault) {
|
|
1042
|
+
throw new ApiException(errors.vault_not_found, 404);
|
|
1043
|
+
}
|
|
1044
|
+
return toVaultResponse(vault);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Get vault by UID
|
|
1048
|
+
*/
|
|
1049
|
+
getVaultByUid(uid, userId) {
|
|
1050
|
+
const vault = this.vaultRepo.findByUid(uid, userId);
|
|
1051
|
+
if (!vault) {
|
|
1052
|
+
throw new ApiException(errors.vault_not_found, 404);
|
|
1053
|
+
}
|
|
1054
|
+
return toVaultResponse(vault);
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Create a new vault
|
|
1058
|
+
*/
|
|
1059
|
+
createVault(userId, data) {
|
|
1060
|
+
const existing = this.vaultRepo.findByName(data.name, userId);
|
|
1061
|
+
if (existing) {
|
|
1062
|
+
throw new ApiException(errors.vault_already_exists(data.name), 409);
|
|
1063
|
+
}
|
|
1064
|
+
const vault = this.vaultRepo.create({
|
|
1065
|
+
userId,
|
|
1066
|
+
name: data.name,
|
|
1067
|
+
data: data.data,
|
|
1068
|
+
salt: data.salt
|
|
1069
|
+
});
|
|
1070
|
+
return toVaultResponse(vault);
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Update a vault
|
|
1074
|
+
*/
|
|
1075
|
+
updateVault(uid, userId, data) {
|
|
1076
|
+
const vault = this.vaultRepo.update(uid, userId, data);
|
|
1077
|
+
if (!vault) {
|
|
1078
|
+
throw new ApiException(errors.vault_not_found, 404);
|
|
1079
|
+
}
|
|
1080
|
+
return toVaultResponse(vault);
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Delete a vault
|
|
1084
|
+
*/
|
|
1085
|
+
deleteVault(uid, userId) {
|
|
1086
|
+
const deleted = this.vaultRepo.delete(uid, userId);
|
|
1087
|
+
if (!deleted) {
|
|
1088
|
+
throw new ApiException(errors.vault_not_found, 404);
|
|
1089
|
+
}
|
|
1090
|
+
return { success: true };
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
// src/repositories/vault-repository.ts
|
|
1095
|
+
var VaultRepository = class {
|
|
1096
|
+
create(vault) {
|
|
1097
|
+
return createVault(vault);
|
|
1098
|
+
}
|
|
1099
|
+
findByUid(uid, userId) {
|
|
1100
|
+
return getVaultByUid(uid, userId);
|
|
1101
|
+
}
|
|
1102
|
+
findByName(name, userId) {
|
|
1103
|
+
return getVaultByName(name, userId);
|
|
1104
|
+
}
|
|
1105
|
+
findByUserId(userId) {
|
|
1106
|
+
return getVaultsByUserId(userId);
|
|
1107
|
+
}
|
|
1108
|
+
update(uid, userId, data) {
|
|
1109
|
+
return updateVault(uid, userId, data);
|
|
1110
|
+
}
|
|
1111
|
+
delete(uid, userId) {
|
|
1112
|
+
return deleteVault(uid, userId);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
// src/api/vault/router.ts
|
|
1117
|
+
var vaultRepo = new VaultRepository();
|
|
1118
|
+
var vaultService = new VaultService(vaultRepo);
|
|
1119
|
+
var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
|
|
1120
|
+
"/",
|
|
1121
|
+
(c) => {
|
|
1122
|
+
const session = c.get("session");
|
|
1123
|
+
return c.json(vaultService.listVaults(session.user.id));
|
|
1124
|
+
}
|
|
1125
|
+
).get(
|
|
1126
|
+
"/by-name/:name",
|
|
1127
|
+
(c) => {
|
|
1128
|
+
const session = c.get("session");
|
|
1129
|
+
const { name } = c.req.param();
|
|
1130
|
+
return c.json(vaultService.getVaultByName(name, session.user.id));
|
|
1131
|
+
}
|
|
1132
|
+
).get(
|
|
1133
|
+
"/:uid",
|
|
1134
|
+
(c) => {
|
|
1135
|
+
const session = c.get("session");
|
|
1136
|
+
const { uid } = c.req.param();
|
|
1137
|
+
return c.json(vaultService.getVaultByUid(uid, session.user.id));
|
|
1138
|
+
}
|
|
1139
|
+
).post(
|
|
1140
|
+
"/",
|
|
1141
|
+
zValidator4("json", CreateVaultRequest),
|
|
1142
|
+
(c) => {
|
|
1143
|
+
const session = c.get("session");
|
|
1144
|
+
const { name, data, salt } = c.req.valid("json");
|
|
1145
|
+
return c.json(
|
|
1146
|
+
vaultService.createVault(session.user.id, { name, data, salt }),
|
|
1147
|
+
201
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
).put(
|
|
1151
|
+
"/:uid",
|
|
1152
|
+
zValidator4("json", UpdateVaultRequest),
|
|
1153
|
+
(c) => {
|
|
1154
|
+
const session = c.get("session");
|
|
1155
|
+
const { uid } = c.req.param();
|
|
1156
|
+
const { data, salt, version } = c.req.valid("json");
|
|
1157
|
+
return c.json(
|
|
1158
|
+
vaultService.updateVault(uid, session.user.id, { data, salt, version })
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
).delete(
|
|
1162
|
+
"/:uid",
|
|
1163
|
+
(c) => {
|
|
1164
|
+
const session = c.get("session");
|
|
1165
|
+
const { uid } = c.req.param();
|
|
1166
|
+
return c.json(vaultService.deleteVault(uid, session.user.id));
|
|
1167
|
+
}
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
// src/app.ts
|
|
1171
|
+
var errorHandler = (error, c) => {
|
|
1172
|
+
const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
|
|
1173
|
+
if (error instanceof ApiException) {
|
|
1174
|
+
return c.json(
|
|
1175
|
+
{ error: error.error, requestId },
|
|
1176
|
+
error.status
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
if (error instanceof ZodError) {
|
|
1180
|
+
const message = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
|
|
1181
|
+
return c.json(
|
|
1182
|
+
{
|
|
1183
|
+
error: {
|
|
1184
|
+
code: "validation_error",
|
|
1185
|
+
message,
|
|
1186
|
+
details: { errors: error.errors }
|
|
1187
|
+
},
|
|
1188
|
+
requestId
|
|
1189
|
+
},
|
|
1190
|
+
400
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
console.error(`[${requestId}] Unhandled error:`, error);
|
|
1194
|
+
return c.json(
|
|
1195
|
+
{ error: errors.internal_error, requestId },
|
|
1196
|
+
500
|
|
1197
|
+
);
|
|
1198
|
+
};
|
|
1199
|
+
function createApp() {
|
|
1200
|
+
const app = new Hono5();
|
|
1201
|
+
app.use("*", logger());
|
|
1202
|
+
app.use(
|
|
1203
|
+
"*",
|
|
1204
|
+
cors({
|
|
1205
|
+
origin: "*",
|
|
1206
|
+
// Configure for production
|
|
1207
|
+
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
1208
|
+
allowHeaders: ["Content-Type", "Authorization"],
|
|
1209
|
+
exposeHeaders: ["X-Request-Id"]
|
|
1210
|
+
})
|
|
1211
|
+
);
|
|
1212
|
+
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
|
|
1213
|
+
app.route("/auth", authRouter);
|
|
1214
|
+
app.route("/vault", vaultRouter);
|
|
1215
|
+
app.onError(errorHandler);
|
|
1216
|
+
app.notFound((c) => {
|
|
1217
|
+
return c.json(
|
|
1218
|
+
{ error: { code: "not_found", message: "Endpoint not found" } },
|
|
1219
|
+
404
|
|
1220
|
+
);
|
|
1221
|
+
});
|
|
1222
|
+
return app;
|
|
1223
|
+
}
|
|
1224
|
+
export {
|
|
1225
|
+
ApiException,
|
|
1226
|
+
AuthResponse,
|
|
1227
|
+
CreateVaultRequest,
|
|
1228
|
+
EmailLoginRequest,
|
|
1229
|
+
EmailRegisterRequest,
|
|
1230
|
+
MeResponse,
|
|
1231
|
+
PasskeyCheckResponse,
|
|
1232
|
+
PasskeyLoginOptionsRequest,
|
|
1233
|
+
PasskeyLoginVerifyRequest,
|
|
1234
|
+
PasskeyRegisterOptionsRequest,
|
|
1235
|
+
PasskeyRegisterVerifyRequest,
|
|
1236
|
+
RefreshResponse,
|
|
1237
|
+
UpdateVaultRequest,
|
|
1238
|
+
UserResponse,
|
|
1239
|
+
VaultResponse,
|
|
1240
|
+
VaultsListResponse,
|
|
1241
|
+
closeDb,
|
|
1242
|
+
createApp,
|
|
1243
|
+
createToken,
|
|
1244
|
+
errors,
|
|
1245
|
+
getDb,
|
|
1246
|
+
getError,
|
|
1247
|
+
hashToken,
|
|
1248
|
+
optionalAuthMiddleware,
|
|
1249
|
+
requireAuthMiddleware,
|
|
1250
|
+
verifyToken
|
|
1251
|
+
};
|