diff --git a/.env.example b/.env.example index 31bfb18..d8b4476 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,17 @@ PORT=9991 NODE_ENV=development +# App URL (your frontend URL) +APP_URL=https://moxiegen.client.guacamolebox.net + # CORS -CORS_ORIGIN=* +CORS_ORIGIN=https://moxiegen.client.guacamolebox.net + +# Auth0 Configuration +AUTH0_DOMAIN=dev-t13zhs74oltgqtfxf.auth0.com +AUTH0_CLIENT_ID=your-client-id-here +AUTH0_CLIENT_SECRET=your-client-secret-here +AUTH0_AUDIENCE=https://dev-t13zhs74oltgqtfxf.auth0.com/api/v2/ # Stripe Configuration (for future use) STRIPE_SECRET_KEY=sk_test_xxx @@ -16,9 +25,8 @@ PAYPAL_CLIENT_SECRET=xxx PAYPAL_WEBHOOK_ID=xxx PAYPAL_MODE=sandbox -# JWT Secret (optional, for enhanced token security) +# JWT Secret (optional, for additional security) JWT_SECRET=your-super-secret-key-change-in-production -# Admin User (created on first run if set) +# First Admin User (will be promoted to admin on first login if email matches) ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=changeme diff --git a/README.md b/README.md index 58a33ee..62b9216 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Moxie Backend -Express.js backend API for user management of an AI site, built with ESM syntax and SQLite database. +Express.js backend API for user management of an AI site, built with ESM syntax, Auth0 authentication, and SQLite database. ## Features -- **User Management**: Registration, authentication, profile management +- **Auth0 Authentication**: Secure authentication via Auth0 +- **User Management**: Auto-creation of users on first login, profile management - **Credit System**: Track and manage user credits - **API Keys**: Generate and manage API keys for programmatic access - **Payment Webhooks**: Ready for Stripe and PayPal integration @@ -17,6 +18,10 @@ Express.js backend API for user management of an AI site, built with ESM syntax # Install dependencies npm install +# Copy environment file and configure +cp .env.example .env +# Edit .env with your Auth0 credentials + # Start the server npm start @@ -26,36 +31,61 @@ npm run dev The server runs on port 9991 by default. +## Auth0 Setup + +### 1. Create Auth0 Application + +1. Go to Auth0 Dashboard > Applications > Applications +2. Create a new "Single Page Application" or "Regular Web Application" +3. Configure the following URLs: + - **Allowed Callback URLs**: `https://moxiegen.client.guacamolebox.net/api/callback` + - **Allowed Logout URLs**: `https://moxiegen.client.guacamolebox.net` + - **Allowed Web Origins**: `https://moxiegen.client.guacamolebox.net` + - **Application Login URI**: `https://moxiegen.client.guacamolebox.net/login` + +### 2. Create Auth0 API (Optional) + +For machine-to-machine authentication: +1. Go to Auth0 Dashboard > Applications > APIs +2. Create a new API +3. Set the identifier as your audience +4. Enable RBAC if needed + +### 3. Configure Environment Variables + +```env +AUTH0_DOMAIN=your-tenant.auth0.com +AUTH0_CLIENT_ID=your-client-id +AUTH0_CLIENT_SECRET=your-client-secret +AUTH0_AUDIENCE=https://your-tenant.auth0.com/api/v2/ +APP_URL=https://moxiegen.client.guacamolebox.net +``` + ## API Endpoints -### Public Endpoints +### Auth Endpoints | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/api/health` | Health check | -| GET | `/api` | API information | -| POST | `/api/users/register` | Register a new user | -| POST | `/api/users/login` | Login and get session token | +| GET | `/api/login` | Redirect to Auth0 login | +| GET | `/api/callback` | Handle Auth0 callback | +| GET | `/api/logout` | Logout and redirect | -### Authenticated Endpoints +### User Endpoints (Requires Auth0 Token) -All authenticated endpoints require `Authorization: Bearer ` header. +All authenticated endpoints require `Authorization: Bearer ` header. | Method | Endpoint | Description | |--------|----------|-------------| -| POST | `/api/users/logout` | Logout and invalidate session | | GET | `/api/users/me` | Get current user profile | | PUT | `/api/users/me` | Update profile | -| PUT | `/api/users/me/password` | Change password | -| DELETE | `/api/users/me` | Delete account | +| DELETE | `/api/users/me` | Deactivate account | | GET | `/api/users/credits` | Get credits and history | | GET | `/api/users/api-keys` | List API keys | | POST | `/api/users/api-keys` | Create new API key | | DELETE | `/api/users/api-keys/:keyId` | Revoke API key | -### Admin Endpoints - -Requires `role: 'admin'` in user record. +### Admin Endpoints (Requires `role: 'admin'`) | Method | Endpoint | Description | |--------|----------|-------------| @@ -64,6 +94,7 @@ Requires `role: 'admin'` in user record. | PUT | `/api/users/:userId` | Update user | | DELETE | `/api/users/:userId` | Delete user | | POST | `/api/users/:userId/credits` | Adjust user credits | +| PUT | `/api/users/:userId/role` | Change user role | ### Webhook Endpoints @@ -72,13 +103,63 @@ Requires `role: 'admin'` in user record. | POST | `/api/webhooks/stripe` | Stripe webhook handler | | POST | `/api/webhooks/paypal` | PayPal webhook handler | +## Frontend Integration + +### Login Flow + +```javascript +// Redirect to Auth0 login +window.location.href = '/api/login'; + +// Handle callback (tokens in URL params) +const urlParams = new URLSearchParams(window.location.search); +const accessToken = urlParams.get('access_token'); +const idToken = urlParams.get('id_token'); + +// Store token and use for API calls +localStorage.setItem('access_token', accessToken); + +// Make authenticated requests +fetch('/api/users/me', { + headers: { + 'Authorization': `Bearer ${accessToken}` + } +}); +``` + +### Using Auth0 SPA SDK + +```javascript +import { Auth0Client } from '@auth0/auth0-spa-js'; + +const auth0 = new Auth0Client({ + domain: 'your-tenant.auth0.com', + client_id: 'your-client-id', + redirect_uri: window.location.origin +}); + +// Login +await auth0.loginWithRedirect(); + +// Get token +const token = await auth0.getTokenSilently(); + +// Use token +fetch('/api/users/me', { + headers: { + 'Authorization': `Bearer ${token}` + } +}); +``` + ## Database Schema ### Users Table - `id` - Primary key (UUID) -- `email` - Unique email address -- `password_hash` - Bcrypt hashed password +- `auth0_id` - Auth0 user ID (sub claim) +- `email` - Email address - `name` - Display name +- `picture` - Profile picture URL - `role` - User role ('user' or 'admin') - `credits` - Available credits - `subscription_status` - Subscription state @@ -87,12 +168,6 @@ Requires `role: 'admin'` in user record. - `paypal_customer_id` - PayPal customer reference - `is_active` - Account status flag -### Sessions Table -- `id` - Session ID -- `user_id` - Foreign key to users -- `token_hash` - Session token -- `expires_at` - Token expiration - ### API Keys Table - `id` - Key ID - `user_id` - Foreign key to users @@ -116,10 +191,8 @@ Requires `role: 'admin'` in user record. ## Caddy Configuration -Add this to your Caddyfile to proxy the API: - ```caddyfile -yourdomain.com { +moxiegen.client.guacamolebox.net { # Static site root * /path/to/static/site file_server @@ -131,24 +204,16 @@ yourdomain.com { } ``` -## Environment Variables +## Making a User Admin -Create a `.env` file based on `.env.example`: +To promote a user to admin role: -```env -PORT=9991 -NODE_ENV=production -CORS_ORIGIN=https://yourdomain.com +1. Find the user ID from the database +2. Use the admin API (requires an existing admin) +3. Or directly update the database: -# Stripe (when ready) -STRIPE_SECRET_KEY=sk_live_xxx -STRIPE_WEBHOOK_SECRET=whsec_xxx - -# PayPal (when ready) -PAYPAL_CLIENT_ID=xxx -PAYPAL_CLIENT_SECRET=xxx -PAYPAL_WEBHOOK_ID=xxx -PAYPAL_MODE=live +```sql +UPDATE users SET role = 'admin' WHERE email = 'admin@example.com'; ``` ## Payment Integration @@ -157,7 +222,7 @@ PAYPAL_MODE=live 1. Create a Stripe account and get API keys 2. Add keys to environment variables -3. Create a webhook endpoint in Stripe dashboard pointing to `https://yourdomain.com/api/webhooks/stripe` +3. Create a webhook endpoint in Stripe dashboard pointing to `https://moxiegen.client.guacamolebox.net/api/webhooks/stripe` 4. Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET` ### PayPal Setup @@ -165,7 +230,7 @@ PAYPAL_MODE=live 1. Create a PayPal Developer account 2. Create a REST API application 3. Add credentials to environment variables -4. Configure webhook in PayPal dashboard pointing to `https://yourdomain.com/api/webhooks/paypal` +4. Configure webhook in PayPal dashboard pointing to `https://moxiegen.client.guacamolebox.net/api/webhooks/paypal` ## License diff --git a/package-lock.json b/package-lock.json index 20a958c..fd991c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,11 @@ "dependencies": { "bcryptjs": "^3.0.3", "cors": "^2.8.6", + "dotenv": "^17.3.1", "express": "^5.2.1", + "express-oauth2-jwt-bearer": "^1.7.4", "helmet": "^8.1.0", + "jose": "^6.2.2", "sqlite3": "^6.0.1", "uuid": "^13.0.0" } @@ -417,6 +420,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -569,6 +584,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-oauth2-jwt-bearer": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/express-oauth2-jwt-bearer/-/express-oauth2-jwt-bearer-1.7.4.tgz", + "integrity": "sha512-teO/eyvU8OkJXiP4cRuoJMrp31nNvjnL47MIkso0D/21AqUGv1O+VEiLisrDA8xjkaCBTufYnV1zepCOCLK4vg==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.5" + }, + "engines": { + "node": "^12.19.0 || ^14.15.0 || ^16.13.0 || ^18.12.0 || ^20.2.0 || ^22.1.0 || ^24.0.0" + } + }, + "node_modules/express-oauth2-jwt-bearer/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -911,6 +947,15 @@ "node": ">=20" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/lru-cache": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", @@ -1348,20 +1393,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", diff --git a/package.json b/package.json index 79cdbef..1370bf3 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,11 @@ "dependencies": { "bcryptjs": "^3.0.3", "cors": "^2.8.6", + "dotenv": "^17.3.1", "express": "^5.2.1", + "express-oauth2-jwt-bearer": "^1.7.4", "helmet": "^8.1.0", + "jose": "^6.2.2", "sqlite3": "^6.0.1", "uuid": "^13.0.0" } diff --git a/src/db/database.js b/src/db/database.js index 5b70120..68a0d48 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -48,13 +48,15 @@ const dbAll = (sql, params = []) => { // Initialize database tables const initDatabase = async () => { try { - // Users table + // Users table (updated for Auth0) await dbRun(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, + auth0_id TEXT UNIQUE, + email TEXT, + password_hash TEXT, name TEXT, + picture TEXT, role TEXT DEFAULT 'user', credits INTEGER DEFAULT 0, subscription_status TEXT DEFAULT 'inactive', @@ -113,7 +115,7 @@ const initDatabase = async () => { ) `); - // Sessions table for user sessions + // Sessions table (kept for API key sessions) await dbRun(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -127,6 +129,7 @@ const initDatabase = async () => { // Create indexes for better query performance await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`); + await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_auth0_id ON users(auth0_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_paypal_customer ON users(paypal_customer_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)`); @@ -134,10 +137,37 @@ const initDatabase = async () => { await dbRun(`CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`); + // Run migrations for existing databases + await runMigrations(); + console.log('Database tables initialized successfully'); } catch (err) { console.error('Error initializing database:', err.message); } }; -export { db, dbRun, dbGet, dbAll, initDatabase }; +// Run migrations for schema updates +const runMigrations = async () => { + try { + // Add auth0_id column if it doesn't exist + const tableInfo = await dbAll('PRAGMA table_info(users)'); + const columns = tableInfo.map(col => col.name); + + if (!columns.includes('auth0_id')) { + await dbRun('ALTER TABLE users ADD COLUMN auth0_id TEXT UNIQUE'); + console.log('Migration: Added auth0_id column'); + } + + if (!columns.includes('picture')) { + await dbRun('ALTER TABLE users ADD COLUMN picture TEXT'); + console.log('Migration: Added picture column'); + } + + // Make email nullable by recreating the table (SQLite limitation) + // This is safe for new databases but preserve existing data + } catch (err) { + // Columns might already exist, that's fine + } +}; + +export { db, dbRun, dbGet, dbAll, initDatabase, runMigrations }; diff --git a/src/index.js b/src/index.js index d46a90b..0781184 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; @@ -7,6 +8,7 @@ import { dirname, join } from 'path'; import usersRouter from './routes/users.js'; import webhooksRouter from './routes/webhooks.js'; import { ApiResponse } from './utils/helpers.js'; +import { validateAccessToken, ensureUserExists } from './middleware/auth.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -14,6 +16,10 @@ const __dirname = dirname(__filename); const app = express(); const PORT = process.env.PORT || 9991; +// Auth0 configuration +const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'dev-t13zhs74oltgqtfxf.auth0.com'; +const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID; + // ============================================ // Middleware // ============================================ @@ -55,7 +61,11 @@ app.get('/api/health', (req, res) => { res.json(ApiResponse(true, { status: 'healthy', timestamp: new Date().toISOString(), - uptime: process.uptime() + uptime: process.uptime(), + auth: { + domain: AUTH0_DOMAIN, + clientId: AUTH0_CLIENT_ID ? 'configured' : 'not configured' + } })); }); @@ -63,37 +73,122 @@ app.get('/api/health', (req, res) => { app.get('/api', (req, res) => { res.json(ApiResponse(true, { name: 'Moxie Backend API', - version: '1.0.0', + version: '2.0.0', + auth: 'Auth0', endpoints: { - users: { - 'POST /api/users/register': 'Register a new user', - 'POST /api/users/login': 'Login user', - 'POST /api/users/logout': 'Logout user (requires auth)', - 'GET /api/users/me': 'Get current user profile (requires auth)', - 'PUT /api/users/me': 'Update current user profile (requires auth)', - 'PUT /api/users/me/password': 'Change password (requires auth)', - 'DELETE /api/users/me': 'Delete account (requires auth)', - 'GET /api/users/credits': 'Get credits and history (requires auth)', - 'GET /api/users/api-keys': 'Get API keys (requires auth)', - 'POST /api/users/api-keys': 'Create API key (requires auth)', - 'DELETE /api/users/api-keys/:keyId': 'Revoke API key (requires auth)' + auth: { + 'GET /api/login': 'Auth0 login callback (redirects to Auth0)', + 'GET /api/callback': 'Auth0 callback handler', + 'GET /api/logout': 'Logout and redirect to Auth0 logout' + }, + user: { + 'GET /api/users/me': 'Get current user profile (requires Auth0 token)', + 'PUT /api/users/me': 'Update current user profile (requires Auth0 token)', + 'DELETE /api/users/me': 'Deactivate account (requires Auth0 token)', + 'GET /api/users/credits': 'Get credits and history (requires Auth0 token)', + 'GET /api/users/api-keys': 'Get API keys (requires Auth0 token)', + 'POST /api/users/api-keys': 'Create API key (requires Auth0 token)', + 'DELETE /api/users/api-keys/:keyId': 'Revoke API key (requires Auth0 token)' }, admin: { 'GET /api/users': 'List all users (admin only)', 'GET /api/users/:userId': 'Get user by ID (admin only)', 'PUT /api/users/:userId': 'Update user (admin only)', 'DELETE /api/users/:userId': 'Delete user (admin only)', - 'POST /api/users/:userId/credits': 'Adjust user credits (admin only)' + 'POST /api/users/:userId/credits': 'Adjust user credits (admin only)', + 'PUT /api/users/:userId/role': 'Change user role (admin only)' }, webhooks: { 'POST /api/webhooks/stripe': 'Stripe webhook endpoint', 'POST /api/webhooks/paypal': 'PayPal webhook endpoint', - 'POST /api/webhooks/create-payment': 'Create payment record (requires auth)' + 'POST /api/webhooks/create-payment': 'Create payment record (requires Auth0 token)' } } })); }); +// ============================================ +// Auth0 Routes +// ============================================ + +/** + * @route GET /api/login + * @desc Initiate Auth0 login + * @access Public + */ +app.get('/api/login', (req, res) => { + const redirectUri = encodeURIComponent(`${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}/api/callback`); + const authUrl = `https://${AUTH0_DOMAIN}/authorize?response_type=code&client_id=${AUTH0_CLIENT_ID}&redirect_uri=${redirectUri}&scope=openid%20profile%20email`; + res.redirect(authUrl); +}); + +/** + * @route GET /api/callback + * @desc Handle Auth0 callback (exchange code for tokens) + * @access Public + * + * Note: This is a basic implementation. In production, you'd want to: + * 1. Exchange the code for tokens server-side + * 2. Set secure HTTP-only cookies + * 3. Or redirect back to frontend with tokens + */ +app.get('/api/callback', async (req, res) => { + const { code, error, error_description } = req.query; + + if (error) { + console.error('Auth0 error:', error, error_description); + return res.redirect(`${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}?error=${encodeURIComponent(error_description || error)}`); + } + + if (!code) { + return res.redirect(`${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}?error=no_code`); + } + + try { + // Exchange code for tokens + const tokenResponse = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: AUTH0_CLIENT_ID, + client_secret: process.env.AUTH0_CLIENT_SECRET, + code, + redirect_uri: `${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}/api/callback` + }) + }); + + const tokens = await tokenResponse.json(); + + if (tokens.error) { + throw new Error(tokens.error_description || tokens.error); + } + + // Redirect to frontend with tokens + // In production, consider using HTTP-only cookies instead + const frontendUrl = process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'; + res.redirect(`${frontendUrl}/?access_token=${tokens.access_token}&id_token=${tokens.id_token}&expires_in=${tokens.expires_in}`); + } catch (err) { + console.error('Token exchange error:', err); + res.redirect(`${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}?error=${encodeURIComponent(err.message)}`); + } +}); + +/** + * @route GET /api/logout + * @desc Logout and redirect to Auth0 logout + * @access Public + */ +app.get('/api/logout', (req, res) => { + const returnTo = encodeURIComponent(process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'); + const logoutUrl = `https://${AUTH0_DOMAIN}/v2/logout?client_id=${AUTH0_CLIENT_ID}&returnTo=${returnTo}`; + res.redirect(logoutUrl); +}); + +// ============================================ +// API Routes +// ============================================ + // Mount routers app.use('/api/users', usersRouter); app.use('/api/webhooks', webhooksRouter); @@ -111,11 +206,12 @@ app.use((req, res) => { app.use((err, req, res, next) => { console.error('Error:', err); - // Handle specific error types + // Auth0 JWT errors if (err.name === 'UnauthorizedError') { - return res.status(401).json(ApiResponse(false, null, 'Invalid token')); + return res.status(401).json(ApiResponse(false, null, err.message || 'Invalid token')); } + // Handle specific error types if (err.name === 'ValidationError') { return res.status(400).json(ApiResponse(false, null, err.message)); } @@ -148,6 +244,7 @@ const server = app.listen(PORT, () => { ╠════════════════════════════════════════════╣ ║ Port: ${PORT} ║ ║ Mode: ${process.env.NODE_ENV || 'development'} ║ +║ Auth0 Domain: ${AUTH0_DOMAIN} ║ ║ Time: ${new Date().toISOString()} ║ ╚════════════════════════════════════════════╝ `); diff --git a/src/middleware/auth.js b/src/middleware/auth.js index b0d6147..33a7175 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,11 +1,19 @@ +import { auth } from 'express-oauth2-jwt-bearer'; import bcrypt from 'bcryptjs'; -import { dbGet } from '../db/database.js'; +import { dbGet, dbRun } from '../db/database.js'; import { ApiResponse } from '../utils/helpers.js'; +import { generateId } from '../utils/helpers.js'; const SALT_ROUNDS = 12; +// Auth0 configuration from environment +const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'dev-t13zhs74oltgqtfxf.auth0.com'; +const AUTH0_AUDIENCE = process.env.AUTH0_AUDIENCE || `https://${AUTH0_DOMAIN}/api/v2/`; +const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID; +const AUTH0_CLIENT_SECRET = process.env.AUTH0_CLIENT_SECRET; + /** - * Hash a password + * Hash a password (for API keys and local use) * @param {string} password - Plain text password * @returns {Promise} Hashed password */ @@ -24,103 +32,125 @@ export const comparePassword = async (password, hash) => { }; /** - * Authentication middleware using Bearer token - * Expects Authorization: Bearer header + * Auth0 JWT validation middleware + * Validates the access token from Auth0 */ -export const authenticateToken = async (req, res, next) => { +export const validateAccessToken = auth({ + issuerBaseURL: `https://${AUTH0_DOMAIN}`, + audience: AUTH0_AUDIENCE, +}); + +/** + * Check if user exists in our database, create if not (first-time Auth0 login) + * Attaches user record to req.user + */ +export const ensureUserExists = async (req, res, next) => { try { - const authHeader = req.headers.authorization; + // Auth0 payload from JWT + const auth0User = req.auth?.payload; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json(ApiResponse(false, null, 'Authorization token required')); + if (!auth0User) { + return res.status(401).json(ApiResponse(false, null, 'No auth payload found')); } - const token = authHeader.split(' ')[1]; - - if (!token) { - return res.status(401).json(ApiResponse(false, null, 'Invalid token format')); + // Extract Auth0 user ID (sub claim) + const auth0Id = auth0User.sub; + const email = auth0User.email || auth0User['https://moxiegen.client.guacamolebox.net/email']; + const name = auth0User.name || auth0User.nickname || auth0User['https://moxiegen.client.guacamolebox.net/name']; + const picture = auth0User.picture || auth0User['https://moxiegen.client.guacamolebox.net/picture']; + + // Check if user exists by auth0_id + let user = await dbGet('SELECT * FROM users WHERE auth0_id = ?', [auth0Id]); + + if (!user && email) { + // Check if user exists by email (for backwards compatibility or email matching) + user = await dbGet('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); + + if (user) { + // Link Auth0 ID to existing user + await dbRun( + 'UPDATE users SET auth0_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [auth0Id, user.id] + ); + user.auth0_id = auth0Id; + } } - // For now, we'll use a simple token validation - // In production, you'd validate JWT or check against sessions table - const session = await dbGet( - `SELECT s.*, u.id as user_id, u.email, u.name, u.role, u.credits, - u.subscription_status, u.subscription_tier, u.is_active - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.token_hash = ? AND s.expires_at > datetime('now')`, - [token] - ); + if (!user) { + // Create new user from Auth0 profile + const userId = generateId(); + + await dbRun( + `INSERT INTO users (id, email, name, auth0_id, picture, role, credits, subscription_status) + VALUES (?, ?, ?, ?, ?, 'user', 0, 'inactive')`, + [userId, email?.toLowerCase() || null, name || null, auth0Id, picture || null] + ); - if (!session) { - return res.status(401).json(ApiResponse(false, null, 'Invalid or expired token')); + user = await dbGet('SELECT * FROM users WHERE id = ?', [userId]); + console.log(`Created new user from Auth0: ${userId} (${email})`); } - if (!session.is_active) { - return res.status(403).json(ApiResponse(false, null, 'Account is disabled')); - } + // Update last login + await dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]); - // Attach user to request + // Attach full user record to request req.user = { - id: session.user_id, - email: session.email, - name: session.name, - role: session.role, - credits: session.credits, - subscription_status: session.subscription_status, - subscription_tier: session.subscription_tier + id: user.id, + auth0_id: user.auth0_id, + email: user.email, + name: user.name, + picture: user.picture, + role: user.role, + credits: user.credits, + subscription_status: user.subscription_status, + subscription_tier: user.subscription_tier, + is_active: user.is_active }; - - req.sessionId = session.id; + + // Also keep Auth0 payload + req.auth0 = auth0User; + next(); } catch (error) { - console.error('Auth middleware error:', error); - return res.status(500).json(ApiResponse(false, null, 'Authentication error')); + console.error('Error ensuring user exists:', error); + return res.status(500).json(ApiResponse(false, null, 'Error processing user')); } }; +/** + * Combined Auth0 authentication middleware + * Validates JWT and ensures user exists in database + */ +export const authenticateToken = [validateAccessToken, ensureUserExists]; + /** * Optional authentication - doesn't fail if no token + * Useful for endpoints that work with or without auth */ export const optionalAuth = async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - req.user = null; - return next(); - } + const authHeader = req.headers.authorization; - const token = authHeader.split(' ')[1]; - - const session = await dbGet( - `SELECT s.*, u.id as user_id, u.email, u.name, u.role, u.credits, - u.subscription_status, u.subscription_tier, u.is_active - FROM sessions s - JOIN users u ON s.user_id = u.id - WHERE s.token_hash = ? AND s.expires_at > datetime('now')`, - [token] - ); - - if (session && session.is_active) { - req.user = { - id: session.user_id, - email: session.email, - name: session.name, - role: session.role, - credits: session.credits, - subscription_status: session.subscription_status, - subscription_tier: session.subscription_tier - }; - req.sessionId = session.id; - } else { - req.user = null; - } - - next(); - } catch (error) { - console.error('Optional auth error:', error); + if (!authHeader || !authHeader.startsWith('Bearer ')) { req.user = null; + req.auth0 = null; + return next(); + } + + try { + // Try to validate the token + await new Promise((resolve, reject) => { + validateAccessToken(req, res, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // If valid, ensure user exists + await ensureUserExists(req, res, next); + } catch (error) { + // Token invalid or missing - continue without user + req.user = null; + req.auth0 = null; next(); } }; @@ -135,6 +165,19 @@ export const requireAdmin = (req, res, next) => { next(); }; +/** + * Active user check middleware + */ +export const requireActiveUser = (req, res, next) => { + if (!req.user) { + return res.status(401).json(ApiResponse(false, null, 'Authentication required')); + } + if (!req.user.is_active) { + return res.status(403).json(ApiResponse(false, null, 'Account is disabled')); + } + next(); +}; + /** * Rate limiting middleware (simple in-memory implementation) * In production, use Redis or similar @@ -146,20 +189,20 @@ export const rateLimit = (maxRequests = 100, windowMs = 60000) => { const ip = req.ip || req.connection.remoteAddress; const now = Date.now(); const windowStart = now - windowMs; - + // Get or create request log for IP let requests = rateLimitStore.get(ip) || []; - + // Filter out old requests requests = requests.filter(time => time > windowStart); - + if (requests.length >= maxRequests) { return res.status(429).json(ApiResponse(false, null, 'Too many requests, please try again later')); } - + requests.push(now); rateLimitStore.set(ip, requests); - + next(); }; }; @@ -176,3 +219,18 @@ setInterval(() => { } } }, 60000); + +/** + * Check if user has required scope/permission + * @param {string} scope - Required scope + */ +export const requireScope = (scope) => { + return (req, res, next) => { + const scopes = req.auth?.payload?.scope?.split(' ') || []; + + if (!scopes.includes(scope)) { + return res.status(403).json(ApiResponse(false, null, `Scope '${scope}' required`)); + } + next(); + }; +}; diff --git a/src/routes/users.js b/src/routes/users.js index 706fc66..723b003 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -1,115 +1,18 @@ import express from 'express'; import { dbRun, dbGet, dbAll } from '../db/database.js'; -import { hashPassword, comparePassword, authenticateToken, optionalAuth, requireAdmin } from '../middleware/auth.js'; -import { generateId, isValidEmail, sanitizeUser, getPagination, ApiResponse, asyncHandler } from '../utils/helpers.js'; +import { hashPassword, authenticateToken, optionalAuth, requireAdmin, requireActiveUser } from '../middleware/auth.js'; +import { generateId, sanitizeUser, getPagination, ApiResponse, asyncHandler } from '../utils/helpers.js'; const router = express.Router(); -/** - * @route POST /api/users/register - * @desc Register a new user - * @access Public - */ -router.post('/register', asyncHandler(async (req, res) => { - const { email, password, name } = req.body; - - // Validation - if (!email || !password) { - return res.status(400).json(ApiResponse(false, null, 'Email and password are required')); - } - - if (!isValidEmail(email)) { - return res.status(400).json(ApiResponse(false, null, 'Invalid email format')); - } - - if (password.length < 8) { - return res.status(400).json(ApiResponse(false, null, 'Password must be at least 8 characters')); - } - - // Check if user exists - const existingUser = await dbGet('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]); - if (existingUser) { - return res.status(409).json(ApiResponse(false, null, 'Email already registered')); - } - - // Create user - const userId = generateId(); - const passwordHash = await hashPassword(password); - - await dbRun( - `INSERT INTO users (id, email, password_hash, name, role, credits) - VALUES (?, ?, ?, ?, 'user', 0)`, - [userId, email.toLowerCase(), passwordHash, name || null] - ); - - const user = await dbGet('SELECT * FROM users WHERE id = ?', [userId]); - - res.status(201).json(ApiResponse(true, { user: sanitizeUser(user) }, 'User registered successfully')); -})); - -/** - * @route POST /api/users/login - * @desc Login user and return session token - * @access Public - */ -router.post('/login', asyncHandler(async (req, res) => { - const { email, password } = req.body; - - if (!email || !password) { - return res.status(400).json(ApiResponse(false, null, 'Email and password are required')); - } - - // Find user - const user = await dbGet('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); - if (!user) { - return res.status(401).json(ApiResponse(false, null, 'Invalid credentials')); - } - - if (!user.is_active) { - return res.status(403).json(ApiResponse(false, null, 'Account is disabled')); - } - - // Verify password - const isValid = await comparePassword(password, user.password_hash); - if (!isValid) { - return res.status(401).json(ApiResponse(false, null, 'Invalid credentials')); - } - - // Create session token - const sessionId = generateId(); - const token = sessionId; // In production, use JWT or a more secure token - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days - - await dbRun( - `INSERT INTO sessions (id, user_id, token_hash, expires_at) - VALUES (?, ?, ?, ?)`, - [sessionId, user.id, token, expiresAt] - ); - - // Update last login - await dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]); - - res.json(ApiResponse(true, { - user: sanitizeUser(user), - token, - expiresAt - }, 'Login successful')); -})); - -/** - * @route POST /api/users/logout - * @desc Logout user (invalidate session) - * @access Private - */ -router.post('/logout', authenticateToken, asyncHandler(async (req, res) => { - await dbRun('DELETE FROM sessions WHERE id = ?', [req.sessionId]); - res.json(ApiResponse(true, null, 'Logged out successfully')); -})); +// ============================================ +// Public Routes +// ============================================ /** * @route GET /api/users/me - * @desc Get current user profile - * @access Private + * @desc Get current user profile (from Auth0 JWT) + * @access Private (requires Auth0 token) */ router.get('/me', authenticateToken, asyncHandler(async (req, res) => { const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.user.id]); @@ -122,10 +25,10 @@ router.get('/me', authenticateToken, asyncHandler(async (req, res) => { /** * @route PUT /api/users/me * @desc Update current user profile - * @access Private + * @access Private (requires Auth0 token) */ router.put('/me', authenticateToken, asyncHandler(async (req, res) => { - const { name, email } = req.body; + const { name, picture } = req.body; const updates = []; const values = []; @@ -134,16 +37,9 @@ router.put('/me', authenticateToken, asyncHandler(async (req, res) => { values.push(name); } - if (email !== undefined) { - if (!isValidEmail(email)) { - return res.status(400).json(ApiResponse(false, null, 'Invalid email format')); - } - const existing = await dbGet('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]); - if (existing) { - return res.status(409).json(ApiResponse(false, null, 'Email already in use')); - } - updates.push('email = ?'); - values.push(email.toLowerCase()); + if (picture !== undefined) { + updates.push('picture = ?'); + values.push(picture); } if (updates.length === 0) { @@ -159,73 +55,38 @@ router.put('/me', authenticateToken, asyncHandler(async (req, res) => { res.json(ApiResponse(true, { user: sanitizeUser(user) }, 'Profile updated successfully')); })); -/** - * @route PUT /api/users/me/password - * @desc Change user password - * @access Private - */ -router.put('/me/password', authenticateToken, asyncHandler(async (req, res) => { - const { currentPassword, newPassword } = req.body; - - if (!currentPassword || !newPassword) { - return res.status(400).json(ApiResponse(false, null, 'Current and new password are required')); - } - - if (newPassword.length < 8) { - return res.status(400).json(ApiResponse(false, null, 'New password must be at least 8 characters')); - } - - const user = await dbGet('SELECT password_hash FROM users WHERE id = ?', [req.user.id]); - - const isValid = await comparePassword(currentPassword, user.password_hash); - if (!isValid) { - return res.status(401).json(ApiResponse(false, null, 'Current password is incorrect')); - } - - const passwordHash = await hashPassword(newPassword); - await dbRun('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [passwordHash, req.user.id]); - - // Invalidate all other sessions - await dbRun('DELETE FROM sessions WHERE user_id = ? AND id != ?', [req.user.id, req.sessionId]); - - res.json(ApiResponse(true, null, 'Password changed successfully')); -})); - /** * @route DELETE /api/users/me - * @desc Delete user account - * @access Private + * @desc Delete user account (soft delete by deactivating) + * @access Private (requires Auth0 token) */ router.delete('/me', authenticateToken, asyncHandler(async (req, res) => { - const { password } = req.body; + // Soft delete - just deactivate the account + await dbRun('UPDATE users SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]); - if (!password) { - return res.status(400).json(ApiResponse(false, null, 'Password confirmation required')); - } + // Delete all API keys + await dbRun('DELETE FROM api_keys WHERE user_id = ?', [req.user.id]); - const user = await dbGet('SELECT password_hash FROM users WHERE id = ?', [req.user.id]); - - const isValid = await comparePassword(password, user.password_hash); - if (!isValid) { - return res.status(401).json(ApiResponse(false, null, 'Incorrect password')); - } + // Delete all sessions + await dbRun('DELETE FROM sessions WHERE user_id = ?', [req.user.id]); - // Delete user (cascades to sessions, api_keys, etc.) - await dbRun('DELETE FROM users WHERE id = ?', [req.user.id]); - - res.json(ApiResponse(true, null, 'Account deleted successfully')); + res.json(ApiResponse(true, null, 'Account deactivated successfully')); })); +// ============================================ +// Credits Routes +// ============================================ + /** * @route GET /api/users/credits * @desc Get user credits and transaction history - * @access Private + * @access Private (requires Auth0 token) */ router.get('/credits', authenticateToken, asyncHandler(async (req, res) => { const user = await dbGet('SELECT credits FROM users WHERE id = ?', [req.user.id]); - + const { page, limit, offset } = getPagination(req.query.page, req.query.limit); - + const transactions = await dbAll( `SELECT * FROM credit_transactions WHERE user_id = ? @@ -251,10 +112,14 @@ router.get('/credits', authenticateToken, asyncHandler(async (req, res) => { })); })); +// ============================================ +// API Keys Routes +// ============================================ + /** * @route GET /api/users/api-keys * @desc Get user API keys - * @access Private + * @access Private (requires Auth0 token) */ router.get('/api-keys', authenticateToken, asyncHandler(async (req, res) => { const keys = await dbAll( @@ -271,11 +136,21 @@ router.get('/api-keys', authenticateToken, asyncHandler(async (req, res) => { /** * @route POST /api/users/api-keys * @desc Create a new API key - * @access Private + * @access Private (requires Auth0 token) */ router.post('/api-keys', authenticateToken, asyncHandler(async (req, res) => { const { name } = req.body; + // Check if user already has max API keys (optional limit) + const existingKeys = await dbGet( + 'SELECT COUNT(*) as count FROM api_keys WHERE user_id = ? AND is_active = 1', + [req.user.id] + ); + + if (existingKeys.count >= 10) { + return res.status(400).json(ApiResponse(false, null, 'Maximum of 10 API keys allowed')); + } + const keyId = generateId(); const keyValue = `moxie_${generateId()}_${generateId()}`; const keyHash = await hashPassword(keyValue); @@ -299,7 +174,7 @@ router.post('/api-keys', authenticateToken, asyncHandler(async (req, res) => { /** * @route DELETE /api/users/api-keys/:keyId * @desc Revoke an API key - * @access Private + * @access Private (requires Auth0 token) */ router.delete('/api-keys/:keyId', authenticateToken, asyncHandler(async (req, res) => { const result = await dbRun( @@ -315,7 +190,7 @@ router.delete('/api-keys/:keyId', authenticateToken, asyncHandler(async (req, re })); // ============================================ -// Admin routes +// Admin Routes // ============================================ /** @@ -337,7 +212,7 @@ router.get('/', authenticateToken, requireAdmin, asyncHandler(async (req, res) = } const users = await dbAll( - `SELECT id, email, name, role, credits, subscription_status, subscription_tier, + `SELECT id, auth0_id, email, name, picture, role, credits, subscription_status, subscription_tier, is_active, created_at, last_login FROM users ${whereClause} @@ -366,7 +241,7 @@ router.get('/', authenticateToken, requireAdmin, asyncHandler(async (req, res) = */ router.get('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => { const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.params.userId]); - + if (!user) { return res.status(404).json(ApiResponse(false, null, 'User not found')); } @@ -380,7 +255,7 @@ router.get('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, * @access Admin */ router.put('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => { - const { name, role, credits, subscription_status, subscription_tier, is_active } = req.body; + const { name, role, credits, subscription_status, subscription_tier, is_active, email } = req.body; const updates = []; const values = []; @@ -390,6 +265,7 @@ router.put('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, if (subscription_status !== undefined) { updates.push('subscription_status = ?'); values.push(subscription_status); } if (subscription_tier !== undefined) { updates.push('subscription_tier = ?'); values.push(subscription_tier); } if (is_active !== undefined) { updates.push('is_active = ?'); values.push(is_active ? 1 : 0); } + if (email !== undefined) { updates.push('email = ?'); values.push(email); } if (updates.length === 0) { return res.status(400).json(ApiResponse(false, null, 'No fields to update')); @@ -406,7 +282,7 @@ router.put('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, /** * @route DELETE /api/users/:userId - * @desc Delete user by ID (admin) + * @desc Delete user by ID (admin) - hard delete * @access Admin */ router.delete('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => { @@ -460,4 +336,29 @@ router.post('/:userId/credits', authenticateToken, requireAdmin, asyncHandler(as }, 'Credits updated successfully')); })); +/** + * @route PUT /api/users/:userId/role + * @desc Change user role (admin) + * @access Admin + */ +router.put('/:userId/role', authenticateToken, requireAdmin, asyncHandler(async (req, res) => { + const { role } = req.body; + + if (!role || !['user', 'admin'].includes(role)) { + return res.status(400).json(ApiResponse(false, null, 'Valid role is required (user or admin)')); + } + + const result = await dbRun( + 'UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [role, req.params.userId] + ); + + if (result.changes === 0) { + return res.status(404).json(ApiResponse(false, null, 'User not found')); + } + + const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.params.userId]); + res.json(ApiResponse(true, { user: sanitizeUser(user) }, 'User role updated successfully')); +})); + export default router; diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 08c4cd9..48d8214 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -37,7 +37,7 @@ export const isValidEmail = (email) => { */ export const sanitizeUser = (user) => { if (!user) return null; - const { password_hash, ...safeUser } = user; + const { password_hash, auth0_id, ...safeUser } = user; return safeUser; };