UNPKG

gunauth

Version:

Minimal identity provider using GUN and SEA

1,242 lines (1,081 loc) 40 kB
import express from 'express'; import Gun from 'gun'; import http from 'http'; import crypto from 'crypto'; import rateLimit from 'express-rate-limit'; import { body, validationResult } from 'express-validator'; // Utility function for conditional logging function debugLog(...args) { if (process.env.NODE_ENV !== 'production') { console.log(...args); } } function errorLog(...args) { console.error(...args); // Always log errors } // JWT Security - Use dedicated key for JWT signing instead of Gun keypairs const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(32).toString('hex'); // JWT utility functions function createJWT(payload) { const header = Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'HS256' })).toString('base64url'); const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); const signature = crypto.createHmac('sha256', JWT_SECRET) .update(`${header}.${body}`) .digest('base64url'); return `${header}.${body}.${signature}`; } function verifyJWT(token) { try { const [header, body, signature] = token.split('.'); const expectedSignature = crypto.createHmac('sha256', JWT_SECRET) .update(`${header}.${body}`) .digest('base64url'); if (signature !== expectedSignature) { return null; } const payload = JSON.parse(Buffer.from(body, 'base64url').toString()); // Check expiration if (payload.exp && Date.now() / 1000 > payload.exp) { return null; } return payload; } catch (error) { return null; } } // Rate limiting configuration const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 50, // 50 authentication attempts per window (increased for testing) message: { error: 'Too many authentication attempts, please try again in 15 minutes' }, standardHeaders: true, legacyHeaders: false }); const generalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // 100 requests per window message: { error: 'Too many requests, please try again later' }, standardHeaders: true, legacyHeaders: false }); // Public Gun instance for all functionality (users, OAuth, encrypted sessions) const gunRelays = process.env.GUN_RELAYS ? process.env.GUN_RELAYS.split(',') : [ 'https://gun-manhattan.herokuapp.com/gun', 'https://gunjs.herokuapp.com/gun', 'https://gun-us.herokuapp.com/gun', 'https://gun-eu.herokuapp.com/gun', 'https://peer.wallie.io/gun', 'https://relay.peer.ooo/gun' ]; // Create Express app const app = express(); const server = http.createServer(app); // Create Gun with local server support const gun = Gun({ web: server, peers: gunRelays }); // User-scoped encrypted session sharing via public Gun network // Sessions encrypted with user credentials, stored using hash-based paths async function getUserSession(userPub) { return new Promise((resolve) => { const timeout = setTimeout(() => { debugLog('User session timeout for pub:', userPub.substring(0, 20) + '...'); resolve(null); }, 10000); // Create deterministic hash for session lookup (immutable storage) const sessionHash = crypto.createHash('sha256') .update(`session_${userPub}`) .digest('hex') .substring(0, 16); // Shorter for efficiency // Use collection-based immutable storage pattern to prevent enumeration attacks gun.get("sessions").get(sessionHash).once((encryptedSession, key) => { clearTimeout(timeout); if (encryptedSession && encryptedSession.exp && Date.now() > encryptedSession.exp) { // Mark session as expired rather than deleting (immutable principle) debugLog('User session expired'); gun.get("sessions").get(sessionHash + "_exp").put(Date.now()); resolve(null); } else if (encryptedSession) { // Verify data integrity if present if (encryptedSession.integrity) { const expectedHash = crypto.createHash('sha256') .update(JSON.stringify({ encrypted: encryptedSession.encrypted, exp: encryptedSession.exp, userPub: encryptedSession.userPub, timestamp: encryptedSession.timestamp })) .digest('hex'); if (encryptedSession.integrity !== expectedHash) { debugLog('⚠️ Session integrity check failed'); resolve(null); return; } } debugLog('Retrieved encrypted user session'); resolve(encryptedSession); } else { // Fallback to legacy storage for backward compatibility gun.get('user_sessions').get(userPub).once((legacySession, key) => { if (legacySession && legacySession.exp && Date.now() <= legacySession.exp) { debugLog('Retrieved session from legacy storage, migrating...'); // Migrate to immutable storage gun.get("sessions").get(sessionHash).put(legacySession); resolve(legacySession); } else { resolve(null); } }); } }); }); } async function setUserSession(userPub, sessionData, userCredentials) { try { // Derive encryption key from user credentials + pub key const encryptionKey = await Gun.SEA.work( userCredentials.username + userCredentials.password, userPub ); // Encrypt session data const encrypted = await Gun.SEA.encrypt(sessionData, encryptionKey); const secureSessionData = { encrypted: encrypted, exp: sessionData.exp, userPub: userPub, timestamp: Date.now(), // Add integrity hash to detect tampering integrity: crypto.createHash('sha256') .update(JSON.stringify({ encrypted, exp: sessionData.exp, userPub, timestamp: Date.now() })) .digest('hex') }; // Only add domains if they exist and are non-empty if (sessionData.domains && sessionData.domains.length > 0) { secureSessionData.domains = sessionData.domains.join(','); // Store as comma-separated string } // Create deterministic hash for immutable storage const sessionHash = crypto.createHash('sha256') .update(`session_${userPub}`) .digest('hex') .substring(0, 16); // Shorter for efficiency return new Promise((resolve) => { const timeout = setTimeout(() => { console.log('User session set timeout'); resolve(false); }, 5000); // Use collection-based storage pattern gun.get("sessions").get(sessionHash).put(secureSessionData, (ack) => { clearTimeout(timeout); if (ack.err) { console.error('Failed to store session:', ack.err); resolve(false); } else { debugLog('✅ Session stored with integrity hash'); // Also store session metadata in separate immutable path gun.get("sessions_meta").get(sessionHash).put({ userPub, created: Date.now(), version: "1.0" }); resolve(true); } }); }); } catch (error) { console.error('Session encryption error:', error); return false; } } async function clearUserSession(userPub) { // Create deterministic hash for immutable storage const sessionHash = crypto.createHash('sha256') .update(`session_${userPub}`) .digest('hex') .substring(0, 16); // Shorter for efficiency return new Promise((resolve) => { const timeout = setTimeout(() => resolve(false), 10000); // Instead of deleting data (which breaks immutability), mark as cleared gun.get("sessions").get(sessionHash + "_clr").put(Date.now(), (ack) => { clearTimeout(timeout); if (ack.err) { console.error('Failed to clear session:', ack.err); resolve(false); } else { debugLog('User session cleared'); resolve(true); } }); }); } // Middleware app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); // Apply general rate limiting to all requests app.use(generalLimiter); // CORS for browser requests app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); if (req.method === 'OPTIONS') { res.sendStatus(200); } else { next(); } }); // Input validation middleware (moved before routes for proper initialization) const validateRegistration = [ body('username') .isLength({ min: 3, max: 30 }) .withMessage('Username must be between 3 and 30 characters') .matches(/^[a-zA-Z0-9_-]+$/) .withMessage('Username can only contain letters, numbers, hyphens, and underscores'), body('password') .isLength({ min: 8, max: 128 }) .withMessage('Password must be between 8 and 128 characters') .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .withMessage('Password must contain at least one lowercase letter, one uppercase letter, and one number') ]; const validateLogin = [ body('username') .isLength({ min: 3, max: 30 }) .withMessage('Username must be between 3 and 30 characters') .matches(/^[a-zA-Z0-9_-]+$/) .withMessage('Invalid username format'), body('password') .isLength({ min: 1, max: 128 }) .withMessage('Password is required') ]; const validateSSO = [ body('token') .notEmpty() .withMessage('Token is required'), body('pub') .notEmpty() .withMessage('Public key is required'), body('redirect_uri') .custom((value) => { try { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol)) { throw new Error('Invalid protocol'); } // Allow localhost and IP addresses for development if (url.hostname === 'localhost' || url.hostname.match(/^127\.\d+\.\d+\.\d+$/) || url.hostname.match(/^\d+\.\d+\.\d+\.\d+$/) || url.hostname.match(/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) { return true; } throw new Error('Invalid hostname'); } catch (error) { throw new Error('Valid redirect URI is required'); } }), body('client_id') .optional({ nullable: true, checkFalsy: true }) .isLength({ max: 100 }) .withMessage('Client ID must be less than 100 characters') ]; // Validation error handler function handleValidationErrors(req, res, next) { const errors = validationResult(req); if (!errors.isEmpty()) { console.log('Validation failed for', req.path, '- errors count:', errors.array().length); return res.status(400).json({ error: 'Validation failed', details: errors.array() }); } next(); } // Serve Gun database over HTTP/WebSocket app.use(Gun.serve); // Health check endpoint app.get('/', (req, res) => { res.json({ service: 'GunAuth Identity Provider', status: 'running', timestamp: Date.now() }); }); // Session API endpoints for the session bridge (DEPRECATED - using user-scoped sessions now) /* app.get('/api/session', async (req, res) => { try { console.log('API: Getting session from GUN'); const session = await getActiveSession('localhost:8000'); console.log('API: Retrieved session:', session); res.json({ session: session || null }); } catch (error) { console.error('API: Session get error:', error); res.json({ session: null, error: error.message }); } }); // Clear all sessions endpoint for testing app.delete('/api/sessions/clear', async (req, res) => { try { console.log('API: Clearing all sessions from GUN'); await clearActiveSession('localhost:8000'); console.log('API: All sessions cleared successfully'); res.json({ success: true, message: 'All sessions cleared' }); } catch (error) { console.error('API: Session clear error:', error); res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/session', async (req, res) => { try { console.log('API: Setting session in GUN:', req.body); await setActiveSession('localhost:8000', req.body); console.log('API: Session set successfully'); res.json({ success: true }); } catch (error) { console.error('API: Session set error:', error); res.status(500).json({ success: false, error: error.message }); } }); app.delete('/api/session', async (req, res) => { try { console.log('API: Clearing session from GUN'); await clearActiveSession('localhost:8000'); console.log('API: Session cleared successfully'); res.json({ success: true }); } catch (error) { console.error('API: Session clear error:', error); res.status(500).json({ success: false, error: error.message }); } }); */ // Session bridge endpoint for PostMessage communication app.get('/session-bridge.html', (req, res) => { const bridgeHtml = `<!DOCTYPE html> <html> <head> <title>GunAuth Session Bridge</title> <meta charset="utf-8"> </head> <body> <script> // This file should be hosted on your auth domain // It handles postMessage communication for cross-domain session sharing class SessionBridge { constructor() { window.addEventListener('message', this.handleMessage.bind(this)); console.log('GunAuth Session Bridge loaded'); } handleMessage(event) { // Verify trusted origins const trustedOrigins = [ 'https://app1.example.com', 'https://app2.example.com', 'http://localhost:8001', 'http://localhost:8002' ]; if (!trustedOrigins.some(origin => event.origin.startsWith(origin))) { console.warn('Untrusted origin:', event.origin); return; } const { type, action, messageId, data } = event.data; if (type === 'gunauth-request') { this.handleRequest(event, action, messageId, data); } } async handleRequest(event, action, messageId, data) { console.log('Session bridge handling request:', { action, messageId, origin: event.origin }); try { let result; switch (action) { case 'getSession': result = await this.getSession(); break; case 'setSession': result = await this.setSession(data); break; case 'clearSession': result = await this.clearSession(); break; case 'verifyToken': console.log('Session bridge calling verifyToken with:', data); result = await this.verifyToken(data); console.log('Session bridge verifyToken completed:', result); break; default: throw new Error(\`Unknown action: \${action}\`); } console.log('Session bridge sending response:', { messageId, result }); event.source.postMessage({ type: 'gunauth-response', messageId, data: result }, event.origin); } catch (error) { console.error('Session bridge error:', { messageId, error: error.message, stack: error.stack }); event.source.postMessage({ type: 'gunauth-response', messageId, error: error.message }, event.origin); } } async getSession() { console.log('Getting session via HTTP endpoint'); try { const response = await fetch('/api/session'); const result = await response.json(); console.log('Retrieved session:', result); return result.session; } catch (error) { console.error('Failed to get session:', error); return null; } } async setSession(sessionData) { console.log('Session bridge setSession called with:', sessionData); if (sessionData && sessionData.token && sessionData.pub) { try { const response = await fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sessionData) }); const result = await response.json(); console.log('Session stored successfully'); return result.success; } catch (error) { console.error('Failed to store session:', error); return false; } } console.log('Failed to set session - invalid data'); return false; } async clearSession() { console.log('Clearing session via HTTP endpoint'); try { const response = await fetch('/api/session', { method: 'DELETE' }); const result = await response.json(); console.log('Session cleared successfully'); return result.success; } catch (error) { console.error('Failed to clear session:', error); return false; } } async verifyToken(data) { console.log('Session bridge verifyToken called with:', data); const authServerUrl = window.location.origin; try { const response = await fetch(\`\${authServerUrl}/verify\`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); console.log('Session bridge verifyToken result:', result); return result; } catch (error) { console.error('Session bridge verifyToken error:', error); throw new Error(\`Token verification failed: \${error.message}\`); } } } // Initialize the bridge new SessionBridge(); </script> </body> </html>`; res.setHeader('Content-Type', 'text/html'); res.send(bridgeHtml); }); // SSO Authorization endpoint - initiates cross-domain login app.get('/sso/authorize', (req, res) => { const { redirect_uri, client_id, state } = req.query; if (!redirect_uri) { return res.status(400).json({ error: 'redirect_uri is required' }); } // Create a login form that will handle the authentication const loginForm = ` <!DOCTYPE html> <html> <head> <title>GunAuth - Login</title> <style> body { font-family: Arial, sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; } .form-group { margin-bottom: 15px; } input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; } button { width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; } button:hover { background: #0056b3; } .error { color: red; margin-top: 10px; } .loading { opacity: 0.7; } </style> </head> <body> <h2>Login to GunAuth</h2> <form id="loginForm"> <div class="form-group"> <input type="text" id="username" placeholder="Username" required> </div> <div class="form-group"> <input type="password" id="password" placeholder="Password" required> </div> <button type="submit" id="submitBtn">Login</button> <div id="error" class="error"></div> </form> <script> const REDIRECT_URI = ${JSON.stringify(redirect_uri)}; const CLIENT_ID = ${JSON.stringify(client_id || '')}; const STATE = ${JSON.stringify(state || '')}; const form = document.getElementById('loginForm'); const submitBtn = document.getElementById('submitBtn'); const errorDiv = document.getElementById('error'); form.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('username').value; const password = document.getElementById('password').value; submitBtn.textContent = 'Logging in...'; submitBtn.disabled = true; form.classList.add('loading'); errorDiv.textContent = ''; try { const response = await fetch('/sso/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const result = await response.json(); e authorization code and redirect const codeResponse = await fetch('/sso/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: result.token, pub: result.pub, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, state: STATE }) }); const codeResult = await codeResponse.json(); if (codeResult.success) { const params = new URLSearchParams({ code: codeResult.code, state: STATE }); window.location.href = REDIRECT_URI + '?' + params.toString(); } else { throw new Error(codeResult.error); } } else { throw new Error(result.error); } } catch (error) { errorDiv.textContent = error.message; submitBtn.textContent = 'Login'; submitBtn.disabled = false; form.classList.remove('loading'); } }); </script> </body> </html> `; res.send(loginForm); }); // SSO Login endpoint - handles authentication for SSO flow using Gun SEA properly app.post('/sso/login', async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } // Get user data const userData = await new Promise((resolve) => { gun.get('users').get(username).once((data) => { resolve(data); }); }); if (!userData || !userData.pub) { return res.status(401).json({ error: 'Invalid credentials' }); } // Verify password by hashing with the user's public key const hashedPassword = await Gun.SEA.work(password, userData.pub); if (hashedPassword !== userData.hashedPassword) { return res.status(401).json({ error: 'Invalid credentials' }); } // For SSO flow, create a temporary keypair for token signing // This is only used for the SSO token, not stored permanently const tempKeyPair = await Gun.SEA.pair(); // Create token claims const now = Math.floor(Date.now() / 1000); // JWT uses seconds const issuer = process.env.ISSUER_URL || `${req.protocol}://${req.get('host')}`; const tokenClaims = { sub: username, iss: issuer, iat: now, exp: now + 3600, // 1 hour expiration (in seconds) sso: true, // Mark as SSO token pub: tempKeyPair.pub // Include public key for verification }; // Create JWT using dedicated signing key (not Gun keypair) const token = createJWT(tokenClaims); // Store the session in Gun for cross-domain access const sessionData = { token, pub: tempKeyPair.pub, // Use temp keypair pub for verification exp: tokenClaims.exp * 1000, // Convert back to milliseconds for storage username: username, loginTime: Date.now(), sso: true }; debugLog('🔐 Server: SSO session created for user'); // Note: Not storing SSO sessions server-side - client handles its own session storage res.json({ success: true, token, pub: tempKeyPair.pub, exp: tokenClaims.exp * 1000, // Convert back to milliseconds for client username: username }); } catch (error) { console.error('SSO Login error:', error); res.status(500).json({ error: 'SSO Login failed' }); } }); // SSO Code exchange - creates temporary authorization codes app.post('/sso/code', authLimiter, validateSSO, handleValidationErrors, async (req, res) => { try { const { token, pub, redirect_uri, client_id, state } = req.body; if (!token || !pub) { console.log('SSO code request missing required fields'); return res.status(400).json({ error: 'Token and pub are required' }); } // Verify the token using JWT verification const verifiedClaims = verifyJWT(token); if (!verifiedClaims) { return res.status(401).json({ error: 'Invalid or expired token' }); } // Generate a temporary authorization code const code = await Gun.SEA.work(JSON.stringify({ token, pub, redirect_uri, client_id, timestamp: Date.now() }), 'gunauth-sso-secret-' + Date.now()); // Store the code temporarily (expires in 10 minutes) const codeData = { token, pub, username: verifiedClaims.sub, // Extract username from token claims redirect_uri, client_id, state, expires: Date.now() + (10 * 60 * 1000) // 10 minutes }; gun.get('sso-codes').get(code).put(codeData); res.json({ success: true, code }); } catch (error) { console.error('SSO code error:', error); res.status(500).json({ error: 'Failed to create authorization code' }); } }); // SSO Token exchange - exchanges authorization code for token app.post('/sso/token', async (req, res) => { try { const { code, client_id } = req.body; if (!code) { return res.status(400).json({ error: 'Authorization code is required' }); } // Retrieve the stored code data const codeData = await new Promise((resolve) => { gun.get('sso-codes').get(code).once((data) => { resolve(data); }); }); if (!codeData) { return res.status(401).json({ error: 'Invalid authorization code' }); } // Check if code has expired if (Date.now() > codeData.expires) { // Clean up expired code gun.get('sso-codes').get(code).put(null); return res.status(401).json({ error: 'Authorization code expired' }); } // Verify client_id if provided if (client_id && codeData.client_id !== client_id) { return res.status(401).json({ error: 'Invalid client_id' }); } // Clean up the used code gun.get('sso-codes').get(code).put(null); // Return the token data res.json({ success: true, token: codeData.token, pub: codeData.pub, username: codeData.username, // Include username in response token_type: 'Bearer' }); } catch (error) { console.error('SSO token exchange error:', error); res.status(500).json({ error: 'Token exchange failed' }); } }); // User Registration app.post('/register', authLimiter, validateRegistration, handleValidationErrors, async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } // Check if user already exists - use immutable storage pattern const userHash = crypto.createHash('sha256') .update(`user_${username}`) .digest('hex') .substring(0, 16); // Shorter for efficiency const existingUser = await new Promise((resolve) => { // Check both new immutable storage and legacy storage gun.get("users").get(userHash).once((data) => { if (data) { resolve(data); } else { // Fallback to legacy storage for existing users gun.get('users').get(username).once((legacyData) => { resolve(legacyData); }); } }); }); if (existingUser) { return res.status(409).json({ error: 'User already exists' }); } // Generate SEA key pair const pair = await Gun.SEA.pair(); // Hash the password using SEA.work const hashedPassword = await Gun.SEA.work(password, pair.pub); // Store user data (only public information) with integrity protection const userData = { username, pub: pair.pub, hashedPassword, createdAt: Date.now(), // Add integrity hash to detect tampering integrity: crypto.createHash('sha256') .update(JSON.stringify({ username, pub: pair.pub, hashedPassword, createdAt: Date.now() })) .digest('hex') }; // Store in collection storage gun.get("users").get(userHash).put(userData); // Also store metadata separately gun.get("users_meta").get(userHash).put({ username, created: Date.now(), version: "1.0" }); // SECURITY: Never store private keys server-side // Return the keypair to client for secure client-side storage res.status(201).json({ success: true, username, pub: pair.pub, priv: pair.priv, // Client will store this securely createdAt: userData.createdAt }); } catch (error) { console.error('Registration error:', error); res.status(500).json({ error: 'Registration failed' }); } }); // Login Challenge - Step 1: Request authentication challenge app.post('/login-challenge', authLimiter, validateLogin, handleValidationErrors, async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } // Get user data using immutable storage pattern const userHash = crypto.createHash('sha256') .update(`user_${username}`) .digest('hex') .substring(0, 16); // Shorter for efficiency const userData = await new Promise((resolve) => { // Check immutable storage first gun.get("users").get(userHash).once((data) => { if (data) { // Verify data integrity const expectedHash = crypto.createHash('sha256') .update(JSON.stringify({ username: data.username, pub: data.pub, hashedPassword: data.hashedPassword, createdAt: data.createdAt })) .digest('hex'); if (data.integrity === expectedHash) { debugLog('✅ User data integrity verified'); resolve(data); } else { debugLog('⚠️ User data integrity check failed'); resolve(null); } } else { // Fallback to legacy storage for existing users gun.get('users').get(username).once((legacyData) => { if (legacyData) { debugLog('📦 Loading user from legacy storage'); } resolve(legacyData); }); } }); }); if (!userData || !userData.pub) { return res.status(401).json({ error: 'Invalid credentials' }); } // Verify password by hashing with the user's public key const hashedPassword = await Gun.SEA.work(password, userData.pub); if (hashedPassword !== userData.hashedPassword) { return res.status(401).json({ error: 'Invalid credentials' }); } // Generate challenge for cryptographic proof const challenge = crypto.randomBytes(32).toString('hex'); const challengeId = crypto.randomBytes(16).toString('hex'); const expiresAt = Date.now() + 300000; // 5 minutes // Store challenge temporarily gun.get('auth-challenges').get(challengeId).put({ challenge, username, pub: userData.pub, expires: expiresAt }); debugLog('🔐 Server: Challenge generated for user:', username); res.json({ success: true, challengeId, challenge, pub: userData.pub }); } catch (error) { console.error('Challenge generation error:', error); res.status(500).json({ error: 'Challenge generation failed' }); } }); // Login Verify - Step 2: Verify cryptographic signature (NO PRIVATE KEYS!) app.post('/login-verify', authLimiter, async (req, res) => { try { const { challengeId, signedChallenge } = req.body; if (!challengeId || !signedChallenge) { return res.status(400).json({ error: 'Challenge ID and signed challenge are required' }); } // Get stored challenge const challengeData = await new Promise((resolve) => { gun.get('auth-challenges').get(challengeId).once((data) => { resolve(data); }); }); if (!challengeData) { return res.status(401).json({ error: 'Invalid or expired challenge' }); } // Check if challenge has expired if (Date.now() > challengeData.expires) { // Clean up expired challenge gun.get('auth-challenges').get(challengeId).put(null); return res.status(401).json({ error: 'Challenge expired' }); } // Verify signature using Gun SEA - NO PRIVATE KEY NEEDED! const verified = await Gun.SEA.verify(signedChallenge, challengeData.pub); if (verified !== challengeData.challenge) { return res.status(401).json({ error: 'Invalid signature' }); } // Clean up used challenge gun.get('auth-challenges').get(challengeId).put(null); // Create token claims with proper JWT structure const now = Math.floor(Date.now() / 1000); const issuer = process.env.ISSUER_URL || `${req.protocol}://${req.get('host')}`; const tokenClaims = { sub: challengeData.username, iss: issuer, iat: now, exp: now + 3600, // 1 hour expiration pub: challengeData.pub }; // Create JWT using dedicated signing key const token = createJWT(tokenClaims); // Store the session in Gun for cross-domain access const sessionData = { token, pub: challengeData.pub, exp: tokenClaims.exp * 1000, username: challengeData.username, loginTime: Date.now() }; debugLog('🔐 Server: Storing session in GUN database for user:', challengeData.username); await setUserSession(challengeData.pub, sessionData, { username: challengeData.username, password: 'dummy' }); debugLog('✅ Server: Session stored successfully in GUN database'); res.json({ success: true, token, pub: challengeData.pub, exp: tokenClaims.exp * 1000, username: challengeData.username }); } catch (error) { console.error('Login verification error:', error); res.status(500).json({ error: 'Login verification failed' }); } }); // Token Verification app.post('/verify', async (req, res) => { try { const { token } = req.body; // Only need token now, pub key is embedded if (!token) { return res.status(400).json({ error: 'Token is required' }); } // Verify the token using JWT verification const verifiedClaims = verifyJWT(token); if (!verifiedClaims) { return res.status(401).json({ error: 'Invalid or expired token' }); } res.json({ success: true, claims: verifiedClaims, valid: true }); } catch (error) { console.error('Verification error:', error); res.status(500).json({ error: 'Token verification failed' }); } }); // Get user public key by username (utility endpoint) app.get('/user/:username/pub', async (req, res) => { try { const { username } = req.params; // Get user data using immutable storage pattern const userHash = crypto.createHash('sha256') .update(`user_${username}`) .digest('hex') .substring(0, 16); // Shorter for efficiency const userData = await new Promise((resolve) => { // Check immutable storage first gun.get("users").get(userHash).once((data) => { if (data) { // Verify data integrity const expectedHash = crypto.createHash('sha256') .update(JSON.stringify({ username: data.username, pub: data.pub, hashedPassword: data.hashedPassword, createdAt: data.createdAt })) .digest('hex'); if (data.integrity === expectedHash) { debugLog('✅ User data integrity verified'); resolve(data); } else { debugLog('⚠️ User data integrity check failed'); resolve(null); } } else { // Fallback to legacy storage gun.get('users').get(username).once((legacyData) => { if (legacyData) { debugLog('📦 Loading user from legacy storage'); } resolve(legacyData); }); } }); }); if (!userData || !userData.pub) { return res.status(404).json({ error: 'User not found' }); } res.json({ username, pub: userData.pub, createdAt: userData.createdAt }); } catch (error) { console.error('User lookup error:', error); res.status(500).json({ error: 'User lookup failed' }); } }); // Error handling middleware app.use((err, req, res, next) => { console.error('Unhandled error:', err); res.status(500).json({ error: 'Internal server error' }); }); // 404 handler app.use((req, res) => { res.status(404).json({ error: 'Endpoint not found' }); }); // Server setup const port = process.env.PORT || 8000; server.listen(port, () => { console.log(`GunAuth Identity Provider running on port ${port}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); });