# Authentication Guidelines ## Overview All authentication is handled by **Mana Core Auth**, a centralized authentication service using **Better Auth** with **EdDSA JWT** tokens. ## Architecture ``` ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ Web/Mobile │────>│ Compute Server │────>│ mana-auth │ │ Client │ │ (Hono/Bun) │ │ (port 3001) │ └─────────────────┘ └─────────────────┘ └──────────────────┘ │ │ │ │ 1. Login │ │ │─────────────────────────────────────────────>│ │ │ │ │ 2. JWT Token │ │ │<─────────────────────────────────────────────│ │ │ │ │ 3. API Request │ │ │ + Bearer Token │ │ │──────────────────────>│ │ │ │ │ │ │ 4. Validate Token │ │ │──────────────────────>│ │ │ │ │ │ 5. {valid, payload} │ │ │<──────────────────────│ │ │ │ │ 6. Response │ │ │<──────────────────────│ │ ``` ## User ID Format **CRITICAL**: Mana Core Auth uses Better Auth, which generates **non-UUID user IDs**. ``` Example user ID: otUe1YrfENPdHnrF3g1vSBfpkQfambCZ ``` **Format details:** - 32 characters - Base62 alphabet (a-z, A-Z, 0-9) - ~190 bits of entropy (more than UUID's 122 bits) - NOT a valid UUID format **Database schema implications:** ```typescript // CORRECT - use text for user_id userId: text('user_id').notNull(), // WRONG - will cause "invalid input syntax for type uuid" errors userId: uuid('user_id').notNull(), ``` Always use `text` type for `user_id` columns in all database schemas. ## Token Structure (EdDSA JWT) ```json { "sub": "otUe1YrfENPdHnrF3g1vSBfpkQfambCZ", "email": "user@example.com", "role": "user", "sid": "session-id-456", "iat": 1701234567, "exp": 1701238167, "iss": "manacore", "aud": "manacore" } ``` **Note**: The `sub` claim contains the Better Auth user ID (not a UUID). **Important**: Keep claims minimal. Do NOT include: - Credit balance (changes frequently) - Organization data (use API instead) - Feature flags - Other dynamic data ## Shared Packages | Package | Purpose | Use Case | |---------|---------|----------| | `@manacore/shared-hono` | Hono auth middleware + helpers | All compute servers (Hono/Bun) | | `@manacore/shared-auth` | Client auth service | Web/Mobile apps | ## Server Integration (Hono/Bun) All compute servers use `@manacore/shared-hono`: ```typescript import { Hono } from 'hono'; import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono'; const app = new Hono(); app.onError(errorHandler); app.notFound(notFoundHandler); app.route('/health', healthRoute('my-server')); // Protect all /api/* routes app.use('/api/*', authMiddleware()); // Access user in route handlers app.get('/api/v1/data', (c) => { const userId = c.get('userId'); // Better Auth user ID (not UUID) const email = c.get('userEmail'); return c.json({ userId }); }); ``` ## Environment Variables ```env # Required for all servers MANA_CORE_AUTH_URL=http://localhost:3001 # Development bypass (optional) NODE_ENV=development DEV_BYPASS_AUTH=true DEV_USER_ID=dev-user-123 # For credit operations (when using nestjs-integration) MANA_CORE_SERVICE_KEY=your-service-key APP_ID=your-app-id ``` ## Client Integration (Web) ### Runtime URL Injection (Recommended for Docker) For Docker deployments, use runtime URL injection instead of build-time environment variables: **Step 1: Server Hook (`hooks.server.ts`)** ```typescript // src/hooks.server.ts import type { Handle } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; export const handle: Handle = async ({ event, resolve }) => { return resolve(event, { transformPageChunk: ({ html }) => html.replace( '%RUNTIME_ENV%', `` ), }); }; ``` **Step 2: Update `app.html`** ```html %sveltekit.head% %RUNTIME_ENV%
%sveltekit.body%
``` **Step 3: URL Helper (`url.ts`)** ```typescript // src/lib/config/url.ts import { browser } from '$app/environment'; import { PUBLIC_MANA_CORE_AUTH_URL, PUBLIC_BACKEND_URL } from '$env/static/public'; declare global { interface Window { __PUBLIC_AUTH_URL__?: string; __PUBLIC_BACKEND_URL__?: string; } } export function getAuthUrl(): string { if (browser && window.__PUBLIC_AUTH_URL__) { return window.__PUBLIC_AUTH_URL__; } return PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; } export function getBackendUrl(): string { if (browser && window.__PUBLIC_BACKEND_URL__) { return window.__PUBLIC_BACKEND_URL__; } return PUBLIC_BACKEND_URL || 'http://localhost:3000'; } ``` ### Standard Auth Store Pattern (Svelte 5) ```typescript // src/lib/stores/auth.svelte.ts import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { initializeWebAuth } from '@manacore/shared-auth'; import { getAuthUrl } from '$lib/config/url'; // Lazy initialization - only create when needed (browser only) let _authService: ReturnType['authService'] | null = null; function getAuthService() { if (!browser) return null; if (!_authService) { const auth = initializeWebAuth({ baseUrl: getAuthUrl() }); _authService = auth.authService; } return _authService; } // Svelte 5 reactive state let user = $state(null); let accessToken = $state(null); let loading = $state(true); let initialized = $state(false); // Initialize auth on app start async function initialize() { if (!browser || initialized) return; const authService = getAuthService(); if (!authService) { loading = false; return; } try { const currentUser = await authService.getCurrentUser(); if (currentUser) { user = currentUser; // Use getValidToken() for auto-refresh, NOT getAccessToken() accessToken = await authService.getValidToken(); } } catch (error) { console.error('Auth initialization failed:', error); user = null; accessToken = null; } finally { loading = false; initialized = true; } } // Get a valid token (with auto-refresh if expired) async function getValidToken(): Promise { const authService = getAuthService(); if (!authService) return null; try { return await authService.getValidToken(); } catch { // Token refresh failed, user needs to re-login await logout(); return null; } } // DEPRECATED: Use getValidToken() instead function getAccessToken(): string | null { console.warn('getAccessToken() is deprecated. Use getValidToken() for auto-refresh.'); return accessToken; } async function login(email: string, password: string): Promise { const authService = getAuthService(); if (!authService) return false; try { const result = await authService.signIn({ email, password }); user = result.user; accessToken = result.accessToken; return true; } catch { return false; } } async function logout() { const authService = getAuthService(); if (authService) { try { await authService.signOut(); } catch { // Ignore logout errors } } user = null; accessToken = null; goto('/login'); } export const authStore = { // Getters (reactive) get user() { return user; }, get loading() { return loading; }, get isAuthenticated() { return !!accessToken && !!user; }, get initialized() { return initialized; }, // Methods initialize, login, logout, getValidToken, // RECOMMENDED getAccessToken, // DEPRECATED }; ``` ### Key Best Practices | Practice | Description | |----------|-------------| | **Lazy Initialization** | Only create auth service when needed (browser check) | | **Use `getValidToken()`** | Auto-refreshes expired tokens, unlike `getAccessToken()` | | **Runtime URL Injection** | Enables Docker deployments without rebuild | | **SSR Guard** | Always check `browser` before accessing auth service | | **Initialized Flag** | Prevents double initialization | ### Basic Setup (Static URLs) For simpler deployments without Docker, use static environment variables: ```typescript // src/lib/stores/auth.svelte.ts import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { initializeWebAuth } from '@manacore/shared-auth'; import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; // Lazy initialize to avoid SSR issues let _authService: ReturnType['authService'] | null = null; function getAuthService() { if (!browser) return null; if (!_authService) { const auth = initializeWebAuth({ baseUrl: AUTH_URL }); _authService = auth.authService; } return _authService; } // State let user = $state(null); let token = $state(null); let loading = $state(true); // Initialize on app start async function initialize() { if (!browser) return; const authService = getAuthService(); if (!authService) return; const currentUser = await authService.getCurrentUser(); if (currentUser) { user = currentUser; token = await authService.getAccessToken(); } loading = false; } // Actions async function login(email: string, password: string): Promise { const authService = getAuthService(); if (!authService) return false; try { const result = await authService.signIn({ email, password }); user = result.user; token = result.accessToken; return true; } catch { return false; } } async function logout() { const authService = getAuthService(); if (authService) { await authService.signOut(); } user = null; token = null; goto('/login'); } export const authStore = { get user() { return user; }, get token() { return token; }, get loading() { return loading; }, get isAuthenticated() { return !!token; }, initialize, login, logout, }; ``` ### Protected Routes ```svelte {#if authStore.loading} {:else if authStore.isAuthenticated} {@render children()} {/if} ``` ### API Requests with Token ```typescript // src/lib/api/client.ts import { authStore } from '$lib/stores/auth.svelte'; import { goto } from '$app/navigation'; import { PUBLIC_BACKEND_URL } from '$env/static/public'; async function request(endpoint: string, options: RequestInit = {}): Promise> { const token = authStore.token; const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, { ...options, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, }, }); // Handle 401 - session expired if (response.status === 401) { authStore.logout(); goto('/login'); return { ok: false, error: { code: 'ERR_2000', message: 'Session expired' } }; } const json = await response.json(); return json.ok ? { ok: true, data: json.data } : { ok: false, error: json.error }; } ``` ## Client Integration (Mobile) ### Auth Provider ```tsx // context/AuthProvider.tsx import { createContext, useContext, useState, useEffect } from 'react'; import * as SecureStore from 'expo-secure-store'; import { initializeMobileAuth } from '@manacore/shared-auth'; import Constants from 'expo-constants'; const AUTH_URL = Constants.expoConfig?.extra?.authUrl ?? 'http://localhost:3001'; const TOKEN_KEY = 'mana_auth_token'; const USER_KEY = 'mana_auth_user'; interface AuthContextType { user: User | null; token: string | null; loading: boolean; login: (email: string, password: string) => Promise; logout: () => Promise; } const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [token, setToken] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { loadStoredAuth(); }, []); async function loadStoredAuth() { try { const storedToken = await SecureStore.getItemAsync(TOKEN_KEY); const storedUser = await SecureStore.getItemAsync(USER_KEY); if (storedToken && storedUser) { // Validate token is still valid const isValid = await validateToken(storedToken); if (isValid) { setToken(storedToken); setUser(JSON.parse(storedUser)); } else { // Token expired, clear storage await SecureStore.deleteItemAsync(TOKEN_KEY); await SecureStore.deleteItemAsync(USER_KEY); } } } finally { setLoading(false); } } async function validateToken(token: string): Promise { try { const response = await fetch(`${AUTH_URL}/api/v1/auth/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); const result = await response.json(); return result.valid === true; } catch { return false; } } async function login(email: string, password: string): Promise { try { const response = await fetch(`${AUTH_URL}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const result = await response.json(); if (result.accessToken && result.user) { await SecureStore.setItemAsync(TOKEN_KEY, result.accessToken); await SecureStore.setItemAsync(USER_KEY, JSON.stringify(result.user)); setToken(result.accessToken); setUser(result.user); return true; } return false; } catch { return false; } } async function logout() { await SecureStore.deleteItemAsync(TOKEN_KEY); await SecureStore.deleteItemAsync(USER_KEY); setToken(null); setUser(null); } return ( {children} ); } export function useAuth() { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be within AuthProvider'); return context; } ``` ## Auth Endpoints ### Mana Core Auth API | Endpoint | Method | Description | |----------|--------|-------------| | `/api/v1/auth/register` | POST | Register new user | | `/api/v1/auth/login` | POST | Login, returns JWT | | `/api/v1/auth/logout` | POST | Logout, invalidates session | | `/api/v1/auth/validate` | POST | Validate JWT token | | `/api/v1/auth/refresh` | POST | Refresh access token | | `/api/v1/auth/me` | GET | Get current user | | `/api/v1/auth/jwks` | GET | Get JWKS for token verification | ### Request/Response Examples **Register** ```bash POST /api/v1/auth/register { "email": "user@example.com", "password": "securepassword123", "name": "John Doe" } Response: { "user": { "id": "...", "email": "...", "name": "..." }, "accessToken": "eyJ...", "refreshToken": "..." } ``` **Login** ```bash POST /api/v1/auth/login { "email": "user@example.com", "password": "securepassword123" } Response: { "user": { "id": "...", "email": "...", "name": "..." }, "accessToken": "eyJ...", "refreshToken": "..." } ``` **Validate Token** ```bash POST /api/v1/auth/validate { "token": "eyJ..." } Response: { "valid": true, "payload": { "sub": "user-id", "email": "user@example.com", "role": "user", "sid": "session-id" } } ``` ## Server-Side Tier Gating The JWT carries a `tier` claim (`guest | public | beta | alpha | founder`) sourced from `auth.users.access_tier`. Client-side `AuthGate` enforcement is not enough — a user can still call the API directly with their token. For any endpoint that consumes shared infrastructure (LLM calls, external search, image/video generation), add a server-side `requireTier` gate on top of `authMiddleware`. ```typescript import { authMiddleware, requireTier } from '@mana/shared-hono'; app.use('/api/*', authMiddleware()); app.use('/api/v1/research/*', requireTier('beta')); app.use('/api/v1/picture/*', requireTier('beta')); ``` Rules: - Apply at the module-group level in `index.ts`, not inside handlers — easy to audit in one place. - `requireTier` always runs after `authMiddleware`; it relies on the `userTier` context variable the middleware sets. - Missing / unknown `tier` claims default to `public`, so a malformed JWT cannot accidentally grant `alpha`. - Pure CRUD modules that only expose a user's own records don't need a tier gate — the access check is ownership, not tier. - `DEV_BYPASS_AUTH=true` sets `userTier=founder` by default; override with `DEV_USER_TIER=` when testing rejection paths locally. ## Development Bypass For local development, you can bypass auth: ```env DEV_BYPASS_AUTH=true DEV_USER_ID=dev-user-123 DEV_USER_TIER=founder # optional — defaults to founder ``` The guard will inject a mock user: ```typescript // From JwtAuthGuard when bypass is enabled request.user = { userId: process.env.DEV_USER_ID || 'dev-user', email: 'dev@example.com', role: 'user', tier: process.env.DEV_USER_TIER || 'founder', }; ``` ## Testing with Auth ### Unit Tests ```typescript // Mock the guard const module = await Test.createTestingModule({ controllers: [FileController], providers: [FileService], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true }) .compile(); // Mock user in controller tests const mockUser = { userId: 'test-user', email: 'test@example.com', role: 'user' }; await controller.listFiles(mockUser); ``` ### E2E Tests ```typescript // Get a real token const loginResponse = await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ email: 'test@example.com', password: 'password' }); const token = loginResponse.body.accessToken; // Use token in requests await request(app.getHttpServer()) .get('/api/v1/files') .set('Authorization', `Bearer ${token}`) .expect(200); ``` ## SvelteKit Auth Routes Checklist When creating a new SvelteKit web app, **ALL** of the following auth routes MUST be implemented: ### Required Routes (in `src/routes/(auth)/`) | Route | Page | Component | Store Method | |-------|------|-----------|-------------| | `/login` | `login/+page.svelte` | `LoginPage` from `@manacore/shared-auth-ui` | `authStore.signIn()` | | `/register` | `register/+page.svelte` | `RegisterPage` from `@manacore/shared-auth-ui` | `authStore.signUp()` | | `/forgot-password` | `forgot-password/+page.svelte` | `ForgotPasswordPage` from `@manacore/shared-auth-ui` | `authStore.resetPassword()` | | `/reset-password` | `reset-password/+page.svelte` | Custom form (token from URL) | `authStore.resetPasswordWithToken()` | ### Required Auth Store Methods The `auth.svelte.ts` store MUST implement these methods using `@manacore/shared-auth`: | Method | Shared Auth Method | Purpose | |--------|-------------------|---------| | `signIn(email, password)` | `authService.signIn()` | Login | | `signUp(email, password)` | `authService.signUp()` | Register | | `signOut()` | `authService.signOut()` | Logout | | `resetPassword(email)` | `authService.forgotPassword()` | Send reset email | | `resetPasswordWithToken(token, pw)` | `authService.resetPassword()` | Reset with token | | `resendVerificationEmail(email)` | `authService.resendVerificationEmail()` | Resend verification | | `getValidToken()` | `tokenManager.getValidToken()` | Get valid JWT | | `getAuthHeaders()` | Direct localStorage read | Get `Authorization` header | ### Login Page Template ```svelte ``` ### Forgot Password Page Template ```svelte ``` ### Reset Password Page The reset password page is a **custom implementation** (not a shared component) because it handles token validation from URL params. See `apps/calendar/apps/web/src/routes/(auth)/reset-password/+page.svelte` as reference. Key requirements: - Read `token` from `$page.url.searchParams` - Validate password length (min 8 chars) and match - Call `authStore.resetPasswordWithToken(token, password)` - Show success state and redirect to `/login` after 3 seconds - Show invalid token state with link to `/forgot-password` ## Security Considerations 1. **Store tokens securely** - Web: HttpOnly cookies or memory (not localStorage) - Mobile: SecureStore (not AsyncStorage) 2. **Token refresh** - Access tokens expire in 1 hour - Use refresh tokens to get new access tokens - Handle 401 responses gracefully 3. **CORS configuration** - Only allow known origins - Include credentials for cookie-based auth 4. **Never trust client data** - Always validate token server-side - Use `@CurrentUser()` decorator, not request body ## Debugging ### Token not validating? ```bash # 1. Check algorithm (should be EdDSA) echo $TOKEN | cut -d'.' -f1 | base64 -d # 2. Check JWKS endpoint curl http://localhost:3001/api/v1/auth/jwks # 3. Check issuer/audience # Should match between signing and validation ``` ### 401 errors? 1. Check token exists in Authorization header 2. Check token format: `Bearer ` 3. Check token hasn't expired 4. Check MANA_CORE_AUTH_URL is correct --- ## Auth UX Patterns ### Email Verification Flow **Rule: Distinguish all email-related error states, never show a generic error.** | User Action | State | Expected UX | |-------------|-------|-------------| | Register with new email | `needsVerification: true` | Green "Check your email" panel + Resend button | | Register with existing **unverified** email | `EMAIL_ALREADY_REGISTERED` | Amber warning panel + Resend button + "Sign in instead" link | | Register with existing **verified** email | `EMAIL_ALREADY_REGISTERED` | Same amber panel — user should click "Sign in instead" | | Login with unverified email | `EMAIL_NOT_VERIFIED` | Inline error + Resend button (not generic "invalid credentials") | | Login with wrong password | `INVALID_CREDENTIALS` | Inline error — no resend button | **Implementation:** - Backend (`auth.ts`): catch `USER_ALREADY_EXISTS` from Better Auth → return `{ code: 'EMAIL_ALREADY_REGISTERED' }` (409) - Backend (`auth.ts`): catch `EMAIL_NOT_VERIFIED` / `status: 'FORBIDDEN'` on login → return `{ code: 'EMAIL_NOT_VERIFIED' }` (403) - `authService.ts`: map both codes to typed error strings, never swallow as generic error - `RegisterPage`: `emailAlreadyRegistered` state triggers amber panel with dual CTAs - `LoginPage`: `showEmailNotVerified` state triggers resend button below error message **Key principle:** A user who registered but never verified should always be able to request a new verification email, from *both* the login and register pages, without knowing which page to go to. ### Verification Email Redirect The verification email must redirect back to the source app, not to `auth.mana.how/`. - `sourceAppStore.set(email, sourceAppUrl)` is called before `signUpEmail` and before `sendVerificationEmail` - The `sendVerificationEmail` callback in `better-auth.config.ts` sets **`callbackURL`** (not `redirectTo`) on the verification URL - Better Auth uses `callbackURL` for the post-verification redirect; `redirectTo` is ignored - `sourceAppUrl` comes from `window.location.origin` in the browser (set by `createManaAuthStore`)