UNPKG

userdo

Version:

A Durable Object base class that provides user authentication, per-user data storage, and real-time updates for Cloudflare Workers applications.

737 lines (736 loc) 29.3 kB
import { Hono } from 'hono'; import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; import { UserDO } from './UserDO'; import { SignupRequestSchema, LoginRequestSchema, PasswordResetRequestSchema, PasswordResetConfirmSchema, SetDataRequestSchema, } from './worker-types'; const isRequestSecure = (c) => new URL(c.req.url).protocol === 'https:'; const app = new Hono(); // --- CLEAN AUTH MIDDLEWARE --- app.use('/*', async (c, next) => { const url = new URL(c.req.url); const token = getCookie(c, 'token') || ''; const refreshToken = getCookie(c, 'refreshToken') || ''; console.log(`🔐 Auth check for ${url.pathname}:`, { hasToken: !!token, hasRefreshToken: !!refreshToken }); if (token || refreshToken) { try { // Helper to decode JWT payload safely const decodeJWT = (jwt) => { try { const parts = jwt.split('.'); return parts.length === 3 ? JSON.parse(atob(parts[1])) : null; } catch { return null; } }; const email = decodeJWT(token)?.email?.toLowerCase() || decodeJWT(refreshToken)?.email?.toLowerCase(); if (email) { const userDO = getUserDOFromContext(c, email); // Try access token first let result = await userDO.verifyToken({ token }); console.log(`🔑 Token verification for ${email}:`, { success: result.ok }); // If access token invalid, try refresh if (!result.ok && refreshToken) { try { console.log('🔄 Attempting token refresh...'); const { token: newToken } = await userDO.refreshToken({ refreshToken }); setCookie(c, 'token', newToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); result = await userDO.verifyToken({ token: newToken }); console.log('✅ Token refreshed successfully'); } catch (e) { console.log('❌ Token refresh failed:', e); // Clear invalid tokens deleteCookie(c, 'token'); deleteCookie(c, 'refreshToken'); } } // Set user if authenticated if (result.ok && result.user) { console.log(`👤 User set: ${result.user.email}`); c.set('user', result.user); } } } catch (e) { console.error('Auth error:', e); } } await next(); }); // --- JSON AUTH ENDPOINTS (for client) --- app.post('/api/signup', async (c) => { try { const body = await c.req.json(); const { email, password } = SignupRequestSchema.parse(body); const userDO = getUserDOFromContext(c, email.toLowerCase()); const { user, token, refreshToken } = await userDO.signup({ email: email.toLowerCase(), password }); setCookie(c, 'token', token, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); setCookie(c, 'refreshToken', refreshToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); const response = { user, token, refreshToken }; return c.json(response); } catch (e) { const errorResponse = { error: e.message || "Signup error" }; return c.json(errorResponse, 400); } }); app.post('/api/login', async (c) => { try { const body = await c.req.json(); const { email, password } = LoginRequestSchema.parse(body); const userDO = getUserDOFromContext(c, email.toLowerCase()); const { user, token, refreshToken } = await userDO.login({ email: email.toLowerCase(), password }); setCookie(c, 'token', token, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); setCookie(c, 'refreshToken', refreshToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); const response = { user, token, refreshToken }; return c.json(response); } catch (e) { const errorResponse = { error: e.message || "Login error" }; return c.json(errorResponse, 400); } }); app.post('/api/logout', async (c) => { try { const token = getCookie(c, 'token') || ''; const tokenParts = token.split('.'); if (tokenParts.length === 3 && tokenParts[1]) { const payload = JSON.parse(atob(tokenParts[1])); const email = payload.email?.toLowerCase(); if (email) { const userDO = getUserDOFromContext(c, email); await userDO.logout(); } } } catch (e) { console.error('Logout error', e); } deleteCookie(c, 'token'); deleteCookie(c, 'refreshToken'); const response = { ok: true }; return c.json(response); }); // Password reset endpoints app.post('/api/password-reset/request', async (c) => { try { const body = await c.req.json(); const { email } = PasswordResetRequestSchema.parse(body); const userDO = getUserDOFromContext(c, email.toLowerCase()); const { resetToken } = await userDO.generatePasswordResetToken(); // In production, you'd send this token via email // For demo purposes, we'll return it (don't do this in production!) return c.json({ ok: true, message: "Reset token generated", resetToken // Remove this in production! }); } catch (e) { const errorResponse = { error: e.message || "Reset request failed" }; return c.json(errorResponse, 400); } }); app.post('/api/password-reset/confirm', async (c) => { try { const body = await c.req.json(); const { resetToken, newPassword } = PasswordResetConfirmSchema.parse(body); // Extract email from token to get the right UserDO const tokenParts = resetToken.split('.'); if (tokenParts.length !== 3) throw new Error('Invalid token format'); const payload = JSON.parse(atob(tokenParts[1])); const email = payload.email?.toLowerCase(); if (!email) throw new Error('Invalid token'); const userDO = getUserDOFromContext(c, email); await userDO.resetPasswordWithToken({ resetToken, newPassword }); return c.json({ ok: true, message: "Password reset successful" }); } catch (e) { const errorResponse = { error: e.message || "Password reset failed" }; return c.json(errorResponse, 400); } }); app.get('/api/me', async (c) => { const user = c.get('user'); if (!user) { const errorResponse = { error: 'Not authenticated' }; return c.json(errorResponse, 401); } return c.json({ user }); }); // --- FORM AUTH ENDPOINTS (for server-side forms) --- app.post('/signup', async (c) => { const formData = await c.req.formData(); const email = formData.get('email')?.toLowerCase(); const password = formData.get('password'); if (!email || !password) { return c.json({ error: "Missing fields" }, 400); } const userDO = getUserDOFromContext(c, email); try { const { user, token, refreshToken } = await userDO.signup({ email, password }); setCookie(c, 'token', token, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); setCookie(c, 'refreshToken', refreshToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); return c.redirect('/'); } catch (e) { return c.json({ error: e.message || "Signup error" }, 400); } }); app.post('/login', async (c) => { const formData = await c.req.formData(); const email = formData.get('email')?.toLowerCase(); const password = formData.get('password'); if (!email || !password) { return c.json({ error: "Missing fields" }, 400); } const userDO = getUserDOFromContext(c, email); try { const { user, token, refreshToken } = await userDO.login({ email, password }); setCookie(c, 'token', token, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); setCookie(c, 'refreshToken', refreshToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); return c.redirect('/'); } catch (e) { return c.json({ error: e.message || "Login error" }, 400); } }); // logout app.post('/logout', async (c) => { try { const token = getCookie(c, 'token') || ''; const tokenParts = token.split('.'); if (tokenParts.length === 3 && tokenParts[1]) { const payload = JSON.parse(atob(tokenParts[1])); const email = payload.email?.toLowerCase(); if (email) { const userDO = getUserDOFromContext(c, email); await userDO.logout(); } } } catch (e) { console.error('Logout error', e); } deleteCookie(c, 'token'); deleteCookie(c, 'refreshToken'); return c.redirect('/'); }); // --- KV STORAGE ENDPOINTS --- app.get("/data", async (c) => { const user = c.get('user'); if (!user) { const errorResponse = { error: 'Unauthorized' }; return c.json(errorResponse, 401); } const userDO = getUserDOFromContext(c, user.email); const result = await userDO.get('data'); const response = { ok: true, data: result }; return c.json(response); }); app.post("/data", async (c) => { try { const user = c.get('user'); if (!user) { const errorResponse = { error: 'Unauthorized' }; return c.json(errorResponse, 401); } let key, value; // Support both JSON and form data const contentType = c.req.header('content-type') || ''; if (contentType.includes('application/json')) { const body = await c.req.json(); key = body.key; value = body.value; } else { const formData = await c.req.formData(); key = formData.get('key'); value = formData.get('value'); } // Validate the data const { key: validKey, value: validValue } = SetDataRequestSchema.parse({ key, value }); const userDO = getUserDOFromContext(c, user.email); const result = await userDO.set(validKey, validValue); if (!result.ok) { const errorResponse = { error: 'Failed to set data' }; return c.json(errorResponse, 400); } const response = { ok: true, data: { key: validKey, value: validValue } }; return c.json(response); } catch (e) { const errorResponse = { error: e.message || 'Invalid data format' }; return c.json(errorResponse, 400); } }); // Example protected endpoint app.get('/protected/profile', (c) => { const user = c.get('user'); if (!user) { const errorResponse = { error: 'Unauthorized' }; return c.json(errorResponse, 401); } return c.json({ ok: true, user }); }); // Export utilities for extending the worker export function getUserDOFromContext(c, email, bindingName = 'USERDO') { // Use the specified binding name, defaulting to USERDO for backwards compatibility const binding = c.env[bindingName]; if (!binding) { throw new Error(`Durable Object binding '${bindingName}' not found. Make sure it's configured in wrangler.jsonc`); } const userDOID = binding.idFromName(email); return binding.get(userDOID); } export function createUserDOWorker(bindingName = 'USERDO') { const app = new Hono(); // Helper function that uses the specified binding name const getUserDO = (c, email) => getUserDOFromContext(c, email, bindingName); // Auth middleware - same clean version as main worker app.use('*', async (c, next) => { const url = new URL(c.req.url); const token = getCookie(c, 'token') || ''; const refreshToken = getCookie(c, 'refreshToken') || ''; console.log(`🔐 [createUserDOWorker] Auth check for ${url.pathname}:`, { hasToken: !!token, hasRefreshToken: !!refreshToken }); if (token || refreshToken) { try { // Helper to decode JWT payload safely const decodeJWT = (jwt) => { try { const parts = jwt.split('.'); return parts.length === 3 ? JSON.parse(atob(parts[1])) : null; } catch { return null; } }; const email = decodeJWT(token)?.email?.toLowerCase() || decodeJWT(refreshToken)?.email?.toLowerCase(); if (email) { const userDO = getUserDO(c, email); // Try access token first let result = await userDO.verifyToken({ token }); console.log(`🔑 [createUserDOWorker] Token verification for ${email}:`, { success: result.ok }); // If access token invalid, try refresh if (!result.ok && refreshToken) { try { console.log('🔄 [createUserDOWorker] Attempting token refresh...'); const { token: newToken } = await userDO.refreshToken({ refreshToken }); setCookie(c, 'token', newToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); result = await userDO.verifyToken({ token: newToken }); console.log('✅ [createUserDOWorker] Token refreshed successfully'); } catch (e) { console.log('❌ [createUserDOWorker] Token refresh failed:', e); // Clear invalid tokens deleteCookie(c, 'token'); deleteCookie(c, 'refreshToken'); } } // Set user if authenticated if (result.ok && result.user) { console.log(`👤 [createUserDOWorker] User set: ${result.user.email}`); c.set('user', result.user); } } } catch (e) { console.error('[createUserDOWorker] Auth error:', e); } } await next(); }); // Copy all the routes from the main app but use the custom getUserDO function // API endpoints app.post('/api/signup', async (c) => { try { const body = await c.req.json(); const { email, password } = SignupRequestSchema.parse(body); const userDO = getUserDO(c, email.toLowerCase()); const { user, token, refreshToken } = await userDO.signup({ email: email.toLowerCase(), password }); setCookie(c, 'token', token, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); setCookie(c, 'refreshToken', refreshToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); const response = { user, token, refreshToken }; return c.json(response); } catch (e) { const errorResponse = { error: e.message || "Signup failed" }; return c.json(errorResponse, 400); } }); app.post('/api/login', async (c) => { try { const body = await c.req.json(); const { email, password } = LoginRequestSchema.parse(body); const userDO = getUserDO(c, email.toLowerCase()); const { user, token, refreshToken } = await userDO.login({ email: email.toLowerCase(), password }); setCookie(c, 'token', token, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); setCookie(c, 'refreshToken', refreshToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); const response = { user, token, refreshToken }; return c.json(response); } catch (e) { const errorResponse = { error: e.message || "Login failed" }; return c.json(errorResponse, 400); } }); app.post('/api/logout', async (c) => { try { const user = c.get('user'); if (user) { const userDO = getUserDO(c, user.email); await userDO.logout(); } } catch (e) { console.error('Logout error', e); } deleteCookie(c, 'token'); deleteCookie(c, 'refreshToken'); const response = { ok: true }; return c.json(response); }); app.post('/api/password-reset/request', async (c) => { try { const body = await c.req.json(); const { email } = PasswordResetRequestSchema.parse(body); const userDO = getUserDO(c, email.toLowerCase()); const { resetToken } = await userDO.generatePasswordResetToken(); return c.json({ ok: true, message: "Password reset token generated", resetToken }); } catch (e) { const errorResponse = { error: e.message || "Password reset request failed" }; return c.json(errorResponse, 400); } }); app.post('/api/password-reset/confirm', async (c) => { try { const body = await c.req.json(); const { resetToken, newPassword } = PasswordResetConfirmSchema.parse(body); const tokenParts = resetToken.split('.'); if (tokenParts.length !== 3) throw new Error('Invalid reset token'); const payload = JSON.parse(atob(tokenParts[1])); const email = payload.email?.toLowerCase(); if (!email) throw new Error('Invalid token'); const userDO = getUserDO(c, email); await userDO.resetPasswordWithToken({ resetToken, newPassword }); return c.json({ ok: true, message: "Password reset successful" }); } catch (e) { const errorResponse = { error: e.message || "Password reset failed" }; return c.json(errorResponse, 400); } }); app.get('/api/me', async (c) => { const user = c.get('user'); if (!user) { const errorResponse = { error: 'Not authenticated' }; return c.json(errorResponse, 401); } return c.json({ user }); }); // WebSocket endpoint removed - handled at worker level to avoid Hono serialization issues // Form endpoints app.post('/signup', async (c) => { const formData = await c.req.formData(); const email = formData.get('email')?.toLowerCase(); const password = formData.get('password'); if (!email || !password) { return c.json({ error: "Missing fields" }, 400); } const userDO = getUserDO(c, email); try { const { user, token, refreshToken } = await userDO.signup({ email, password }); setCookie(c, 'token', token, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); setCookie(c, 'refreshToken', refreshToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); return c.redirect('/'); } catch (e) { return c.json({ error: e.message || "Signup error" }, 400); } }); app.post('/login', async (c) => { const formData = await c.req.formData(); const email = formData.get('email')?.toLowerCase(); const password = formData.get('password'); if (!email || !password) { return c.json({ error: "Missing fields" }, 400); } const userDO = getUserDO(c, email); try { const { user, token, refreshToken } = await userDO.login({ email, password }); setCookie(c, 'token', token, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); setCookie(c, 'refreshToken', refreshToken, { httpOnly: true, secure: isRequestSecure(c), path: '/', sameSite: 'Lax' }); return c.redirect('/'); } catch (e) { return c.json({ error: e.message || "Login error" }, 400); } }); app.post('/logout', async (c) => { try { const token = getCookie(c, 'token') || ''; const tokenParts = token.split('.'); if (tokenParts.length === 3 && tokenParts[1]) { const payload = JSON.parse(atob(tokenParts[1])); const email = payload.email?.toLowerCase(); if (email) { const userDO = getUserDO(c, email); await userDO.logout(); } } } catch (e) { console.error('Logout error', e); } deleteCookie(c, 'token'); deleteCookie(c, 'refreshToken'); return c.redirect('/'); }); // Data endpoints app.get("/data", async (c) => { const user = c.get('user'); if (!user) { const errorResponse = { error: 'Unauthorized' }; return c.json(errorResponse, 401); } const userDO = getUserDO(c, user.email); const result = await userDO.get('data'); const response = { ok: true, data: result }; return c.json(response); }); app.post("/data", async (c) => { try { const user = c.get('user'); if (!user) { const errorResponse = { error: 'Unauthorized' }; return c.json(errorResponse, 401); } let key, value; // Support both JSON and form data const contentType = c.req.header('content-type') || ''; if (contentType.includes('application/json')) { const body = await c.req.json(); key = body.key; value = body.value; } else { const formData = await c.req.formData(); key = formData.get('key'); value = formData.get('value'); } const { key: validKey, value: validValue } = SetDataRequestSchema.parse({ key, value }); const userDO = getUserDO(c, user.email); const result = await userDO.set(validKey, validValue); if (!result.ok) { const errorResponse = { error: 'Failed to set data' }; return c.json(errorResponse, 400); } // Broadcast WebSocket notification for data changes console.log(`🔥 Data changed for ${user.email}: ${validKey} = ${JSON.stringify(validValue)}`); broadcastToUser(user.email, { event: `kv:${validKey}`, data: { key: validKey, value: validValue }, timestamp: Date.now() }, bindingName, c.env); const response = { ok: true, data: { key: validKey, value: validValue } }; return c.json(response); } catch (e) { const errorResponse = { error: e.message || 'Invalid data format' }; return c.json(errorResponse, 400); } }); app.get('/protected/profile', (c) => { const user = c.get('user'); if (!user) { const errorResponse = { error: 'Unauthorized' }; return c.json(errorResponse, 401); } return c.json({ ok: true, user }); }); // Note: Generic collection endpoints removed - apps should implement specific routes // like /api/posts, /api/comments, etc. for proper database integration app.get('/api/docs', async (c) => { return c.json({ name: 'UserDO', version: '0.1.37', status: 'ready', endpoints: { auth: ['/api/signup', '/api/login', '/api/logout', '/api/me'], data: ['/data'], collections: ['/api/:collection'], passwordReset: ['/api/password-reset/request', '/api/password-reset/confirm'] }, docs: 'https://github.com/acoyfellow/userdo' }); }); return app; } // Helper to broadcast to user's WebSocket connections via UserDO export function broadcastToUser(email, message, bindingName = 'USERDO', env) { // Get the UserDO and call its broadcast method const binding = env[bindingName]; if (!binding) { console.error(`Durable Object binding '${bindingName}' not found`); return; } const userDOID = binding.idFromName(email); const userDO = binding.get(userDOID); // Call the UserDO's broadcast method (this will be async but we don't await it) userDO.broadcast(message.event, message.data).catch((error) => { console.error('Failed to broadcast to UserDO:', error); }); } // Separate WebSocket handler factory export function createWebSocketHandler(bindingName = 'USERDO') { return { async fetch(request, env, ctx) { const url = new URL(request.url); // Only handle WebSocket upgrades if (url.pathname === '/api/ws' && request.headers.get('upgrade') === 'websocket') { console.log('🔌 WebSocket upgrade request received'); // Parse cookies for authentication const cookieHeader = request.headers.get('cookie') || ''; const cookies = Object.fromEntries(cookieHeader.split(';') .filter(c => c.includes('=')) .map(c => c.trim().split('='))); const token = cookies.token || ''; if (!token) { console.log('❌ No auth token for WebSocket'); return new Response('Unauthorized', { status: 401 }); } // Decode JWT to get email try { const parts = token.split('.'); if (parts.length !== 3) throw new Error('Invalid token format'); const payload = JSON.parse(atob(parts[1])); const email = payload.email?.toLowerCase(); if (!email) throw new Error('No email in token'); console.log(`🔌 WebSocket auth successful for: ${email}`); // Delegate WebSocket handling to the UserDO using hibernation API const binding = env[bindingName]; if (!binding) { throw new Error(`Durable Object binding '${bindingName}' not found`); } const userDOID = binding.idFromName(email); const userDO = binding.get(userDOID); // Forward the WebSocket upgrade request to the UserDO return userDO.fetch(request); } catch (error) { console.log('❌ WebSocket auth failed:', error); return new Response('Unauthorized', { status: 401 }); } } // Not a WebSocket request return new Response('Not Found', { status: 404 }); } }; } // Export the UserDO class for Wrangler and the complete worker with all auth routes export { UserDO }; export { app as userDOWorker }; export default app;