managarten/.claude/guidelines/authentication.md
Till JS 6e75718cfa feat(arcade): migrate backend from NestJS to Hono/Bun
Replace @arcade/backend (NestJS) with @arcade/server (Hono/Bun).
Same two endpoints, no auth required (public game generator):
- POST /api/games/generate — AI game generation (Gemini, Claude, GPT)
- POST /api/games/submit  — Community game submission via GitHub PR
- GET  /health            — Health check

This removes the last remaining NestJS backend from the monorepo.
NestJS is now completely gone — all servers use Hono + Bun.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:02:14 +02:00

23 KiB

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:

// 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)

{
  "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:

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 });
});

Note: Arcade's server (@arcade/server) does not require auth — game generation and community submission are public endpoints.

Environment Variables

# 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)

For Docker deployments, use runtime URL injection instead of build-time environment variables:

Step 1: Server Hook (hooks.server.ts)

// 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%',
        `<script>
          window.__PUBLIC_AUTH_URL__ = "${env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'}";
          window.__PUBLIC_BACKEND_URL__ = "${env.PUBLIC_BACKEND_URL || 'http://localhost:3000'}";
        </script>`
      ),
  });
};

Step 2: Update app.html

<!DOCTYPE html>
<html lang="en">
  <head>
    %sveltekit.head%
    %RUNTIME_ENV%
  </head>
  <body>
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>

Step 3: URL Helper (url.ts)

// 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)

// 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<typeof initializeWebAuth>['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<User | null>(null);
let accessToken = $state<string | null>(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<string | null> {
  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<boolean> {
  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:

// 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<typeof initializeWebAuth>['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<User | null>(null);
let token = $state<string | null>(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<boolean> {
  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

<!-- src/routes/(protected)/+layout.svelte -->
<script lang="ts">
  import { browser } from '$app/environment';
  import { goto } from '$app/navigation';
  import { authStore } from '$lib/stores/auth.svelte';
  import { onMount } from 'svelte';

  let { children } = $props();

  onMount(() => {
    authStore.initialize();
  });

  $effect(() => {
    if (browser && !authStore.loading && !authStore.isAuthenticated) {
      goto('/login');
    }
  });
</script>

{#if authStore.loading}
  <LoadingScreen />
{:else if authStore.isAuthenticated}
  {@render children()}
{/if}

API Requests with Token

// 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<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
  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

// 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<boolean>;
  logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(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<boolean> {
    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<boolean> {
    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 (
    <AuthContext.Provider value={{ user, token, loading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

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

POST /api/v1/auth/register
{
  "email": "user@example.com",
  "password": "securepassword123",
  "name": "John Doe"
}

Response:
{
  "user": { "id": "...", "email": "...", "name": "..." },
  "accessToken": "eyJ...",
  "refreshToken": "..."
}

Login

POST /api/v1/auth/login
{
  "email": "user@example.com",
  "password": "securepassword123"
}

Response:
{
  "user": { "id": "...", "email": "...", "name": "..." },
  "accessToken": "eyJ...",
  "refreshToken": "..."
}

Validate Token

POST /api/v1/auth/validate
{
  "token": "eyJ..."
}

Response:
{
  "valid": true,
  "payload": {
    "sub": "user-id",
    "email": "user@example.com",
    "role": "user",
    "sid": "session-id"
  }
}

Development Bypass

For local development, you can bypass auth:

DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-123

The guard will inject a mock user:

// From JwtAuthGuard when bypass is enabled
request.user = {
  userId: process.env.DEV_USER_ID || 'dev-user',
  email: 'dev@example.com',
  role: 'user',
};

Testing with Auth

Unit Tests

// 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

// 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

<script lang="ts">
  import { goto } from '$app/navigation';
  import { LoginPage } from '@manacore/shared-auth-ui';
  import { getLoginTranslations } from '@manacore/shared-i18n';
  import { YourAppLogo } from '@manacore/shared-branding';
  import { authStore } from '$lib/stores/auth.svelte';

  const translations = getLoginTranslations('en');

  async function handleSignIn(email: string, password: string) {
    return authStore.signIn(email, password);
  }
</script>

<LoginPage
  appName="YourApp"
  logo={YourAppLogo}
  primaryColor="#your-color"
  onSignIn={handleSignIn}
  {goto}
  forgotPasswordPath="/forgot-password"
  registerPath="/register"
  {translations}
/>

Forgot Password Page Template

<script lang="ts">
  import { goto } from '$app/navigation';
  import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
  import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
  import { YourAppLogo } from '@manacore/shared-branding';
  import { authStore } from '$lib/stores/auth.svelte';

  const translations = getForgotPasswordTranslations('en');

  async function handleForgotPassword(email: string) {
    return authStore.resetPassword(email);
  }
</script>

<ForgotPasswordPage
  appName="YourApp"
  logo={YourAppLogo}
  primaryColor="#your-color"
  onForgotPassword={handleForgotPassword}
  {goto}
  loginPath="/login"
  {translations}
/>

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?

# 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 <token>
  3. Check token hasn't expired
  4. Check MANA_CORE_AUTH_URL is correct