@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
package/src/core/auth.ts
ADDED
|
@@ -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
|
+
|