@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.
@@ -0,0 +1,424 @@
1
+ /**
2
+ * @kuratchi/auth — Backend Auth Utility
3
+ *
4
+ * The primary API for server-side auth in KuratchiJS.
5
+ * Call getAuth() from load(), actions, or rpc functions to get
6
+ * explicit, on-demand auth context — no middleware required.
7
+ *
8
+ * The framework's compiler injects thin session cookie parsing
9
+ * into the Worker entry (via kuratchi.config.ts auth config), which
10
+ * populates locals.auth with the raw cookie. getAuth() then
11
+ * builds the full auth context lazily on first call.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * // src/server/routes/dashboard.ts
16
+ * import { getAuth } from '@kuratchi/auth';
17
+ *
18
+ * export async function load() {
19
+ * const auth = getAuth();
20
+ * const session = await auth.getSession();
21
+ * if (!session) return auth.redirect('/login');
22
+ *
23
+ * return {
24
+ * user: session.user,
25
+ * permissions: auth.getPermissions(),
26
+ * };
27
+ * }
28
+ * ```
29
+ */
30
+
31
+ import type { AuthEnv } from './plugin.js';
32
+ import { parseSessionCookie } from '../utils/crypto.js';
33
+
34
+ // ============================================================================
35
+ // Types
36
+ // ============================================================================
37
+
38
+ export interface AuthContext {
39
+ /** Get the current session (decrypts cookie, validates, returns session or null) */
40
+ getSession: () => Promise<AuthSession | null>;
41
+
42
+ /** Get the current user (shorthand for getSession().user) */
43
+ getUser: () => Promise<any | null>;
44
+
45
+ /** Check if the current request is authenticated */
46
+ isAuthenticated: () => Promise<boolean>;
47
+
48
+ /** Check a permission against the session's roles */
49
+ hasPermission: (permission: string) => Promise<boolean>;
50
+
51
+ /** Check if user has a specific role */
52
+ hasRole: (role: string) => Promise<boolean>;
53
+
54
+ /** Get all permissions for the current user (serializable for client) */
55
+ getPermissions: () => Promise<string[]>;
56
+
57
+ /** Get the raw session cookie value (before decryption) */
58
+ getSessionCookie: () => string | null;
59
+
60
+ /** Get parsed cookies from the request */
61
+ getCookies: () => Record<string, string>;
62
+
63
+ /** Build a Set-Cookie header string */
64
+ buildSetCookie: (name: string, value: string, opts?: CookieOptions) => string;
65
+
66
+ /** Build a clear-cookie header string */
67
+ buildClearCookie: (name: string) => string;
68
+
69
+ /** Create a redirect Response */
70
+ redirect: (url: string, status?: number) => Response;
71
+
72
+ /** Create a 401/403 error Response */
73
+ forbidden: (message?: string) => Response;
74
+
75
+ /** Create a JSON Response */
76
+ json: (data: any, status?: number) => Response;
77
+
78
+ /** Get auth env values (AUTH_SECRET, etc.) */
79
+ getAuthEnv: () => AuthEnv;
80
+
81
+ /** Get the request-scoped locals */
82
+ getLocals: () => Record<string, any>;
83
+
84
+ /** Get the raw request */
85
+ getRequest: () => Request;
86
+
87
+ /** Get the Worker env bindings */
88
+ getEnv: () => Record<string, any>;
89
+ }
90
+
91
+ export interface AuthSession {
92
+ userId: string;
93
+ email?: string;
94
+ organizationId?: string;
95
+ user?: any;
96
+ roles?: string[];
97
+ permissions?: string[];
98
+ expiresAt?: string;
99
+ [key: string]: any;
100
+ }
101
+
102
+ export interface CookieOptions {
103
+ expires?: Date;
104
+ maxAge?: number;
105
+ path?: string;
106
+ httpOnly?: boolean;
107
+ secure?: boolean;
108
+ sameSite?: 'strict' | 'lax' | 'none';
109
+ }
110
+
111
+ export interface GetAuthOptions {
112
+ /**
113
+ * Custom env resolver — override automatic env resolution.
114
+ * If not provided, reads AUTH_SECRET and other keys directly from env.
115
+ */
116
+ getEnv?: (env: Record<string, any>) => AuthEnv;
117
+
118
+ /**
119
+ * Map env binding names to auth env keys.
120
+ * Example: { AUTH_SECRET: 'MY_AUTH_SECRET' }
121
+ */
122
+ envMap?: Record<string, string>;
123
+
124
+ /**
125
+ * Custom session decoder — override the default AES-GCM cookie decryption.
126
+ * Useful for JWT tokens or external session stores.
127
+ */
128
+ decodeSession?: (cookie: string, secret: string) => Promise<AuthSession | null>;
129
+
130
+ /**
131
+ * Custom session loader — called after cookie decryption to load full session from DB.
132
+ * Receives the decoded session payload and should return the enriched session.
133
+ */
134
+ loadSession?: (decoded: AuthSession, env: Record<string, any>) => Promise<AuthSession | null>;
135
+
136
+ /**
137
+ * Static role → permissions map for hasPermission/hasRole checks.
138
+ * Example: { admin: ['*'], editor: ['posts.*', 'comments.*'] }
139
+ */
140
+ permissions?: Record<string, string[]>;
141
+
142
+ /**
143
+ * Framework context — provide env, request, and locals directly.
144
+ * If not provided, getAuth() reads from KuratchiJS's module-scoped context
145
+ * (getEnv/getRequest/getLocals from KuratchiJS/runtime/context.js).
146
+ */
147
+ context?: {
148
+ env: Record<string, any>;
149
+ request: Request;
150
+ locals: Record<string, any>;
151
+ };
152
+ }
153
+
154
+ // ============================================================================
155
+ // Implementation
156
+ // ============================================================================
157
+
158
+ /**
159
+ * Resolve auth env values from Worker env bindings.
160
+ */
161
+ function resolveAuthEnv(env: Record<string, any>, envMap?: Record<string, string>): AuthEnv {
162
+ const pick = (key: string): string | undefined => {
163
+ if (envMap?.[key]) {
164
+ const val = env[envMap[key]];
165
+ if (val !== undefined && val !== null && String(val).length > 0) return String(val);
166
+ }
167
+ const val = env[key];
168
+ if (val !== undefined && val !== null && String(val).length > 0) return String(val);
169
+ return undefined;
170
+ };
171
+
172
+ return {
173
+ AUTH_SECRET: pick('AUTH_SECRET') || pick('kuratchi_AUTH_SECRET') || '',
174
+ ORIGIN: pick('ORIGIN') || pick('APP_ORIGIN'),
175
+ RESEND_API_KEY: pick('RESEND_API_KEY'),
176
+ EMAIL_FROM: pick('EMAIL_FROM'),
177
+ GOOGLE_CLIENT_ID: pick('GOOGLE_CLIENT_ID'),
178
+ GOOGLE_CLIENT_SECRET: pick('GOOGLE_CLIENT_SECRET'),
179
+ GITHUB_CLIENT_ID: pick('GITHUB_CLIENT_ID'),
180
+ GITHUB_CLIENT_SECRET: pick('GITHUB_CLIENT_SECRET'),
181
+ TURNSTILE_SECRET: pick('TURNSTILE_SECRET') || pick('CLOUDFLARE_TURNSTILE_SECRET'),
182
+ TURNSTILE_SITE_KEY: pick('TURNSTILE_SITE_KEY') || pick('CLOUDFLARE_TURNSTILE_SITE_KEY'),
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Check if a permission matches a pattern (supports wildcards).
188
+ * 'posts.*' matches 'posts.create', 'posts.delete', etc.
189
+ * '*' matches everything.
190
+ */
191
+ function matchPermission(pattern: string, permission: string): boolean {
192
+ if (pattern === '*') return true;
193
+ if (pattern === permission) return true;
194
+ if (pattern.endsWith('.*')) {
195
+ const prefix = pattern.slice(0, -2);
196
+ return permission.startsWith(prefix + '.') || permission === prefix;
197
+ }
198
+ return false;
199
+ }
200
+
201
+ /**
202
+ * Get auth context for the current request.
203
+ *
204
+ * This is the primary API for backend auth. It reads from the
205
+ * framework's request-scoped context (set by the compiler-injected
206
+ * session init) and provides lazy, cached auth operations.
207
+ *
208
+ * @param options - Optional configuration for env resolution, session decoding, etc.
209
+ */
210
+ export function getAuth(options: GetAuthOptions = {}): AuthContext {
211
+ // Resolve framework context: either from explicit options or from KuratchiJS globals
212
+ let _env: Record<string, any>;
213
+ let _request: Request;
214
+ let _locals: Record<string, any>;
215
+
216
+ if (options.context) {
217
+ // Explicit context provided (e.g., from load/action/rpc args)
218
+ _env = options.context.env;
219
+ _request = options.context.request;
220
+ _locals = options.context.locals;
221
+ } else {
222
+ // Read from KuratchiJS globals set by the compiler-generated Worker entry:
223
+ // __kuratchi_context__ → request, locals (per-request)
224
+ // __cloudflare_env__ → env bindings (set once via __setEnvCompat)
225
+ try {
226
+ const dezContext = (globalThis as any).__kuratchi_context__;
227
+ _env = (globalThis as any).__cloudflare_env__ ?? {};
228
+ _request = dezContext?.request ?? new Request('http://localhost');
229
+ _locals = dezContext?.locals ?? {};
230
+ } catch {
231
+ _env = {};
232
+ _request = new Request('http://localhost');
233
+ _locals = {};
234
+ }
235
+ }
236
+
237
+ // Resolve auth env
238
+ const authEnv = options.getEnv
239
+ ? options.getEnv(_env)
240
+ : resolveAuthEnv(_env, options.envMap);
241
+
242
+ // Cached session (resolved once per getAuth() call)
243
+ let _session: AuthSession | null | undefined = undefined;
244
+ let _sessionPromise: Promise<AuthSession | null> | null = null;
245
+
246
+ const getSessionCookie = (): string | null => {
247
+ return _locals.auth?.sessionCookie || null;
248
+ };
249
+
250
+ const getCookies = (): Record<string, string> => {
251
+ return _locals.auth?.cookies || {};
252
+ };
253
+
254
+ const resolveSession = async (): Promise<AuthSession | null> => {
255
+ if (_session !== undefined) return _session;
256
+
257
+ const cookie = getSessionCookie();
258
+ if (!cookie) {
259
+ _session = null;
260
+ return null;
261
+ }
262
+
263
+ if (!authEnv.AUTH_SECRET) {
264
+ console.error('[kuratchi/auth] AUTH_SECRET not set — cannot decrypt session cookie. Add it to .dev.vars or Workers secrets.');
265
+ _session = null;
266
+ return null;
267
+ }
268
+
269
+ try {
270
+ // Custom decoder
271
+ let decoded: AuthSession | null = null;
272
+ if (options.decodeSession) {
273
+ decoded = await options.decodeSession(cookie, authEnv.AUTH_SECRET);
274
+ } else {
275
+ // Default: AES-GCM decryption via parseSessionCookie
276
+ const parsed = await parseSessionCookie(authEnv.AUTH_SECRET, cookie);
277
+ if (parsed) {
278
+ // The cookie contains { orgId, tokenHash } — build session from it
279
+ decoded = {
280
+ userId: '', // Will be enriched by loadSession
281
+ organizationId: parsed.orgId,
282
+ tokenHash: parsed.tokenHash,
283
+ };
284
+ }
285
+ }
286
+
287
+ if (!decoded) {
288
+ _session = null;
289
+ return null;
290
+ }
291
+
292
+ // Check expiry
293
+ if (decoded.expiresAt && new Date(decoded.expiresAt) < new Date()) {
294
+ _session = null;
295
+ return null;
296
+ }
297
+
298
+ // Custom session loader (e.g., enrich from DB)
299
+ if (options.loadSession) {
300
+ decoded = await options.loadSession(decoded, _env);
301
+ }
302
+
303
+ _session = decoded;
304
+
305
+ // Also set on locals for downstream access
306
+ _locals.session = decoded;
307
+ _locals.user = decoded?.user || null;
308
+
309
+ return decoded;
310
+ } catch (e) {
311
+ console.warn('[kuratchi/auth] Failed to decrypt session cookie:', e);
312
+ _session = null;
313
+ return null;
314
+ }
315
+ };
316
+
317
+ return {
318
+ getSession: async () => {
319
+ if (_sessionPromise) return _sessionPromise;
320
+ _sessionPromise = resolveSession();
321
+ return _sessionPromise;
322
+ },
323
+
324
+ getUser: async () => {
325
+ const session = await resolveSession();
326
+ return session?.user || null;
327
+ },
328
+
329
+ isAuthenticated: async () => {
330
+ const session = await resolveSession();
331
+ return session !== null;
332
+ },
333
+
334
+ hasPermission: async (permission: string) => {
335
+ const session = await resolveSession();
336
+ if (!session) return false;
337
+
338
+ // Check session-level permissions first
339
+ if (session.permissions?.some((p: string) => matchPermission(p, permission))) {
340
+ return true;
341
+ }
342
+
343
+ // Check role-based permissions from options
344
+ if (options.permissions && session.roles) {
345
+ for (const role of session.roles) {
346
+ const rolePerms = options.permissions[role];
347
+ if (rolePerms?.some(p => matchPermission(p, permission))) {
348
+ return true;
349
+ }
350
+ }
351
+ }
352
+
353
+ return false;
354
+ },
355
+
356
+ hasRole: async (role: string) => {
357
+ const session = await resolveSession();
358
+ return session?.roles?.includes(role) ?? false;
359
+ },
360
+
361
+ getPermissions: async () => {
362
+ const session = await resolveSession();
363
+ if (!session) return [];
364
+
365
+ const perms = new Set<string>(session.permissions || []);
366
+
367
+ // Expand role-based permissions
368
+ if (options.permissions && session.roles) {
369
+ for (const role of session.roles) {
370
+ const rolePerms = options.permissions[role];
371
+ if (rolePerms) {
372
+ for (const p of rolePerms) perms.add(p);
373
+ }
374
+ }
375
+ }
376
+
377
+ return Array.from(perms);
378
+ },
379
+
380
+ getSessionCookie,
381
+ getCookies,
382
+
383
+ buildSetCookie: (name: string, value: string, opts?: CookieOptions) => {
384
+ const parts = [`${name}=${value}`];
385
+ parts.push(`Path=${opts?.path || '/'}`);
386
+ if (opts?.httpOnly !== false) parts.push('HttpOnly');
387
+ if (opts?.secure !== false) parts.push('Secure');
388
+ parts.push(`SameSite=${opts?.sameSite || 'Lax'}`);
389
+ if (opts?.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);
390
+ if (opts?.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`);
391
+ return parts.join('; ');
392
+ },
393
+
394
+ buildClearCookie: (name: string) => {
395
+ return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
396
+ },
397
+
398
+ redirect: (url: string, status = 302) => {
399
+ return new Response(null, { status, headers: { Location: url } });
400
+ },
401
+
402
+ forbidden: (message = 'Forbidden') => {
403
+ return new Response(JSON.stringify({ error: message }), {
404
+ status: 403,
405
+ headers: { 'content-type': 'application/json' },
406
+ });
407
+ },
408
+
409
+ json: (data: any, status = 200) => {
410
+ return new Response(JSON.stringify(data), {
411
+ status,
412
+ headers: { 'content-type': 'application/json' },
413
+ });
414
+ },
415
+
416
+ getAuthEnv: () => authEnv,
417
+ getLocals: () => _locals,
418
+ getRequest: () => _request,
419
+ getEnv: () => _env,
420
+ };
421
+ }
422
+
423
+
424
+