@kuratchi/auth 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/package.json +19 -0
- package/src/adapter.ts +24 -0
- package/src/core/activity.ts +294 -0
- package/src/core/auth.ts +424 -0
- package/src/core/credentials.ts +665 -0
- package/src/core/guards.ts +152 -0
- package/src/core/oauth.ts +348 -0
- package/src/core/organization.ts +171 -0
- package/src/core/plugin.ts +178 -0
- package/src/core/rate-limit.ts +235 -0
- package/src/core/roles.ts +219 -0
- package/src/core/turnstile.ts +275 -0
- package/src/index.ts +69 -0
- package/src/utils/activity-actions.ts +50 -0
- package/src/utils/crypto.ts +207 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kuratchi/auth — Credentials API
|
|
3
|
+
*
|
|
4
|
+
* Ready-to-use server functions for email/password auth.
|
|
5
|
+
* Uses @kuratchi/orm for all database operations.
|
|
6
|
+
*
|
|
7
|
+
* Supports two modes:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Standard** — single-tenant, ORM backed by configured D1 binding
|
|
10
|
+
* 2. **Org-aware** — multi-tenant, admin D1 for org/user mapping,
|
|
11
|
+
* per-org Durable Objects for credentials/sessions (via RPC)
|
|
12
|
+
*
|
|
13
|
+
* The credentials plugin owns: binding (D1), defaultRole, redirects.
|
|
14
|
+
* The organizations plugin owns: DO namespace binding.
|
|
15
|
+
* Credentials checks `isOrgAvailable()` to decide mode.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { signUp, signIn, signOut, getCurrentUser } from '@kuratchi/auth';
|
|
20
|
+
*
|
|
21
|
+
* export async function signUpAction(formData: FormData) {
|
|
22
|
+
* await signUp(formData);
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
hashPassword,
|
|
29
|
+
comparePassword,
|
|
30
|
+
generateSessionToken,
|
|
31
|
+
hashToken,
|
|
32
|
+
buildSessionCookie,
|
|
33
|
+
parseSessionCookie,
|
|
34
|
+
} from '../utils/crypto.js';
|
|
35
|
+
import { kuratchiORM } from '@kuratchi/orm';
|
|
36
|
+
import { isOrgAvailable, getOrgStubByName } from './organization.js';
|
|
37
|
+
import { logActivity } from './activity.js';
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Framework context helpers
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
function _getEnv(): Record<string, any> {
|
|
44
|
+
return (globalThis as any).__cloudflare_env__ ?? {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _getContext() {
|
|
48
|
+
const ctx = (globalThis as any).__kuratchi_context__;
|
|
49
|
+
return {
|
|
50
|
+
request: ctx?.request as Request | undefined,
|
|
51
|
+
locals: ctx?.locals as Record<string, any> ?? {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _getSecret(): string {
|
|
56
|
+
const secret = _getEnv().AUTH_SECRET;
|
|
57
|
+
if (!secret) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'[kuratchi/auth] AUTH_SECRET is not set. Add it to .dev.vars (local) or Workers secrets (production). '
|
|
60
|
+
+ 'Auth operations cannot proceed without a secret.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return secret;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _getCookieName(): string {
|
|
67
|
+
const { locals } = _getContext();
|
|
68
|
+
return locals.auth?.cookieName || 'kuratchi_session';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _getSessionCookie(): string | null {
|
|
72
|
+
const { locals } = _getContext();
|
|
73
|
+
return locals.auth?.sessionCookie || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _setRedirect(path: string) {
|
|
77
|
+
const { locals } = _getContext();
|
|
78
|
+
locals.__redirectTo = path;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _pushSetCookie(header: string) {
|
|
82
|
+
const { locals } = _getContext();
|
|
83
|
+
if (!locals.__setCookieHeaders) locals.__setCookieHeaders = [];
|
|
84
|
+
locals.__setCookieHeaders.push(header);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _buildSetCookieHeader(name: string, value: string, opts: {
|
|
88
|
+
expires?: Date;
|
|
89
|
+
httpOnly?: boolean;
|
|
90
|
+
secure?: boolean;
|
|
91
|
+
sameSite?: string;
|
|
92
|
+
path?: string;
|
|
93
|
+
}): string {
|
|
94
|
+
const parts = [`${name}=${value}`];
|
|
95
|
+
parts.push(`Path=${opts.path || '/'}`);
|
|
96
|
+
if (opts.httpOnly !== false) parts.push('HttpOnly');
|
|
97
|
+
if (opts.secure !== false) parts.push('Secure');
|
|
98
|
+
parts.push(`SameSite=${opts.sameSite || 'Lax'}`);
|
|
99
|
+
if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);
|
|
100
|
+
return parts.join('; ');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _buildClearCookieHeader(name: string): string {
|
|
104
|
+
return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function _safeLogActivity(
|
|
108
|
+
action: string,
|
|
109
|
+
options?: { detail?: string | Record<string, any>; userId?: number | string | null },
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
await logActivity(action, { detail: options?.detail, userId: options?.userId });
|
|
113
|
+
} catch {
|
|
114
|
+
// Telemetry should never break auth flows.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _errorMessage(err: unknown): string {
|
|
119
|
+
if (err instanceof Error && err.message) return err.message;
|
|
120
|
+
return String(err);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// Database — ORM against the configured D1 binding
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
/** Get the D1 database as an ORM instance (uses credentials.binding). */
|
|
128
|
+
function _getDb(): Record<string, any> {
|
|
129
|
+
const bindingName = _config.binding || 'DB';
|
|
130
|
+
return kuratchiORM(() => {
|
|
131
|
+
const env = _getEnv();
|
|
132
|
+
if (!env[bindingName]) throw new Error(`[kuratchi/auth] No ${bindingName} binding found in env.`);
|
|
133
|
+
return env[bindingName];
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Generate a unique DO name from an org name. */
|
|
138
|
+
function _generateDoName(orgName: string): string {
|
|
139
|
+
const sanitized = orgName
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
142
|
+
.replace(/-+/g, '-')
|
|
143
|
+
.replace(/^-|-$/g, '')
|
|
144
|
+
.substring(0, 32);
|
|
145
|
+
return `org-${sanitized}-${crypto.randomUUID().substring(0, 8)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Configuration
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
export interface CredentialsConfig {
|
|
153
|
+
/** D1 binding name for auth database (default: 'DB') */
|
|
154
|
+
binding?: string;
|
|
155
|
+
/** Default role for new users (default: 'user') */
|
|
156
|
+
defaultRole?: string;
|
|
157
|
+
/** Session duration in ms (default: 30 days) */
|
|
158
|
+
sessionDuration?: number;
|
|
159
|
+
/** Minimum password length (default: 8) */
|
|
160
|
+
minPasswordLength?: number;
|
|
161
|
+
/** Redirect after signup (default: '/auth/login') */
|
|
162
|
+
signUpRedirect?: string;
|
|
163
|
+
/** Redirect after signin (default: '/admin') */
|
|
164
|
+
signInRedirect?: string;
|
|
165
|
+
/** Redirect after signout (default: '/auth/login') */
|
|
166
|
+
signOutRedirect?: string;
|
|
167
|
+
/** Fields to exclude from the returned user object */
|
|
168
|
+
excludeFields?: string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let _config: CredentialsConfig = {};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Configure credentials behavior. Called by the compiler from kuratchi.config.ts.
|
|
175
|
+
*/
|
|
176
|
+
export function configureCredentials(config: CredentialsConfig): void {
|
|
177
|
+
_config = { ..._config, ...config };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Public API
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Sign up a new user with email/password.
|
|
186
|
+
*
|
|
187
|
+
* Standard mode: inserts user via ORM into D1, redirects.
|
|
188
|
+
* Org mode: creates org + mapping in admin D1, creates user
|
|
189
|
+
* in org DO via RPC, auto-creates session, redirects.
|
|
190
|
+
*/
|
|
191
|
+
export async function signUp(formData: FormData): Promise<void> {
|
|
192
|
+
const email = (formData.get('email') as string)?.trim().toLowerCase();
|
|
193
|
+
const password = formData.get('password') as string;
|
|
194
|
+
const name = (formData.get('name') as string)?.trim() || null;
|
|
195
|
+
try {
|
|
196
|
+
|
|
197
|
+
if (!email || !password) {
|
|
198
|
+
throw new Error('Email and password are required');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const minLen = _config.minPasswordLength ?? 8;
|
|
202
|
+
if (password.length < minLen) {
|
|
203
|
+
throw new Error(`Password must be at least ${minLen} characters`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const secret = _getSecret();
|
|
207
|
+
const passwordHash = await hashPassword(password, undefined, secret);
|
|
208
|
+
const role = _config.defaultRole ?? 'user';
|
|
209
|
+
|
|
210
|
+
// ── Org-aware mode ─────────────────────────────────────
|
|
211
|
+
if (isOrgAvailable()) {
|
|
212
|
+
const orgName = (formData.get('orgName') as string)?.trim();
|
|
213
|
+
if (!orgName) {
|
|
214
|
+
throw new Error('Organization name is required');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const db = _getDb();
|
|
218
|
+
|
|
219
|
+
// Check if email is already mapped
|
|
220
|
+
const existing = await db.userMappings.where({ email }).first();
|
|
221
|
+
if (existing.data) {
|
|
222
|
+
throw new Error('An account with this email already exists');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Generate DO name and create org record
|
|
226
|
+
const doName = _generateDoName(orgName);
|
|
227
|
+
await db.organizations.insert({ orgName, doName, email });
|
|
228
|
+
const org = await db.organizations.where({ doName }).first();
|
|
229
|
+
|
|
230
|
+
// Map user → org
|
|
231
|
+
await db.userMappings.insert({ orgId: org.data.id, email });
|
|
232
|
+
|
|
233
|
+
// Create user in org DO via RPC
|
|
234
|
+
const stub = getOrgStubByName(doName)!;
|
|
235
|
+
const newUser = await stub.createUser({ email, name, passwordHash, role });
|
|
236
|
+
|
|
237
|
+
// Auto-login: create session in org DO
|
|
238
|
+
const sessionToken = generateSessionToken();
|
|
239
|
+
const sessionTokenHash = await hashToken(sessionToken);
|
|
240
|
+
const duration = _config.sessionDuration ?? 30 * 24 * 60 * 60 * 1000;
|
|
241
|
+
const expires = new Date(Date.now() + duration);
|
|
242
|
+
|
|
243
|
+
await stub.createSession({
|
|
244
|
+
sessionToken: sessionTokenHash,
|
|
245
|
+
userId: newUser.id,
|
|
246
|
+
expires: expires.getTime(),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const sessionCookie = await buildSessionCookie(secret, doName, sessionTokenHash);
|
|
250
|
+
const { locals } = _getContext();
|
|
251
|
+
if (!locals.auth) locals.auth = {};
|
|
252
|
+
locals.auth.sessionCookie = sessionCookie;
|
|
253
|
+
_pushSetCookie(_buildSetCookieHeader(_getCookieName(), sessionCookie, {
|
|
254
|
+
expires, httpOnly: true, secure: true, sameSite: 'lax',
|
|
255
|
+
}));
|
|
256
|
+
await _safeLogActivity('user.signup', {
|
|
257
|
+
userId: newUser.id,
|
|
258
|
+
detail: { email, orgName, role },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
_setRedirect(_config.signUpRedirect ?? '/');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Standard D1 mode ──────────────────────────────────
|
|
266
|
+
const db = _getDb();
|
|
267
|
+
|
|
268
|
+
const existing = await db.users.where({ email }).first();
|
|
269
|
+
if (existing.data) {
|
|
270
|
+
throw new Error('An account with this email already exists');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await db.users.insert({ email, name, passwordHash, role });
|
|
274
|
+
const created = await db.users.where({ email }).first();
|
|
275
|
+
await _safeLogActivity('user.signup', {
|
|
276
|
+
userId: created.data?.id ?? null,
|
|
277
|
+
detail: { email, role },
|
|
278
|
+
});
|
|
279
|
+
_setRedirect(_config.signUpRedirect ?? '/auth/login');
|
|
280
|
+
} catch (err) {
|
|
281
|
+
await _safeLogActivity('auth.failed', {
|
|
282
|
+
detail: { phase: 'signup', email: email || null, error: _errorMessage(err) },
|
|
283
|
+
});
|
|
284
|
+
throw err as Error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Sign in with email/password.
|
|
290
|
+
*
|
|
291
|
+
* Standard mode: verifies credentials + creates session in D1 via ORM.
|
|
292
|
+
* Org mode: resolves email→org in D1, then verifies + creates session
|
|
293
|
+
* in the org's DO via RPC.
|
|
294
|
+
*/
|
|
295
|
+
export async function signIn(formData: FormData): Promise<void> {
|
|
296
|
+
const email = (formData.get('email') as string)?.trim().toLowerCase();
|
|
297
|
+
const password = formData.get('password') as string;
|
|
298
|
+
try {
|
|
299
|
+
|
|
300
|
+
if (!email || !password) {
|
|
301
|
+
throw new Error('Email and password are required');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const secret = _getSecret();
|
|
305
|
+
|
|
306
|
+
// ── Org-aware mode ─────────────────────────────────────
|
|
307
|
+
if (isOrgAvailable()) {
|
|
308
|
+
const db = _getDb();
|
|
309
|
+
|
|
310
|
+
// Resolve email → org
|
|
311
|
+
const mapping = await db.userMappings.where({ email }).first();
|
|
312
|
+
if (!mapping.data) throw new Error('Invalid email or password');
|
|
313
|
+
|
|
314
|
+
const org = await db.organizations.where({ id: mapping.data.orgId }).first();
|
|
315
|
+
if (!org.data?.doName) throw new Error('Invalid email or password');
|
|
316
|
+
|
|
317
|
+
const doName = org.data.doName as string;
|
|
318
|
+
const stub = getOrgStubByName(doName)!;
|
|
319
|
+
|
|
320
|
+
// Verify credentials in org DO
|
|
321
|
+
const user = await stub.getUserByEmail(email);
|
|
322
|
+
if (!user?.passwordHash) throw new Error('Invalid email or password');
|
|
323
|
+
|
|
324
|
+
const isValid = await comparePassword(password, user.passwordHash as string, secret);
|
|
325
|
+
if (!isValid) throw new Error('Invalid email or password');
|
|
326
|
+
|
|
327
|
+
// Create session in org DO
|
|
328
|
+
const sessionToken = generateSessionToken();
|
|
329
|
+
const sessionTokenHash = await hashToken(sessionToken);
|
|
330
|
+
const duration = _config.sessionDuration ?? 30 * 24 * 60 * 60 * 1000;
|
|
331
|
+
const expires = new Date(Date.now() + duration);
|
|
332
|
+
|
|
333
|
+
await stub.createSession({
|
|
334
|
+
sessionToken: sessionTokenHash,
|
|
335
|
+
userId: user.id as number,
|
|
336
|
+
expires: expires.getTime(),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const sessionCookie = await buildSessionCookie(secret, doName, sessionTokenHash);
|
|
340
|
+
const { locals } = _getContext();
|
|
341
|
+
if (!locals.auth) locals.auth = {};
|
|
342
|
+
locals.auth.sessionCookie = sessionCookie;
|
|
343
|
+
_pushSetCookie(_buildSetCookieHeader(_getCookieName(), sessionCookie, {
|
|
344
|
+
expires, httpOnly: true, secure: true, sameSite: 'lax',
|
|
345
|
+
}));
|
|
346
|
+
await _safeLogActivity('user.login', {
|
|
347
|
+
userId: user.id as number,
|
|
348
|
+
detail: { email },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
_setRedirect(_config.signInRedirect ?? '/');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── Standard D1 mode ──────────────────────────────────
|
|
356
|
+
const db = _getDb();
|
|
357
|
+
|
|
358
|
+
const userResult = await db.users.where({ email }).first();
|
|
359
|
+
const user = userResult.data;
|
|
360
|
+
if (!user?.passwordHash) throw new Error('Invalid email or password');
|
|
361
|
+
|
|
362
|
+
const isValid = await comparePassword(password, user.passwordHash, secret);
|
|
363
|
+
if (!isValid) throw new Error('Invalid email or password');
|
|
364
|
+
|
|
365
|
+
const sessionToken = generateSessionToken();
|
|
366
|
+
const sessionTokenHash = await hashToken(sessionToken);
|
|
367
|
+
const duration = _config.sessionDuration ?? 30 * 24 * 60 * 60 * 1000;
|
|
368
|
+
const expires = new Date(Date.now() + duration);
|
|
369
|
+
|
|
370
|
+
await db.sessions.insert({
|
|
371
|
+
sessionToken: sessionTokenHash,
|
|
372
|
+
userId: user.id,
|
|
373
|
+
expires: expires.getTime(),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const sessionCookie = await buildSessionCookie(secret, 'default', sessionTokenHash);
|
|
377
|
+
const { locals } = _getContext();
|
|
378
|
+
if (!locals.auth) locals.auth = {};
|
|
379
|
+
locals.auth.sessionCookie = sessionCookie;
|
|
380
|
+
_pushSetCookie(_buildSetCookieHeader(_getCookieName(), sessionCookie, {
|
|
381
|
+
expires, httpOnly: true, secure: true, sameSite: 'lax',
|
|
382
|
+
}));
|
|
383
|
+
await _safeLogActivity('user.login', { userId: user.id, detail: { email } });
|
|
384
|
+
|
|
385
|
+
_setRedirect(_config.signInRedirect ?? '/');
|
|
386
|
+
} catch (err) {
|
|
387
|
+
await _safeLogActivity('auth.failed', {
|
|
388
|
+
detail: { phase: 'signin', email: email || null, error: _errorMessage(err) },
|
|
389
|
+
});
|
|
390
|
+
throw err as Error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Sign out the current user.
|
|
396
|
+
* Resolves the user's database from the cookie, deletes the session.
|
|
397
|
+
*/
|
|
398
|
+
export async function signOut(_formData?: FormData): Promise<void> {
|
|
399
|
+
const sessionCookie = _getSessionCookie();
|
|
400
|
+
const secret = _getSecret();
|
|
401
|
+
|
|
402
|
+
if (sessionCookie) {
|
|
403
|
+
const parsed = await parseSessionCookie(secret, sessionCookie);
|
|
404
|
+
if (parsed) {
|
|
405
|
+
if (isOrgAvailable() && parsed.orgId !== 'default') {
|
|
406
|
+
const stub = getOrgStubByName(parsed.orgId);
|
|
407
|
+
if (stub) {
|
|
408
|
+
const sessionRecord = await stub.getSession(parsed.tokenHash);
|
|
409
|
+
await _safeLogActivity('user.logout', {
|
|
410
|
+
userId: sessionRecord?.user?.id ?? null,
|
|
411
|
+
detail: { orgId: parsed.orgId },
|
|
412
|
+
});
|
|
413
|
+
await stub.deleteSession(parsed.tokenHash);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
const db = _getDb();
|
|
417
|
+
const sessionResult = await db.sessions.where({ sessionToken: parsed.tokenHash }).first();
|
|
418
|
+
await _safeLogActivity('user.logout', { userId: sessionResult.data?.userId ?? null });
|
|
419
|
+
await db.sessions.delete({ sessionToken: parsed.tokenHash });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
_pushSetCookie(_buildClearCookieHeader(_getCookieName()));
|
|
425
|
+
_setRedirect(_config.signOutRedirect ?? '/auth/login');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ============================================================================
|
|
429
|
+
// Password reset token expiry (15 minutes)
|
|
430
|
+
// ============================================================================
|
|
431
|
+
|
|
432
|
+
const PASSWORD_RESET_TTL_MS = 15 * 60 * 1000;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Request a password reset for the given email.
|
|
436
|
+
*
|
|
437
|
+
* Generates a signed reset token, stores a hashed copy in `passwordResets`,
|
|
438
|
+
* and sends the reset link via email (requires RESEND_API_KEY + EMAIL_FROM).
|
|
439
|
+
*
|
|
440
|
+
* Standard mode only — org-mode apps delegate account recovery to their own
|
|
441
|
+
* per-org flow using the same pattern.
|
|
442
|
+
*
|
|
443
|
+
* The response is always identical regardless of whether the email exists to
|
|
444
|
+
* prevent user enumeration.
|
|
445
|
+
*/
|
|
446
|
+
export async function requestPasswordReset(formData: FormData): Promise<void> {
|
|
447
|
+
const email = (formData.get('email') as string)?.trim().toLowerCase();
|
|
448
|
+
|
|
449
|
+
if (!email) throw new Error('Email is required');
|
|
450
|
+
|
|
451
|
+
// Silently succeed for unrecognised addresses (prevents user enumeration)
|
|
452
|
+
try {
|
|
453
|
+
const db = _getDb();
|
|
454
|
+
const userResult = await db.users.where({ email }).first();
|
|
455
|
+
const user = userResult.data;
|
|
456
|
+
if (!user) {
|
|
457
|
+
_setRedirect('/auth/forgot-password?sent=1');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Clean up any existing unused tokens for this user
|
|
462
|
+
await db.passwordResets.delete({ userId: user.id });
|
|
463
|
+
|
|
464
|
+
const token = generateSessionToken();
|
|
465
|
+
const tokenHash = await hashToken(token);
|
|
466
|
+
const expiresAt = Date.now() + PASSWORD_RESET_TTL_MS;
|
|
467
|
+
|
|
468
|
+
await db.passwordResets.insert({ userId: user.id, tokenHash, expiresAt });
|
|
469
|
+
|
|
470
|
+
const env = _getEnv();
|
|
471
|
+
const origin = env.ORIGIN ?? 'http://localhost:8787';
|
|
472
|
+
const resetUrl = `${origin}/auth/reset-password?token=${token}`;
|
|
473
|
+
|
|
474
|
+
await _sendPasswordResetEmail({ email, resetUrl, env });
|
|
475
|
+
|
|
476
|
+
await _safeLogActivity('user.password_reset_requested', {
|
|
477
|
+
userId: user.id,
|
|
478
|
+
detail: { email },
|
|
479
|
+
});
|
|
480
|
+
} catch (err) {
|
|
481
|
+
await _safeLogActivity('auth.failed', {
|
|
482
|
+
detail: { phase: 'password_reset_request', email, error: _errorMessage(err) },
|
|
483
|
+
});
|
|
484
|
+
throw err;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
_setRedirect('/auth/forgot-password?sent=1');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Reset the user's password using a valid reset token.
|
|
492
|
+
*
|
|
493
|
+
* Validates the token, updates the password hash, invalidates all sessions,
|
|
494
|
+
* and deletes the used token. Redirects to login on success.
|
|
495
|
+
*/
|
|
496
|
+
export async function resetPassword(formData: FormData): Promise<void> {
|
|
497
|
+
const token = (formData.get('token') as string)?.trim();
|
|
498
|
+
const password = formData.get('password') as string;
|
|
499
|
+
const confirmPassword = formData.get('confirmPassword') as string;
|
|
500
|
+
|
|
501
|
+
if (!token) throw new Error('Reset token is missing');
|
|
502
|
+
if (!password) throw new Error('Password is required');
|
|
503
|
+
|
|
504
|
+
const minLen = _config.minPasswordLength ?? 8;
|
|
505
|
+
if (password.length < minLen) {
|
|
506
|
+
throw new Error(`Password must be at least ${minLen} characters`);
|
|
507
|
+
}
|
|
508
|
+
if (password !== confirmPassword) {
|
|
509
|
+
throw new Error('Passwords do not match');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const tokenHash = await hashToken(token);
|
|
514
|
+
const db = _getDb();
|
|
515
|
+
|
|
516
|
+
const resetResult = await db.passwordResets.where({ tokenHash }).first();
|
|
517
|
+
const reset = resetResult.data;
|
|
518
|
+
|
|
519
|
+
if (!reset) throw new Error('Invalid or expired reset link');
|
|
520
|
+
if (reset.expiresAt < Date.now()) {
|
|
521
|
+
await db.passwordResets.delete({ tokenHash });
|
|
522
|
+
throw new Error('This reset link has expired. Please request a new one.');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const secret = _getSecret();
|
|
526
|
+
const passwordHash = await hashPassword(password, undefined, secret);
|
|
527
|
+
|
|
528
|
+
await db.users.update({ passwordHash }, { id: reset.userId });
|
|
529
|
+
|
|
530
|
+
// Invalidate all existing sessions for security
|
|
531
|
+
await db.sessions.delete({ userId: reset.userId });
|
|
532
|
+
|
|
533
|
+
// Clean up the used token
|
|
534
|
+
await db.passwordResets.delete({ tokenHash });
|
|
535
|
+
|
|
536
|
+
await _safeLogActivity('user.password_reset_completed', {
|
|
537
|
+
userId: reset.userId,
|
|
538
|
+
detail: {},
|
|
539
|
+
});
|
|
540
|
+
} catch (err) {
|
|
541
|
+
await _safeLogActivity('auth.failed', {
|
|
542
|
+
detail: { phase: 'password_reset', error: _errorMessage(err) },
|
|
543
|
+
});
|
|
544
|
+
throw err;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
_setRedirect('/auth/login?reset=1');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Internal email helper ────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
async function _sendPasswordResetEmail(opts: {
|
|
553
|
+
email: string;
|
|
554
|
+
resetUrl: string;
|
|
555
|
+
env: Record<string, any>;
|
|
556
|
+
}): Promise<void> {
|
|
557
|
+
const { email, resetUrl, env } = opts;
|
|
558
|
+
const apiKey = env.RESEND_API_KEY;
|
|
559
|
+
const from = env.EMAIL_FROM ?? 'noreply@example.com';
|
|
560
|
+
|
|
561
|
+
if (!apiKey) {
|
|
562
|
+
// Dev fallback: log to console so the flow still works without email
|
|
563
|
+
console.warn(`[kuratchi/auth] RESEND_API_KEY not set. Password reset URL: ${resetUrl}`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const res = await fetch('https://api.resend.com/emails', {
|
|
568
|
+
method: 'POST',
|
|
569
|
+
headers: {
|
|
570
|
+
Authorization: `Bearer ${apiKey}`,
|
|
571
|
+
'Content-Type': 'application/json',
|
|
572
|
+
},
|
|
573
|
+
body: JSON.stringify({
|
|
574
|
+
from,
|
|
575
|
+
to: [email],
|
|
576
|
+
subject: 'Reset your password',
|
|
577
|
+
html: `
|
|
578
|
+
<p>You requested a password reset.</p>
|
|
579
|
+
<p><a href="${resetUrl}">Click here to reset your password</a></p>
|
|
580
|
+
<p>This link expires in 15 minutes. If you didn't request this, you can ignore this email.</p>
|
|
581
|
+
<p style="color:#888;font-size:12px;">${resetUrl}</p>
|
|
582
|
+
`.trim(),
|
|
583
|
+
}),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
if (!res.ok) {
|
|
587
|
+
const body = await res.text().catch(() => '');
|
|
588
|
+
throw new Error(`[kuratchi/auth] Failed to send reset email: ${res.status} ${body}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Get the current authenticated user.
|
|
594
|
+
* Resolves the user's database from the cookie, validates session,
|
|
595
|
+
* returns a safe user object (no passwordHash).
|
|
596
|
+
*/
|
|
597
|
+
export async function getCurrentUser(): Promise<Record<string, any> | null> {
|
|
598
|
+
const sessionCookie = _getSessionCookie();
|
|
599
|
+
if (!sessionCookie) return null;
|
|
600
|
+
|
|
601
|
+
const secret = _getSecret();
|
|
602
|
+
const parsed = await parseSessionCookie(secret, sessionCookie);
|
|
603
|
+
if (!parsed) return null;
|
|
604
|
+
|
|
605
|
+
// ── Org-aware mode ─────────────────────────────────────
|
|
606
|
+
if (isOrgAvailable() && parsed.orgId !== 'default') {
|
|
607
|
+
const stub = getOrgStubByName(parsed.orgId);
|
|
608
|
+
if (!stub) return null;
|
|
609
|
+
|
|
610
|
+
const result = await stub.getSession(parsed.tokenHash);
|
|
611
|
+
if (!result?.user) return null;
|
|
612
|
+
|
|
613
|
+
const user = result.user as Record<string, any>;
|
|
614
|
+
user.orgId = parsed.orgId;
|
|
615
|
+
// Enrich with org metadata from admin DB so UI can render org-aware labels.
|
|
616
|
+
try {
|
|
617
|
+
const db = _getDb();
|
|
618
|
+
const orgResult = await db.organizations.where({ doName: parsed.orgId }).first();
|
|
619
|
+
const org = orgResult.data as Record<string, any> | undefined;
|
|
620
|
+
if (org) {
|
|
621
|
+
if (!user.organization && typeof org.orgName === 'string') {
|
|
622
|
+
user.organization = org.orgName;
|
|
623
|
+
}
|
|
624
|
+
if (user.organizationId == null && org.id != null) {
|
|
625
|
+
user.organizationId = org.id;
|
|
626
|
+
}
|
|
627
|
+
if (!user.plan && typeof org.plan === 'string') {
|
|
628
|
+
user.plan = org.plan;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
} catch {
|
|
632
|
+
// Do not fail auth if org enrichment is unavailable.
|
|
633
|
+
}
|
|
634
|
+
if (!user.role) user.role = 'user';
|
|
635
|
+
return user;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ── Standard D1 mode ──────────────────────────────────
|
|
639
|
+
const db = _getDb();
|
|
640
|
+
|
|
641
|
+
const sessionResult = await db.sessions.where({ sessionToken: parsed.tokenHash }).first();
|
|
642
|
+
const session = sessionResult.data;
|
|
643
|
+
if (!session) return null;
|
|
644
|
+
|
|
645
|
+
if (session.expires < Date.now()) {
|
|
646
|
+
await db.sessions.delete({ sessionToken: parsed.tokenHash });
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const userResult = await db.users.where({ id: session.userId }).first();
|
|
651
|
+
const user = userResult.data;
|
|
652
|
+
if (!user) return null;
|
|
653
|
+
|
|
654
|
+
const exclude = new Set(_config.excludeFields ?? ['passwordHash']);
|
|
655
|
+
const safeUser: Record<string, any> = {};
|
|
656
|
+
for (const [key, value] of Object.entries(user)) {
|
|
657
|
+
if (!exclude.has(key)) safeUser[key] = value;
|
|
658
|
+
}
|
|
659
|
+
if (!safeUser.role) safeUser.role = 'user';
|
|
660
|
+
|
|
661
|
+
return safeUser;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
|