BREAKING: JWT keys are now auto-managed by Better Auth (EdDSA/Ed25519) - Remove all JWT_PRIVATE_KEY, JWT_PUBLIC_KEY, JWT_SECRET references - Keys stored in auth.jwks database table (auto-generated on first run) - Delete obsolete generate-keys.sh and generate-staging-secrets.sh scripts - Clean up legacy AUTH_*.md analysis files from root Security Improvements: - Add security_events table for audit logging - Add SecurityEventsService for tracking auth events - Enhanced security headers (HSTS, CSP, X-Frame-Options) - Rate limiting configuration Monitoring Setup: - Add auth-health-check.sh for automated testing - Add generate-dashboard.sh for HTML status dashboard - Tests: health endpoint, JWKS (EdDSA), security headers, response time - Ready for Hetzner cron deployment Documentation: - Update deployment docs with Better Auth notes - Update environment variable references - Add security improvements documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
24 KiB
Mana Core Authentication Architecture - Canonical Pattern Report
Date: 2024-12-01
Service: mana-core-auth (Central Authentication Service)
Author: Auth Architecture Analysis
Status: Source of Truth
Executive Summary
This report documents the canonical authentication architecture for the Mana Universe ecosystem. All backend services must implement auth according to these patterns. The mana-core-auth service (port 3001) is the single source of truth for JWT validation, token issuance, and user authentication.
Key Principles:
- All JWT tokens are generated and validated via mana-core-auth
- Minimal JWT claims (no dynamic data)
- EdDSA algorithm with Better Auth's JWKS
- Better Auth framework handles all auth logic (no custom implementations)
- Development bypass mode supported for testing
1. API Route Structure & Versioning
Global Prefix
/api/v1
All auth endpoints are prefixed with /api/v1/auth
Authentication Endpoints
B2C (Individual Users)
| Method | Route | Purpose | Auth Required | Response |
|---|---|---|---|---|
| POST | /auth/register |
Register new user | No | { user, token? } |
| POST | /auth/login |
Sign in with credentials | No | { user, accessToken, refreshToken, expiresIn } |
| POST | /auth/logout |
Sign out user | Yes | { success: true, message } |
| POST | /auth/refresh |
Refresh access token | No | { user, accessToken, refreshToken, expiresIn, tokenType } |
| GET | /auth/session |
Get current session | Yes | { user, session } |
| POST | /auth/validate |
Validate JWT token | No | { valid: boolean, payload?, error? } |
| GET | /auth/jwks |
Get public keys (JWKS) | No | { keys: [] } |
B2B (Organizations)
| Method | Route | Purpose | Auth Required |
|---|---|---|---|
| POST | /auth/register/b2b |
Register org with owner | No |
| GET | /auth/organizations |
List user's organizations | Yes |
| GET | /auth/organizations/:id |
Get org details | Yes |
| GET | /auth/organizations/:id/members |
List org members | Yes |
| POST | /auth/organizations/:id/invite |
Invite employee | Yes |
| POST | /auth/organizations/accept-invitation |
Accept invitation | Yes |
| DELETE | /auth/organizations/:id/members/:memberId |
Remove member | Yes |
| POST | /auth/organizations/set-active |
Switch active org | Yes |
HTTP Status Codes
- 200 OK - Successful operation
- 201 Created - Resource created (implicit in POST endpoints)
- 400 Bad Request - Invalid input validation
- 401 Unauthorized - Token missing or invalid
- 403 Forbidden - Permission denied (e.g., insufficient org role)
- 404 Not Found - Resource not found
- 409 Conflict - Email already exists
2. JWT Token Format & Structure
Token Algorithm
- Algorithm: EdDSA (Elliptic Curve Digital Signature Algorithm)
- Key Type: Ed25519 (NOT RSA, NOT HS256)
- Library:
jose(NOTjsonwebtoken) - Key Storage: Managed by Better Auth in
auth.jwkstable
Token Claims (Minimal Design)
{
"sub": "user-uuid", // Subject (user ID)
"email": "user@example.com", // Email address
"role": "user", // Role: user | admin | service
"sid": "session-uuid", // Session ID for tracking
"iat": 1733040000, // Issued at (auto)
"exp": 1733040900, // Expires in 15 minutes (auto)
"iss": "manacore", // Issuer
"aud": "manacore" // Audience
}
What NOT to Include in JWT
The following should NOT be in JWT claims (fetch via API instead):
| Data | Reason | API Endpoint |
|---|---|---|
| Organization info | Can change frequently | POST /organization/get-active-member |
| Credit balance | Changes every operation | GET /api/v1/credits/balance |
| Customer type | Derive from session.activeOrganizationId |
N/A |
| Device info | Static per session | auth.sessions.deviceId |
| Permissions | Dynamic based on role + org | Use @CurrentUser().role |
Token Expiration Times
| Token Type | Expiry | Rotation |
|---|---|---|
| Access Token (JWT) | 15 minutes | Refresh token required |
| Refresh Token | 7 days | Refresh token rotation (old revoked) |
| Session | 7 days | Extends on activity |
Token Format in Headers
Authorization: Bearer eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
Extraction Pattern:
const [type, token] = authHeader.split(' ');
const jwtToken = type === 'Bearer' ? token : undefined;
3. Validation Flow & JWKS
Token Validation Flow (For Backends)
┌─────────────┐
│ Client │
│ (JWT Token)│
└──────┬──────┘
│ GET /api/v1/auth/validate
│ { token }
▼
┌─────────────────────────┐
│ mana-core-auth │
│ (Port 3001) │
├─────────────────────────┤
│ 1. Verify signature │
│ (JWKS EdDSA keys) │
│ 2. Check issuer/audience│
│ 3. Check expiration │
└──────┬──────────────────┘
│
▼
┌──────────────────┐
│ { valid: true, │
│ payload: {...} │
│ } │
└──────────────────┘
JWKS Endpoint
GET /api/v1/auth/jwks
Response Format:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "base64url_encoded_public_key",
"kid": "key_id"
}
]
}
Validation Endpoint
POST /api/v1/auth/validate
Content-Type: application/json
{
"token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..."
}
Success Response (200 OK):
{
"valid": true,
"payload": {
"sub": "user-123",
"email": "user@example.com",
"role": "user",
"sid": "session-456",
"iat": 1733040000,
"exp": 1733040900,
"iss": "manacore",
"aud": "manacore"
}
}
Error Response (200 OK with valid=false):
{
"valid": false,
"error": "Token expired"
}
4. Authentication Guards & Decorators
Pattern 1: Shared NestJS Auth Package
Package: @manacore/shared-nestjs-auth
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
@Controller('api')
@UseGuards(JwtAuthGuard)
export class MyController {
@Get('profile')
getProfile(@CurrentUser() user: CurrentUserData) {
return {
userId: user.userId,
email: user.email,
role: user.role,
sessionId: user.sessionId
};
}
}
Environment Variables:
MANA_CORE_AUTH_URL=http://localhost:3001
NODE_ENV=development
DEV_BYPASS_AUTH=true # Optional: development only
DEV_USER_ID=test-user-uuid # Optional: custom test user
Development Bypass:
- When
NODE_ENV=developmentANDDEV_BYPASS_AUTH=true - Guard injects mock user data instead of validating token
- Default dev user ID:
00000000-0000-0000-0000-000000000000
Pattern 2: ManaCoreModule (With Credits)
Package: @mana-core/nestjs-integration
// In AppModule
import { ManaCoreModule } from '@mana-core/nestjs-integration';
@Module({
imports: [
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
appId: config.get('APP_ID'), // Required for credit tracking
serviceKey: config.get('SERVICE_KEY'), // For credit operations
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
// In Controller
import { AuthGuard } from '@mana-core/nestjs-integration';
import { CurrentUser } from '@mana-core/nestjs-integration';
import { CreditClientService } from '@mana-core/nestjs-integration';
@Controller('api')
@UseGuards(AuthGuard)
export class ApiController {
constructor(private creditClient: CreditClientService) {}
@Post('generate')
async generate(@CurrentUser() user: any) {
// Consume credits
await this.creditClient.consumeCredits(
user.sub,
'generation',
10,
'AI generation operation'
);
// ... do work
}
}
Public Routes:
import { Public } from '@mana-core/nestjs-integration';
@Controller('api')
@UseGuards(AuthGuard)
export class ApiController {
@Get('health')
@Public()
health() {
return { status: 'ok' };
}
}
CurrentUserData Interface
export interface CurrentUserData {
userId: string; // User ID from JWT sub
email: string; // Email from JWT
role: string; // Role: user | admin | service
sessionId?: string; // Session ID (sid or sessionId from JWT)
}
5. Database Schema (PostgreSQL)
Auth Schema (auth.*)
users table
CREATE TABLE auth.users (
id TEXT PRIMARY KEY, -- nanoid (Better Auth)
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
email_verified BOOLEAN DEFAULT FALSE,
image TEXT, -- Avatar URL
role user_role DEFAULT 'user', -- user | admin | service
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE -- Soft delete
);
sessions table
CREATE TABLE auth.sessions (
id TEXT PRIMARY KEY, -- nanoid (Better Auth)
user_id TEXT NOT NULL REFERENCES users(id),
token TEXT UNIQUE NOT NULL, -- Session token
refresh_token TEXT UNIQUE, -- Refresh token (rotating)
refresh_token_expires_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
device_id TEXT, -- Device identifier
device_name TEXT, -- Device name
ip_address TEXT,
user_agent TEXT,
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE, -- Soft revoke for rotation
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
accounts table
CREATE TABLE auth.accounts (
id TEXT PRIMARY KEY, -- nanoid (Better Auth)
user_id TEXT NOT NULL REFERENCES users(id),
provider_id TEXT NOT NULL, -- 'credential', 'google', etc.
account_id TEXT NOT NULL,
password TEXT, -- Hashed password (for credential)
access_token TEXT, -- OAuth access token
refresh_token TEXT, -- OAuth refresh token
id_token TEXT,
scope TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
verification table
CREATE TABLE auth.verification (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL, -- Email or other identifier
value TEXT NOT NULL, -- Verification token
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
INDEX verification_identifier_idx (identifier)
);
jwks table (Better Auth JWT Plugin)
CREATE TABLE auth.jwks (
id TEXT PRIMARY KEY,
public_key TEXT NOT NULL, -- EdDSA public key (JSON)
private_key TEXT NOT NULL, -- EdDSA private key (encrypted in production)
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
6. Environment Variables (Required for All Backends)
Mandatory Variables
# Auth Service
MANA_CORE_AUTH_URL=http://localhost:3001
# Node Environment
NODE_ENV=development
Development Mode (Optional)
# Enable auth bypass in development
DEV_BYPASS_AUTH=true
# Custom test user ID (optional, uses default UUID if not set)
DEV_USER_ID=test-user-12345
For Credit Operations (If Using ManaCoreModule)
# App identifier
APP_ID=zitare
# Service key for credit operations
MANA_CORE_SERVICE_KEY=your-service-key
JWT Configuration (Should NOT be needed - Better Auth manages this)
IMPORTANT: Do NOT set these variables. Better Auth handles JWKS via the database:
# DO NOT USE - Better Auth auto-generates EdDSA keys
JWT_PRIVATE_KEY=...
JWT_PUBLIC_KEY=...
JWT_ALGORITHM=...
7. Login Flow (End-to-End)
Step 1: User Registration (POST /api/v1/auth/register)
Request:
{
"email": "user@example.com",
"password": "securePassword123",
"name": "John Doe"
}
Response:
{
"user": {
"id": "user-abc123",
"email": "user@example.com",
"name": "John Doe"
},
"token": "eyJhbGciOiJFZERTQSI..." // Optional session token
}
Step 2: User Login (POST /api/v1/auth/login)
Request:
{
"email": "user@example.com",
"password": "securePassword123",
"deviceId": "device-uuid", // Optional: for multi-device tracking
"deviceName": "iPhone 14" // Optional: for device naming
}
Response:
{
"user": {
"id": "user-abc123",
"email": "user@example.com",
"name": "John Doe",
"role": "user"
},
"accessToken": "eyJhbGciOiJFZERTQSI...", // JWT (15 min expiry)
"refreshToken": "nanoid-64-chars...", // Session refresh token (7 day expiry)
"expiresIn": 900, // Seconds (15 min)
"tokenType": "Bearer"
}
Step 3: Request Protected Endpoint
Request:
GET /api/favorites HTTP/1.1
Authorization: Bearer eyJhbGciOiJFZERTQSI...
Backend Flow:
- Guard intercepts request
- Extracts token from
Authorization: Bearer ...header - Calls
POST http://localhost:3001/api/v1/auth/validatewith token - Receives payload with user claims
- Attaches user data to request:
request.user = { userId, email, role, sessionId } - Controller receives via
@CurrentUser() user: CurrentUserData
Step 4: Token Refresh (POST /api/v1/auth/refresh)
When access token expires (15 min), client uses refresh token:
Request:
{
"refreshToken": "nanoid-64-chars..."
}
Response:
{
"user": {
"id": "user-abc123",
"email": "user@example.com",
"name": "John Doe",
"role": "user"
},
"accessToken": "eyJhbGciOiJFZERTQSI...", // New JWT
"refreshToken": "new-nanoid-64-chars...", // New refresh token (rotation)
"expiresIn": 900,
"tokenType": "Bearer"
}
Security Note: Old refresh token is revoked (soft delete via revokedAt). Each refresh rotates the token.
8. Organization (B2B) Flow
Register Organization
POST /api/v1/auth/register/b2b
{
"ownerEmail": "owner@company.com",
"ownerName": "Jane Smith",
"password": "securePassword123",
"organizationName": "Acme Corp"
}
Response:
{
"user": { ... },
"organization": {
"id": "org-xyz789",
"name": "Acme Corp",
"slug": "acme-corp",
"logo": null,
"createdAt": "2024-12-01T10:00:00Z"
},
"token": "session-token..."
}
Invite Employee
POST /api/v1/auth/organizations/:id/invite
Authorization: Bearer {ownerJWT}
{
"employeeEmail": "employee@example.com",
"role": "member" // owner | admin | member
}
Accept Invitation
POST /api/v1/auth/organizations/accept-invitation
Authorization: Bearer {employeeJWT}
{
"invitationId": "invitation-123"
}
List User's Organizations
GET /api/v1/auth/organizations
Authorization: Bearer {userJWT}
Response:
{
"organizations": [
{
"id": "org-1",
"name": "Acme Corp",
"slug": "acme-corp",
"createdAt": "2024-12-01T10:00:00Z"
}
]
}
9. Integration Best Practices
For Backend Authors (NestJS)
1. Choose Your Integration Path
Path A: Simple Auth Only (Use @manacore/shared-nestjs-auth)
- For services that don't need credit tracking
- Lighter weight
- Example: Zitare, Picture
npm install @manacore/shared-nestjs-auth
Path B: Auth + Credits (Use @mana-core/nestjs-integration)
- For services that consume credits
- More complete
- Example: Chat, ManaDeck
npm install @mana-core/nestjs-integration
2. Setup Environment Variables
Create .env file:
NODE_ENV=development
MANA_CORE_AUTH_URL=http://localhost:3001
# Development only
DEV_BYPASS_AUTH=true
DEV_USER_ID=test-user-uuid
# If using ManaCoreModule
APP_ID=your-app-id
MANA_CORE_SERVICE_KEY=your-service-key
3. Apply Guard Globally
For Path A:
// In main.ts
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new JwtAuthGuard(app.get(ConfigService)));
For Path B:
// In main.ts
import { AuthGuard } from '@mana-core/nestjs-integration';
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new AuthGuard(/* options */));
4. Use in Controllers
import { CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
// OR
import { CurrentUser } from '@mana-core/nestjs-integration';
@Controller('api')
@UseGuards(JwtAuthGuard) // Or AuthGuard
export class ApiController {
@Get('me')
getProfile(@CurrentUser() user: CurrentUserData) {
return {
userId: user.userId,
email: user.email,
role: user.role
};
}
@Get('health')
@Public() // Skip auth guard if using ManaCoreModule
health() {
return { status: 'ok' };
}
}
5. Error Handling
All auth errors throw UnauthorizedException:
import { UnauthorizedException } from '@nestjs/common';
try {
// Guard will throw UnauthorizedException if token is invalid
} catch (error) {
if (error instanceof UnauthorizedException) {
return { error: 'Authentication failed', statusCode: 401 };
}
throw error;
}
For Client Authors (Web/Mobile)
Flow: Get Token from mana-core-auth
- Register:
POST http://localhost:3001/api/v1/auth/register - Login:
POST http://localhost:3001/api/v1/auth/login - Store tokens:
accessToken(memory),refreshToken(secure storage) - Send with requests:
Authorization: Bearer {accessToken} - Refresh when needed: Use
refreshTokento get newaccessToken
Testing Token in Browser
// Get token from login
const response = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
password: 'password123'
})
});
const { accessToken } = await response.json();
// Use in authenticated request
const data = await fetch('http://localhost:3007/api/favorites', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
10. Common Issues & Troubleshooting
Issue: "No token provided" Error
Cause: Missing or incorrectly formatted Authorization header
Solution:
// CORRECT
Authorization: Bearer eyJhbGciOiJFZERTQSI...
// WRONG - missing Bearer
Authorization: eyJhbGciOiJFZERTQSI...
// WRONG - using wrong type
Authorization: Token eyJhbGciOiJFZERTQSI...
Issue: "Invalid token" Error
Likely causes:
- Token is expired (15 min expiry)
- Token is for different issuer/audience
- Token was tampered with
Solution:
# Refresh token if expired
POST /api/v1/auth/refresh
{ "refreshToken": "..." }
# Check token claims
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq '.'
Issue: JWKS Fetch Error
Cause: mana-core-auth service not running or wrong URL
Solution:
- Ensure
MANA_CORE_AUTH_URLis correct - Check mana-core-auth is running:
curl http://localhost:3001/api/v1/auth/jwks - Verify network connectivity between services
Issue: Dev Bypass Not Working
Cause: Conditions not met for bypass
Solution: Bypass only works when ALL conditions are true:
if (NODE_ENV === 'development' && DEV_BYPASS_AUTH === 'true') {
// Bypass enabled
}
Verify:
echo $NODE_ENV # Must be 'development'
echo $DEV_BYPASS_AUTH # Must be 'true' (string)
11. Testing & Debugging
Manual Token Validation
# Get a token
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123"
}' | jq -r '.accessToken')
# Validate it
curl -X POST http://localhost:3001/api/v1/auth/validate \
-H "Content-Type: application/json" \
-d "{\"token\": \"$TOKEN\"}"
# Decode payload (inspect claims)
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq '.'
Check JWKS Keys
curl http://localhost:3001/api/v1/auth/jwks | jq '.'
Inspect Token Details
// In browser console
const token = 'eyJhbGciOiJFZERTQSI...';
const parts = token.split('.');
const payload = JSON.parse(atob(parts[1]));
console.log(payload);
12. Monitoring & Logging
Key Log Points to Watch
- Token validation: Check for repeated validation failures
- Refresh token rotation: Track revoked sessions
- JWT signature errors: Indicates key mismatch
- JWKS fetch failures: Service connectivity issues
Health Check Endpoint
curl http://localhost:3001/api/v1/auth/session \
-H "Authorization: Bearer {token}"
Returns 401 if token is invalid.
13. Security Considerations
JWT Algorithm
- EdDSA selected for better performance and security vs RSA
- Public keys stored in
auth.jwkstable - Private keys managed by Better Auth framework
Token Storage (Client-Side)
- Access Token (JWT): Memory only (lost on page refresh)
- Refresh Token: Secure HTTP-only cookie or encrypted storage
Refresh Token Rotation
- Old token revoked immediately when new one issued
- Prevents token replay attacks
- Client must use new token immediately
CORS Headers
origin: [http://localhost:3000, http://localhost:8081, ...]
credentials: true
methods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
allowedHeaders: [Content-Type, Authorization, X-Requested-With, X-App-Id]
14. Validation Checklist for New Backends
When adding a new backend service, verify:
- Using
@manacore/shared-nestjs-authOR@mana-core/nestjs-integration MANA_CORE_AUTH_URL=http://localhost:3001configured- All protected routes use
@UseGuards(JwtAuthGuard)or@UseGuards(AuthGuard) - Health/public endpoints marked with
@Public()decorator (if using ManaCoreModule) - User data injected via
@CurrentUser()decorator - Error responses return 401 for auth failures
- Development mode supports
DEV_BYPASS_AUTHfor testing - JWT tokens follow minimal claims pattern
- No custom JWT signing/verification code
- CORS configured to allow frontend domains
- Documentation updated in service's CLAUDE.md
15. References & Further Reading
Key Files in Codebase
| File | Purpose |
|---|---|
services/mana-core-auth/src/auth/auth.controller.ts |
Main auth endpoints |
services/mana-core-auth/src/auth/services/better-auth.service.ts |
Auth business logic |
services/mana-core-auth/src/auth/better-auth.config.ts |
Better Auth setup with JWT plugin |
packages/shared-nestjs-auth/src/guards/jwt-auth.guard.ts |
Guard for backends |
packages/mana-core-nestjs-integration/src/guards/auth.guard.ts |
Extended guard with credits |
services/mana-core-auth/src/db/schema/auth.schema.ts |
Database schema |
External Resources
- Better Auth Docs: https://www.better-auth.com/docs
- JWT.io: https://jwt.io (token decoder)
- EdDSA: https://en.wikipedia.org/wiki/EdDSA
Version History
| Date | Version | Changes |
|---|---|---|
| 2024-12-01 | 1.0 | Initial comprehensive report |
Report Status: APPROVED - This document serves as the source of truth for authentication architecture in Mana Universe.