@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,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kuratchi/auth — Guards API
|
|
3
|
+
*
|
|
4
|
+
* Route protection that runs before route handlers.
|
|
5
|
+
* Configure in kuratchi.config.ts, or call requireAuth() in server functions.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* // In kuratchi.config.ts:
|
|
10
|
+
* auth: {
|
|
11
|
+
* guards: {
|
|
12
|
+
* paths: ['/admin/*', '/dashboard/*'],
|
|
13
|
+
* exclude: ['/admin/login'],
|
|
14
|
+
* redirectTo: '/auth/login',
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* // Or call directly in a server function:
|
|
19
|
+
* import { requireAuth } from '@kuratchi/auth';
|
|
20
|
+
* const user = await requireAuth(); // throws redirect if not authenticated
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
export interface GuardsConfig {
|
|
29
|
+
/** Paths to protect (glob patterns). If empty, protects all paths. */
|
|
30
|
+
paths?: string[];
|
|
31
|
+
/** Paths to exclude from protection (glob patterns) */
|
|
32
|
+
exclude?: string[];
|
|
33
|
+
/** Redirect URL if not authenticated (default: '/auth/login') */
|
|
34
|
+
redirectTo?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Module state
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
let _config: GuardsConfig | null = null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Configure route guards. Called automatically by the compiler from kuratchi.config.ts.
|
|
45
|
+
*/
|
|
46
|
+
export function configureGuards(config: GuardsConfig): void {
|
|
47
|
+
_config = config;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Framework context
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
function _getContext() {
|
|
55
|
+
const dezContext = (globalThis as any).__kuratchi_context__;
|
|
56
|
+
return {
|
|
57
|
+
request: dezContext?.request as Request | undefined,
|
|
58
|
+
locals: dezContext?.locals as Record<string, any> ?? {},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Pattern matching
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
function _matchPattern(pathname: string, pattern: string): boolean {
|
|
67
|
+
// /admin/* should match /admin, /admin/, /admin/roles, etc.
|
|
68
|
+
if (pattern.endsWith('/*')) {
|
|
69
|
+
const base = pattern.slice(0, -2); // '/admin'
|
|
70
|
+
if (pathname === base || pathname.startsWith(base + '/')) return true;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const regexStr = pattern
|
|
74
|
+
.replace(/\*/g, '.*')
|
|
75
|
+
.replace(/\//g, '\\/');
|
|
76
|
+
return new RegExp(`^${regexStr}$`).test(pathname);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// Public API
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if the current request should be guarded.
|
|
85
|
+
* Called by the compiler-generated worker entry before route handlers.
|
|
86
|
+
* Returns a redirect Response if not authenticated, or null to proceed.
|
|
87
|
+
*/
|
|
88
|
+
export function checkGuard(): Response | null {
|
|
89
|
+
if (!_config) return null;
|
|
90
|
+
|
|
91
|
+
const { request, locals } = _getContext();
|
|
92
|
+
if (!request) return null;
|
|
93
|
+
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
const pathname = url.pathname;
|
|
96
|
+
|
|
97
|
+
// Skip internal asset routes
|
|
98
|
+
if (pathname.startsWith('/_assets/')) return null;
|
|
99
|
+
|
|
100
|
+
// Check if path is protected
|
|
101
|
+
const paths = _config.paths || [];
|
|
102
|
+
const exclude = _config.exclude || [];
|
|
103
|
+
|
|
104
|
+
if (paths.length > 0) {
|
|
105
|
+
const included = paths.some(p => _matchPattern(pathname, p));
|
|
106
|
+
if (!included) return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (exclude.length > 0) {
|
|
110
|
+
const excluded = exclude.some(p => _matchPattern(pathname, p));
|
|
111
|
+
if (excluded) return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check if authenticated (session cookie exists)
|
|
115
|
+
const sessionCookie = locals.auth?.sessionCookie;
|
|
116
|
+
if (sessionCookie) return null; // Has session cookie — allow through
|
|
117
|
+
|
|
118
|
+
// Not authenticated — redirect
|
|
119
|
+
const redirectTo = _config.redirectTo || '/auth/login';
|
|
120
|
+
return new Response(null, {
|
|
121
|
+
status: 302,
|
|
122
|
+
headers: { Location: redirectTo },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Callable guard for use in server functions.
|
|
128
|
+
* Returns the current user or redirects if not authenticated.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```ts
|
|
132
|
+
* const user = await requireAuth();
|
|
133
|
+
* // If we get here, user is authenticated
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export async function requireAuth(options?: {
|
|
137
|
+
redirectTo?: string;
|
|
138
|
+
}): Promise<Record<string, any>> {
|
|
139
|
+
const { getCurrentUser } = await import('./credentials.js');
|
|
140
|
+
const user = await getCurrentUser();
|
|
141
|
+
|
|
142
|
+
if (!user) {
|
|
143
|
+
const { locals } = _getContext();
|
|
144
|
+
locals.__redirectTo = options?.redirectTo || _config?.redirectTo || '/auth/login';
|
|
145
|
+
throw new Error('Authentication required');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return user;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kuratchi/auth — OAuth API
|
|
3
|
+
*
|
|
4
|
+
* Ready-to-use server functions for OAuth flows.
|
|
5
|
+
* Configure providers once, then startOAuth/handleOAuthCallback just work.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { configureOAuth, startOAuth, handleOAuthCallback, getOAuthData } from '@kuratchi/auth';
|
|
10
|
+
*
|
|
11
|
+
* configureOAuth({
|
|
12
|
+
* providers: {
|
|
13
|
+
* github: {
|
|
14
|
+
* clientId: env.GITHUB_CLIENT_ID,
|
|
15
|
+
* clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
16
|
+
* },
|
|
17
|
+
* },
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
signState,
|
|
24
|
+
verifyState,
|
|
25
|
+
hashToken,
|
|
26
|
+
buildSessionCookie,
|
|
27
|
+
generateSessionToken,
|
|
28
|
+
} from '../utils/crypto.js';
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Types
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
export interface OAuthProviderConfig {
|
|
35
|
+
clientId: string;
|
|
36
|
+
clientSecret: string;
|
|
37
|
+
scopes?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface OAuthConfig {
|
|
41
|
+
providers: Record<string, OAuthProviderConfig>;
|
|
42
|
+
/** Redirect after successful OAuth login (default: '/admin') */
|
|
43
|
+
loginRedirect?: string;
|
|
44
|
+
/** Session duration in ms (default: 30 days) */
|
|
45
|
+
sessionDuration?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ProviderEndpoints {
|
|
49
|
+
authorizeUrl: string;
|
|
50
|
+
tokenUrl: string;
|
|
51
|
+
profileUrl: string;
|
|
52
|
+
defaultScopes: string[];
|
|
53
|
+
parseProfile: (data: any) => { id: string; email: string; name: string | null; image: string | null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Known providers
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
const PROVIDERS: Record<string, ProviderEndpoints> = {
|
|
61
|
+
github: {
|
|
62
|
+
authorizeUrl: 'https://github.com/login/oauth/authorize',
|
|
63
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
64
|
+
profileUrl: 'https://api.github.com/user',
|
|
65
|
+
defaultScopes: ['read:user', 'user:email'],
|
|
66
|
+
parseProfile: (data: any) => ({
|
|
67
|
+
id: String(data.id),
|
|
68
|
+
email: data.email?.toLowerCase() || `${data.login}@github.noemail`,
|
|
69
|
+
name: data.name || data.login || null,
|
|
70
|
+
image: data.avatar_url || null,
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
google: {
|
|
74
|
+
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
75
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
76
|
+
profileUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
|
77
|
+
defaultScopes: ['openid', 'email', 'profile'],
|
|
78
|
+
parseProfile: (data: any) => ({
|
|
79
|
+
id: String(data.id),
|
|
80
|
+
email: data.email?.toLowerCase() || '',
|
|
81
|
+
name: data.name || null,
|
|
82
|
+
image: data.picture || null,
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Module state
|
|
89
|
+
// ============================================================================
|
|
90
|
+
|
|
91
|
+
let _config: OAuthConfig | null = null;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Configure OAuth providers. Call once at module scope.
|
|
95
|
+
*/
|
|
96
|
+
export function configureOAuth(config: OAuthConfig): void {
|
|
97
|
+
_config = config;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get configured OAuth provider info (for UI rendering).
|
|
102
|
+
*/
|
|
103
|
+
export function getOAuthProviders(): string[] {
|
|
104
|
+
if (!_config) return [];
|
|
105
|
+
return Object.keys(_config.providers).filter(p => {
|
|
106
|
+
const c = _config!.providers[p];
|
|
107
|
+
return !!c.clientId;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Framework context
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
function _getEnv(): Record<string, any> {
|
|
116
|
+
return (globalThis as any).__cloudflare_env__ ?? {};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _getContext() {
|
|
120
|
+
const dezContext = (globalThis as any).__kuratchi_context__;
|
|
121
|
+
return {
|
|
122
|
+
request: dezContext?.request as Request | undefined,
|
|
123
|
+
locals: dezContext?.locals as Record<string, any> ?? {},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _getDb(): any {
|
|
128
|
+
const env = _getEnv();
|
|
129
|
+
if (!env.DB) throw new Error('[kuratchi/auth] No DB binding found.');
|
|
130
|
+
return env.DB;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _getSecret(): string {
|
|
134
|
+
const secret = _getEnv().AUTH_SECRET;
|
|
135
|
+
if (!secret) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'[kuratchi/auth] AUTH_SECRET is not set. Add it to .dev.vars (local) or Workers secrets (production). '
|
|
138
|
+
+ 'Auth operations cannot proceed without a secret.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return secret;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _getCookieName(): string {
|
|
145
|
+
const { locals } = _getContext();
|
|
146
|
+
return locals.auth?.cookieName || 'kuratchi_session';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _setRedirect(path: string) {
|
|
150
|
+
const { locals } = _getContext();
|
|
151
|
+
locals.__redirectTo = path;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _pushSetCookie(header: string) {
|
|
155
|
+
const { locals } = _getContext();
|
|
156
|
+
if (!locals.__setCookieHeaders) locals.__setCookieHeaders = [];
|
|
157
|
+
locals.__setCookieHeaders.push(header);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _buildSetCookieHeader(name: string, value: string, opts: {
|
|
161
|
+
expires?: Date; httpOnly?: boolean; secure?: boolean; sameSite?: string; path?: string;
|
|
162
|
+
}): string {
|
|
163
|
+
const parts = [`${name}=${value}`];
|
|
164
|
+
parts.push(`Path=${opts.path || '/'}`);
|
|
165
|
+
if (opts.httpOnly !== false) parts.push('HttpOnly');
|
|
166
|
+
if (opts.secure !== false) parts.push('Secure');
|
|
167
|
+
parts.push(`SameSite=${opts.sameSite || 'Lax'}`);
|
|
168
|
+
if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);
|
|
169
|
+
return parts.join('; ');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Public API
|
|
174
|
+
// ============================================================================
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get OAuth data for the OAuth page (which providers are configured).
|
|
178
|
+
*/
|
|
179
|
+
export async function getOAuthData() {
|
|
180
|
+
const { request } = _getContext();
|
|
181
|
+
const url = new URL(request?.url || 'http://localhost');
|
|
182
|
+
const origin = `${url.protocol}//${url.host}`;
|
|
183
|
+
|
|
184
|
+
const providers = getOAuthProviders();
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
providers,
|
|
188
|
+
hasGithub: providers.includes('github'),
|
|
189
|
+
hasGoogle: providers.includes('google'),
|
|
190
|
+
origin,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Start an OAuth flow for a given provider.
|
|
196
|
+
* Reads provider name from FormData or defaults to 'github'.
|
|
197
|
+
*/
|
|
198
|
+
export async function startOAuth(formData: FormData): Promise<void> {
|
|
199
|
+
const providerName = (formData.get('provider') as string) || 'github';
|
|
200
|
+
if (!_config) throw new Error('[kuratchi/auth] OAuth not configured. Call configureOAuth() first.');
|
|
201
|
+
|
|
202
|
+
const providerConfig = _config.providers[providerName];
|
|
203
|
+
if (!providerConfig?.clientId) throw new Error(`OAuth provider '${providerName}' not configured.`);
|
|
204
|
+
|
|
205
|
+
const endpoints = PROVIDERS[providerName];
|
|
206
|
+
if (!endpoints) throw new Error(`Unknown OAuth provider: ${providerName}`);
|
|
207
|
+
|
|
208
|
+
const { request } = _getContext();
|
|
209
|
+
const url = new URL(request?.url || 'http://localhost');
|
|
210
|
+
const origin = `${url.protocol}//${url.host}`;
|
|
211
|
+
const secret = _getSecret();
|
|
212
|
+
|
|
213
|
+
const state = await signState(secret, {
|
|
214
|
+
provider: providerName,
|
|
215
|
+
redirectTo: _config.loginRedirect || '/admin',
|
|
216
|
+
ts: Date.now(),
|
|
217
|
+
n: crypto.randomUUID(),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const authUrl = new URL(endpoints.authorizeUrl);
|
|
221
|
+
authUrl.searchParams.set('client_id', providerConfig.clientId);
|
|
222
|
+
authUrl.searchParams.set('redirect_uri', `${origin}/auth/oauth/${providerName}/callback`);
|
|
223
|
+
authUrl.searchParams.set('scope', (providerConfig.scopes || endpoints.defaultScopes).join(' '));
|
|
224
|
+
authUrl.searchParams.set('state', state);
|
|
225
|
+
if (providerName === 'google') {
|
|
226
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
227
|
+
authUrl.searchParams.set('access_type', 'offline');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_setRedirect(authUrl.toString());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Handle OAuth callback. Exchanges code for token, fetches profile,
|
|
235
|
+
* creates/links user, creates session, sets cookie.
|
|
236
|
+
*/
|
|
237
|
+
export async function handleOAuthCallback(): Promise<{ success?: boolean; error?: string; redirectTo?: string }> {
|
|
238
|
+
const { request } = _getContext();
|
|
239
|
+
const url = new URL(request?.url || 'http://localhost');
|
|
240
|
+
const origin = `${url.protocol}//${url.host}`;
|
|
241
|
+
|
|
242
|
+
const code = url.searchParams.get('code');
|
|
243
|
+
const stateParam = url.searchParams.get('state');
|
|
244
|
+
const secret = _getSecret();
|
|
245
|
+
|
|
246
|
+
if (!code || !stateParam) return { error: 'Missing code or state parameter' };
|
|
247
|
+
if (!_config) return { error: 'OAuth not configured' };
|
|
248
|
+
|
|
249
|
+
// Verify state
|
|
250
|
+
const payload = await verifyState(secret, stateParam);
|
|
251
|
+
if (!payload) return { error: 'Invalid or expired state' };
|
|
252
|
+
|
|
253
|
+
const stateData = payload as Record<string, any>;
|
|
254
|
+
if (stateData.ts && Date.now() - stateData.ts > 600000) return { error: 'State expired' };
|
|
255
|
+
|
|
256
|
+
const providerName = stateData.provider || 'github';
|
|
257
|
+
const providerConfig = _config.providers[providerName];
|
|
258
|
+
const endpoints = PROVIDERS[providerName];
|
|
259
|
+
if (!providerConfig || !endpoints) return { error: `Unknown provider: ${providerName}` };
|
|
260
|
+
|
|
261
|
+
// Exchange code for token
|
|
262
|
+
const tokenBody: Record<string, string> = {
|
|
263
|
+
code,
|
|
264
|
+
client_id: providerConfig.clientId,
|
|
265
|
+
client_secret: providerConfig.clientSecret,
|
|
266
|
+
redirect_uri: `${origin}/auth/oauth/${providerName}/callback`,
|
|
267
|
+
};
|
|
268
|
+
if (providerName === 'google') tokenBody.grant_type = 'authorization_code';
|
|
269
|
+
|
|
270
|
+
const tokenRes = await fetch(endpoints.tokenUrl, {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
|
273
|
+
body: new URLSearchParams(tokenBody),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (!tokenRes.ok) return { error: `Token exchange failed: ${await tokenRes.text()}` };
|
|
277
|
+
|
|
278
|
+
const tokenJson: any = await tokenRes.json();
|
|
279
|
+
const access_token = tokenJson.access_token;
|
|
280
|
+
if (!access_token) return { error: 'No access token received' };
|
|
281
|
+
|
|
282
|
+
// Fetch profile
|
|
283
|
+
const profileRes = await fetch(endpoints.profileUrl, {
|
|
284
|
+
headers: { 'Authorization': `Bearer ${access_token}`, 'Accept': 'application/json' },
|
|
285
|
+
});
|
|
286
|
+
if (!profileRes.ok) return { error: `Failed to fetch profile: ${await profileRes.text()}` };
|
|
287
|
+
|
|
288
|
+
const profileData: any = await profileRes.json();
|
|
289
|
+
const profile = endpoints.parseProfile(profileData);
|
|
290
|
+
|
|
291
|
+
const db = _getDb();
|
|
292
|
+
|
|
293
|
+
// Check if OAuth account already linked
|
|
294
|
+
const existingOAuth = await db.prepare(
|
|
295
|
+
'SELECT userId FROM oauthAccounts WHERE provider = ? AND providerAccountId = ?'
|
|
296
|
+
).bind(providerName, profile.id).first();
|
|
297
|
+
|
|
298
|
+
let user: any;
|
|
299
|
+
|
|
300
|
+
if (existingOAuth?.userId) {
|
|
301
|
+
user = await db.prepare('SELECT * FROM users WHERE id = ?').bind(existingOAuth.userId).first();
|
|
302
|
+
} else {
|
|
303
|
+
// Check by email
|
|
304
|
+
user = await db.prepare('SELECT * FROM users WHERE email = ?').bind(profile.email).first();
|
|
305
|
+
|
|
306
|
+
if (!user) {
|
|
307
|
+
// Create new user
|
|
308
|
+
await db.prepare(
|
|
309
|
+
'INSERT INTO users (email, name, passwordHash, role, image, emailVerified) VALUES (?, ?, ?, ?, ?, ?)'
|
|
310
|
+
).bind(profile.email, profile.name, null, 'user', profile.image, Date.now()).run();
|
|
311
|
+
user = await db.prepare('SELECT * FROM users WHERE email = ?').bind(profile.email).first();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Link OAuth account
|
|
315
|
+
if (user) {
|
|
316
|
+
await db.prepare(
|
|
317
|
+
'INSERT INTO oauthAccounts (userId, provider, providerAccountId, accessToken, refreshToken, idToken) VALUES (?, ?, ?, ?, ?, ?)'
|
|
318
|
+
).bind(user.id, providerName, profile.id, access_token, tokenJson.refresh_token || null, null).run();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!user) return { error: 'Failed to create or find user' };
|
|
323
|
+
|
|
324
|
+
// Create session
|
|
325
|
+
const sessionToken = generateSessionToken();
|
|
326
|
+
const sessionTokenHash = await hashToken(sessionToken);
|
|
327
|
+
const now = new Date();
|
|
328
|
+
const duration = _config.sessionDuration ?? 30 * 24 * 60 * 60 * 1000;
|
|
329
|
+
const expires = new Date(now.getTime() + duration);
|
|
330
|
+
|
|
331
|
+
await db.prepare(
|
|
332
|
+
'INSERT INTO sessions (sessionToken, userId, expires) VALUES (?, ?, ?)'
|
|
333
|
+
).bind(sessionTokenHash, user.id, expires.getTime()).run();
|
|
334
|
+
|
|
335
|
+
const sessionCookie = await buildSessionCookie(secret, 'default', sessionTokenHash);
|
|
336
|
+
const cookieName = _getCookieName();
|
|
337
|
+
_pushSetCookie(_buildSetCookieHeader(cookieName, sessionCookie, {
|
|
338
|
+
expires, httpOnly: true, secure: true, sameSite: 'lax',
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
const redirectTo = stateData.redirectTo || _config.loginRedirect || '/admin';
|
|
342
|
+
_setRedirect(redirectTo);
|
|
343
|
+
|
|
344
|
+
return { success: true, redirectTo };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kuratchi/auth — Organization API
|
|
3
|
+
*
|
|
4
|
+
* Multi-tenant DO-backed database orchestrator.
|
|
5
|
+
* Manages the DO namespace binding and provides stub access.
|
|
6
|
+
*
|
|
7
|
+
* The DO exposes explicit RPC methods (createUser, getUserByEmail,
|
|
8
|
+
* createSession, getSession, deleteSession) that use the ORM
|
|
9
|
+
* internally. No proxy needed — credentials.ts calls them directly.
|
|
10
|
+
*
|
|
11
|
+
* Configuration lives in `auth.organizations` in kuratchi.config.ts:
|
|
12
|
+
* ```ts
|
|
13
|
+
* organizations: {
|
|
14
|
+
* binding: 'ORG_DB',
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { getOrgClient, getOrgStubByName } from '@kuratchi/auth';
|
|
21
|
+
*
|
|
22
|
+
* // Get stub by DO name (from session cookie or admin lookup)
|
|
23
|
+
* const stub = getOrgStubByName(doName);
|
|
24
|
+
* const user = await stub.getUserByEmail('user@example.com');
|
|
25
|
+
*
|
|
26
|
+
* // Get stub by org ID (resolves via admin DB)
|
|
27
|
+
* const stub = await getOrgClient(organizationId);
|
|
28
|
+
* const sites = await stub.query({ table: 'sites', method: 'many', args: [{}] });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { kuratchiORM } from '@kuratchi/orm';
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Types
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
export interface OrganizationConfig {
|
|
39
|
+
/** DO namespace binding name in env (e.g. 'ORG_DB') */
|
|
40
|
+
binding: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OrgDatabaseInfo {
|
|
44
|
+
databaseName: string;
|
|
45
|
+
organizationId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Module state
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
let _config: OrganizationConfig | null = null;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Configure organization multi-tenancy. Called by the compiler.
|
|
56
|
+
*/
|
|
57
|
+
export function configureOrganization(config: OrganizationConfig): void {
|
|
58
|
+
_config = config;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Framework context
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
function _getEnv(): Record<string, any> {
|
|
66
|
+
return (globalThis as any).__cloudflare_env__ ?? {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _getDoNamespace(): any {
|
|
70
|
+
if (!_config) return null;
|
|
71
|
+
const env = _getEnv();
|
|
72
|
+
return env[_config.binding] || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _getAdminDb(): Record<string, any> {
|
|
76
|
+
return kuratchiORM(() => {
|
|
77
|
+
const env = _getEnv();
|
|
78
|
+
if (!env.DB) throw new Error('[kuratchi/auth] No DB binding found.');
|
|
79
|
+
return env.DB;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Public API
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get a DO stub by its DO name (for direct RPC calls).
|
|
89
|
+
* The stub exposes the OrganizationDO's RPC methods:
|
|
90
|
+
* stub.createUser(), stub.getUserByEmail(), stub.createSession(),
|
|
91
|
+
* stub.getSession(), stub.deleteSession(), stub.query()
|
|
92
|
+
*/
|
|
93
|
+
export function getOrgStubByName(doName: string): any | null {
|
|
94
|
+
const doNamespace = _getDoNamespace();
|
|
95
|
+
if (!doNamespace) return null;
|
|
96
|
+
const doId = doNamespace.idFromName(doName);
|
|
97
|
+
return doNamespace.get(doId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the DO name for an organization by its ID.
|
|
102
|
+
* Looks up the `organizations` table in the admin D1 via ORM.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveOrgDatabaseName(organizationId: string): Promise<string | null> {
|
|
105
|
+
try {
|
|
106
|
+
const adminDb = _getAdminDb();
|
|
107
|
+
const result = await adminDb.organizations.where({ id: organizationId }).first();
|
|
108
|
+
return result.data?.doName || null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get a DO stub for an organization by its organization ID.
|
|
116
|
+
* Resolves the DO name from admin D1, then returns the stub.
|
|
117
|
+
*/
|
|
118
|
+
export async function getOrgClient(organizationId: string): Promise<any | null> {
|
|
119
|
+
const doName = await resolveOrgDatabaseName(organizationId);
|
|
120
|
+
if (!doName) {
|
|
121
|
+
console.warn(`[kuratchi/auth organization] No database found for org: ${organizationId}`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return getOrgStubByName(doName);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a new organization database (DO provisioning).
|
|
129
|
+
* Returns the generated database name.
|
|
130
|
+
*/
|
|
131
|
+
export async function createOrgDatabase(params: {
|
|
132
|
+
organizationId: string;
|
|
133
|
+
organizationName: string;
|
|
134
|
+
}): Promise<{ databaseName: string }> {
|
|
135
|
+
const doNamespace = _getDoNamespace();
|
|
136
|
+
if (!doNamespace) {
|
|
137
|
+
throw new Error('[kuratchi/auth organization] DO namespace not available');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sanitizedName = params.organizationName
|
|
141
|
+
.toLowerCase()
|
|
142
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
143
|
+
.replace(/-+/g, '-')
|
|
144
|
+
.substring(0, 32);
|
|
145
|
+
const databaseName = `org-${sanitizedName}-${crypto.randomUUID().substring(0, 8)}`;
|
|
146
|
+
|
|
147
|
+
// Touch the DO to provision it (constructor runs initDO → creates tables)
|
|
148
|
+
const doId = doNamespace.idFromName(databaseName);
|
|
149
|
+
doNamespace.get(doId);
|
|
150
|
+
|
|
151
|
+
return { databaseName };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get database info for an organization.
|
|
156
|
+
*/
|
|
157
|
+
export async function getOrgDatabaseInfo(organizationId: string): Promise<OrgDatabaseInfo | null> {
|
|
158
|
+
const databaseName = await resolveOrgDatabaseName(organizationId);
|
|
159
|
+
if (!databaseName) return null;
|
|
160
|
+
return { databaseName, organizationId };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if the organizations plugin is configured and DO namespace is available.
|
|
165
|
+
*/
|
|
166
|
+
export function isOrgAvailable(): boolean {
|
|
167
|
+
return !!_getDoNamespace();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|