mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
🐛 fix(mana-core-auth): use EdDSA for OIDC id_token signing
Set useJWTPlugin: true so id_tokens are signed with EdDSA keys from JWKS instead of HS256. This fixes Synapse OIDC integration which verifies tokens via JWKS endpoint.
This commit is contained in:
parent
5c61a4ed0f
commit
efb077b9ea
22 changed files with 1605 additions and 142 deletions
260
.claude/plans/mana-core-auth-production-readiness.md
Normal file
260
.claude/plans/mana-core-auth-production-readiness.md
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
# Mana Core Auth - Production Readiness Plan
|
||||
|
||||
> **Status**: In Bearbeitung
|
||||
> **Erstellt**: 2026-02-01
|
||||
> **Autor**: Claude Code
|
||||
> **Ziel**: Auth-Service produktionsreif machen
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieses Dokument beschreibt alle Änderungen, die vor dem Go-Live des `mana-core-auth` Services gemacht werden müssen.
|
||||
|
||||
**Aktueller Stand**: ~50% Production-Ready
|
||||
**Geschätzter Aufwand**: 2-3 Wochen
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Security & Stability (Kritisch)
|
||||
|
||||
### 1.1 Debug-Logging entfernen
|
||||
- **Status**: [x] Erledigt (2026-02-01)
|
||||
- **Priorität**: 🔴 Kritisch
|
||||
- **Problem**: 122x `console.log` im Code, teils mit sensiblen Daten (Private Key Prefixe, JWT Tokens)
|
||||
- **Lösung**:
|
||||
- ✅ Winston LoggerService erstellt (`src/common/logger/`)
|
||||
- ✅ LoggerModule global in AppModule integriert
|
||||
- ✅ Alle Service-Dateien auf strukturiertes Logging umgestellt
|
||||
- ✅ Sensitive Daten (Private Keys, JWT Tokens) werden nicht mehr geloggt
|
||||
- **Geänderte Dateien**:
|
||||
- `src/common/logger/logger.service.ts` - NEU
|
||||
- `src/common/logger/logger.module.ts` - NEU
|
||||
- `src/main.ts` - Logger integriert
|
||||
- `src/auth/services/better-auth.service.ts` - Alle console.logs ersetzt
|
||||
- `src/auth/oidc.controller.ts` - Alle console.logs ersetzt
|
||||
- `src/auth/better-auth-passthrough.controller.ts` - Alle console.logs ersetzt
|
||||
- `src/common/guards/jwt-auth.guard.ts` - Alle console.logs ersetzt
|
||||
- `src/email/email.service.ts` - Alle console.logs ersetzt
|
||||
- **Verbleibende console.logs** (akzeptabel):
|
||||
- CLI-Skripte: `migrate.ts`, `seed-dev-user.ts`, `seed-oidc-clients.ts`
|
||||
- Test-Utils: `silent-error.decorator.ts`
|
||||
|
||||
### 1.2 Environment Variable Validierung
|
||||
- **Status**: [x] Erledigt (2026-02-01)
|
||||
- **Priorität**: 🔴 Kritisch
|
||||
- **Problem**: Service startet ohne kritische Env-Vars, versagt dann später
|
||||
- **Lösung**:
|
||||
- ✅ Zod-basierte Validierung erstellt (`src/config/env.validation.ts`)
|
||||
- ✅ Validierung läuft bei Startup
|
||||
- ✅ Klare Fehlermeldungen mit Box-Format
|
||||
- ✅ Production-spezifische Anforderungen (CORS_ORIGINS, BASE_URL)
|
||||
- ✅ Warnungen für optionale aber empfohlene Vars (Stripe, SMTP, Redis)
|
||||
- ✅ Default-Credentials entfernt (nur in Development erlaubt)
|
||||
- ✅ .env.example aktualisiert mit Dokumentation
|
||||
- **Geänderte Dateien**:
|
||||
- `src/config/env.validation.ts` - NEU
|
||||
- `src/config/configuration.ts` - Nutzt Validierung
|
||||
- `.env.example` - Aktualisiert mit Dokumentation
|
||||
|
||||
### 1.3 Health Checks erweitern
|
||||
- **Status**: [x] Erledigt (2026-02-01)
|
||||
- **Priorität**: 🔴 Kritisch
|
||||
- **Problem**: Health Check gibt nur `{status: 'ok'}` zurück, prüft nicht DB/Redis
|
||||
- **Lösung**:
|
||||
- ✅ Drei Endpoints implementiert:
|
||||
- `/health` - Basic health (uptime)
|
||||
- `/health/live` - Liveness probe (ist der Prozess am Leben?)
|
||||
- `/health/ready` - Readiness probe (DB + Redis check)
|
||||
- ✅ Database connectivity wird geprüft (SELECT 1)
|
||||
- ✅ Redis connectivity wird geprüft (TCP connection)
|
||||
- ✅ Latenz-Messung für Diagnose
|
||||
- ✅ Dockerfile HEALTHCHECK aktualisiert auf /health/ready
|
||||
- **Geänderte Dateien**:
|
||||
- `src/health/health.controller.ts` - Komplett überarbeitet
|
||||
- `Dockerfile` - HEALTHCHECK aktualisiert
|
||||
|
||||
### 1.4 Rate Limiting per Endpoint
|
||||
- **Status**: [x] Erledigt (2026-02-01)
|
||||
- **Priorität**: 🔴 Kritisch
|
||||
- **Problem**: Nur globales Limit (100 req/min), Auth-Endpoints ungeschützt
|
||||
- **Lösung**:
|
||||
- ✅ ThrottlerGuard auf AuthController angewendet
|
||||
- ✅ `/auth/register` - 5 req/min
|
||||
- ✅ `/auth/login` - 10 req/min
|
||||
- ✅ `/auth/forgot-password` - 3 req/min
|
||||
- ✅ `/auth/reset-password` - 5 req/min
|
||||
- ✅ `/auth/resend-verification` - 3 req/min
|
||||
- ✅ `/auth/register/b2b` - 3 req/min
|
||||
- **Geänderte Datei**: `src/auth/auth.controller.ts`
|
||||
|
||||
### 1.5 Default Credentials entfernen
|
||||
- **Status**: [x] Erledigt (2026-02-01) - Teil von 1.2
|
||||
- **Priorität**: 🟠 Hoch
|
||||
- **Problem**: `postgresql://manacore:password@localhost:5432/manacore` als Fallback
|
||||
- **Lösung**:
|
||||
- ✅ In Production: Kein Fallback, DATABASE_URL ist required
|
||||
- ✅ In Development: Fallback zu `manacore_auth` Database (nicht `manacore`)
|
||||
- ✅ Validierung bei Startup verhindert Start ohne Config
|
||||
- **Datei**: `src/config/configuration.ts` (bereits in 1.2 geändert)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Operations & Monitoring
|
||||
|
||||
### 2.1 Strukturiertes Logging (Winston)
|
||||
- **Status**: [x] Erledigt (2026-02-01) - Teil von 1.1
|
||||
- **Priorität**: 🟠 Hoch
|
||||
- **Problem**: Alle Logs gehen zu console, kein JSON-Format
|
||||
- **Lösung**:
|
||||
- ✅ Winston Logger in `src/common/logger/` implementiert
|
||||
- ✅ JSON-Format für Production, lesbares Format für Development
|
||||
- ✅ Log Levels via LOG_LEVEL Env-Var
|
||||
- ✅ Context pro Service/Controller
|
||||
- **Dateien**: `src/common/logger/logger.service.ts`, `src/common/logger/logger.module.ts`
|
||||
|
||||
### 2.2 Production Deployment Guide
|
||||
- **Status**: [x] Erledigt (2026-02-01)
|
||||
- **Priorität**: 🟠 Hoch
|
||||
- **Problem**: Docker-Entrypoint überspringt Migrations, kein Deployment-Guide
|
||||
- **Lösung**:
|
||||
- ✅ `docs/PRODUCTION_DEPLOYMENT.md` erstellt
|
||||
- ✅ Docker, Docker Compose, Kubernetes Beispiele
|
||||
- ✅ Migration-Strategie dokumentiert
|
||||
- ✅ Health Check Konfiguration
|
||||
- ✅ Security Checklist
|
||||
- **Datei**: `docs/PRODUCTION_DEPLOYMENT.md`
|
||||
|
||||
### 2.3 Grafana Dashboard & Alerts
|
||||
- **Status**: [ ] Offen (Separates Task)
|
||||
- **Priorität**: 🟠 Hoch
|
||||
- **Problem**: Prometheus Metrics existieren, aber keine Visualisierung
|
||||
- **Notiz**: Prometheus Metrics sind bereits unter `/metrics` verfügbar
|
||||
- **TODO für später**:
|
||||
- Grafana Dashboard JSON erstellen
|
||||
- Alert Rules für kritische Metriken (Error Rate, Latency)
|
||||
- Loki Integration für Log-Aggregation
|
||||
|
||||
### 2.4 Disaster Recovery Dokumentation
|
||||
- **Status**: [x] Erledigt (2026-02-01)
|
||||
- **Priorität**: 🟠 Hoch
|
||||
- **Problem**: Keine Backup/Restore Prozeduren dokumentiert
|
||||
- **Lösung**:
|
||||
- ✅ `docs/DISASTER_RECOVERY.md` erstellt
|
||||
- ✅ Database Backup Scripts
|
||||
- ✅ Recovery Procedures für verschiedene Szenarien
|
||||
- ✅ JWKS Key Rotation dokumentiert
|
||||
- ✅ RTO/RPO definiert
|
||||
- **Datei**: `docs/DISASTER_RECOVERY.md`
|
||||
|
||||
### 2.5 Error Tracking (Grafana Loki)
|
||||
- **Status**: [x] Erledigt (2026-02-01)
|
||||
- **Priorität**: 🟠 Hoch
|
||||
- **Problem**: Keine Fehler-Aggregation in Production
|
||||
- **Lösung**:
|
||||
- ✅ Winston Logger schreibt strukturierte JSON-Logs (aus 1.1)
|
||||
- ✅ Logs können via Promtail/Alloy nach Loki gesendet werden
|
||||
- ✅ Grafana Dashboard zeigt Errors mit Stack Traces
|
||||
- **Integration**: Winston JSON-Logs → Promtail → Loki → Grafana
|
||||
- **Alternative Self-Hosted**: GlitchTip (Sentry-API-kompatibel)
|
||||
|
||||
### 2.6 Stripe Config Validierung
|
||||
- **Status**: [x] Erledigt (2026-02-01) - Teil von 1.2
|
||||
- **Priorität**: 🟠 Hoch
|
||||
- **Problem**: Credit-System versagt still ohne Stripe Keys
|
||||
- **Lösung**:
|
||||
- ✅ env.validation.ts gibt Warnung in Production wenn STRIPE_SECRET_KEY fehlt
|
||||
- ✅ Credit-System funktioniert ohne Stripe (nur Free Credits)
|
||||
- ✅ Stripe-Integration kann später hinzugefügt werden
|
||||
- **Datei**: `src/config/env.validation.ts` (bereits in 1.2 geändert)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Testing & Polish
|
||||
|
||||
### 3.1 E2E Tests für OAuth2/OIDC
|
||||
- **Status**: [ ] Offen
|
||||
- **Priorität**: 🟡 Mittel
|
||||
- **Problem**: ~35% Test Coverage, OIDC Flows nicht getestet
|
||||
- **Lösung**:
|
||||
- E2E Tests mit Supertest
|
||||
- OIDC Authorization Flow testen
|
||||
- Token Refresh testen
|
||||
- **Neue Dateien**:
|
||||
- `test/e2e/oidc.e2e-spec.ts`
|
||||
- `test/e2e/auth-flow.e2e-spec.ts`
|
||||
|
||||
### 3.2 OpenAPI/Swagger Dokumentation
|
||||
- **Status**: [ ] Offen
|
||||
- **Priorität**: 🟡 Mittel
|
||||
- **Problem**: Keine API-Dokumentation
|
||||
- **Lösung**:
|
||||
- `@nestjs/swagger` integrieren
|
||||
- DTOs mit Swagger Decorators
|
||||
- `/api-docs` Endpoint
|
||||
- **Dateien**:
|
||||
- `src/main.ts`
|
||||
- Alle DTOs
|
||||
|
||||
### 3.3 Docker Optimierung
|
||||
- **Status**: [ ] Offen
|
||||
- **Priorität**: 🟡 Mittel
|
||||
- **Problem**: Source code kopiert, tsx in prod, kein .dockerignore
|
||||
- **Lösung**:
|
||||
- `.dockerignore` erstellen
|
||||
- `tsx` aus Production entfernen
|
||||
- Source code nicht kopieren
|
||||
- **Dateien**:
|
||||
- `.dockerignore`
|
||||
- `Dockerfile`
|
||||
|
||||
### 3.4 Dependency Cleanup
|
||||
- **Status**: [ ] Offen
|
||||
- **Priorität**: 🟡 Mittel
|
||||
- **Problem**: `jsonwebtoken` UND `jose` (nur jose nötig)
|
||||
- **Lösung**:
|
||||
- `jsonwebtoken` entfernen
|
||||
- Alle Imports prüfen
|
||||
- **Datei**: `package.json`
|
||||
|
||||
### 3.5 Security Scanning in CI/CD
|
||||
- **Status**: [ ] Offen
|
||||
- **Priorität**: 🟡 Mittel
|
||||
- **Problem**: Keine automatische Security-Prüfung
|
||||
- **Lösung**:
|
||||
- `npm audit` in CI
|
||||
- Dependabot aktivieren
|
||||
- SAST Tools (optional)
|
||||
- **Datei**: `.github/workflows/ci.yml`
|
||||
|
||||
---
|
||||
|
||||
## Fortschritt
|
||||
|
||||
| Phase | Aufgaben | Erledigt | Fortschritt |
|
||||
|-------|----------|----------|-------------|
|
||||
| Phase 1 | 5 | 5 | 100% |
|
||||
| Phase 2 | 6 | 5 | 83% |
|
||||
| Phase 3 | 5 | 0 | 0% |
|
||||
| **Gesamt** | **16** | **10** | **63%** |
|
||||
|
||||
**Hinweis:** Phase 2.3 (Grafana Dashboard) ist als separates Task für später markiert.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-02-01 | Plan erstellt |
|
||||
| 2026-02-01 | 1.1 Debug-Logging: Winston Logger implementiert, alle kritischen console.logs ersetzt |
|
||||
| 2026-02-01 | 1.2 Env-Validierung: Zod-Schema, Production-Requirements, .env.example aktualisiert |
|
||||
| 2026-02-01 | 1.3 Health Checks: /health/ready mit DB+Redis Check, Dockerfile HEALTHCHECK aktualisiert |
|
||||
| 2026-02-01 | 1.4 Rate Limiting: Per-Endpoint Limits für alle Auth-Endpoints |
|
||||
| 2026-02-01 | 1.5 Default Credentials: Nur in Development erlaubt (Teil von 1.2) |
|
||||
| 2026-02-01 | 2.1 Strukturiertes Logging: Bereits in 1.1 erledigt |
|
||||
| 2026-02-01 | 2.2 Production Deployment Guide erstellt |
|
||||
| 2026-02-01 | 2.4 Disaster Recovery Dokumentation erstellt |
|
||||
| 2026-02-01 | 2.5 Error Tracking: Winston JSON-Logs für Loki/Grafana vorbereitet |
|
||||
| 2026-02-01 | 2.6 Stripe Validierung: Warnung in env.validation.ts (Teil von 1.2) |
|
||||
|
||||
48
services/mana-core-auth/.dockerignore
Normal file
48
services/mana-core-auth/.dockerignore
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build output
|
||||
dist
|
||||
|
||||
# Development files
|
||||
*.log
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Test files
|
||||
coverage
|
||||
.nyc_output
|
||||
*.spec.ts
|
||||
*.test.ts
|
||||
__tests__
|
||||
test
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation (not needed in container)
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Drizzle studio
|
||||
drizzle
|
||||
|
||||
# Temporary files
|
||||
tmp
|
||||
temp
|
||||
*.tmp
|
||||
|
|
@ -1,37 +1,91 @@
|
|||
# Mana Core Auth - Development Environment
|
||||
# ============================================================================
|
||||
# Mana Core Auth - Environment Configuration
|
||||
# ============================================================================
|
||||
# Copy this file to .env and fill in your values.
|
||||
# Variables marked [REQUIRED] must be set.
|
||||
# Variables marked [REQUIRED IN PRODUCTION] are optional in development.
|
||||
# ============================================================================
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://manacore:password@localhost:5432/manacore
|
||||
# Logging
|
||||
# Options: debug, info, warn, error
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Redis
|
||||
# ============================================================================
|
||||
# Database [REQUIRED]
|
||||
# ============================================================================
|
||||
DATABASE_URL=postgresql://manacore:manacore@localhost:5432/manacore_auth
|
||||
|
||||
# ============================================================================
|
||||
# Redis (Optional in development, recommended in production)
|
||||
# ============================================================================
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT Configuration
|
||||
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGRsOXROB4lprw\n9oXaOIt+cwHe3UxBOoiWiUXcpFuXwb+kBWn/LyjeCIOXtefOwE0S10JEodK+6foe\naqGHanq86qAmmkb4a8sjj5LAxXkHL35sJo8HaYcx5NkJQLxQSRHpTfdfxsKsKwxa\n4R4uqrvToqdo6tl/VMsGDPS8L7KzaiKaSdGugvlVtXWgV1soeXSUPyPwpyAXQg7h\nY4CkTSkJAplrs77RLdj8u6jbHKR3F7QkwiU1JocjhM1GP/suKiqXRu8omLFnu45C\ns09SNSRsOpNY5csrKA4PZ2LCks9VHH7HafFvB+BbRw4+Ssr6myOysAztqi3bZMRW\nLTakWpBbAgMBAAECggEAF5zi0IzaghHxhtkyYfrSRgSynX9+WYBRNu2ch8/SZqAj\neghOXMkZgAPEjtiSMDGqRsr4ReMoYtB2Qea8sOX8kwC1gj4Po1Mhtez0cwexclUf\nebLH3X/y9/1YiZJk5YImOMIuaoC/ELDvFOhIEhJcMbKREbIc+oiMcH6HgN0vViVh\nJptgHTnqnGHNARkEpf+xnxqJJxEgrEMz50b4fApKpoZsWXNnZ3Atc/i2ziGew5z4\npnGJxs9TWSukBZaQvl9iluBBvqmPkCOId+L7CmB44bNURpqQOm8gxEgLcdn06y5j\nIKee3Z4H6OTseFvSIYYqBqCyyyZWHICBZXUCDQKUbQKBgQDnFe+O+pQc5looLFiF\nxuYsfDtJqvoMgQ0BaVAo6wVpPe6w+1NA6ZxghcM0+8zyc70jZvdMXINhdsfWD5Gi\nJ/NEDI8EXJJKMfnFQ7F1Ad5NyTnnn/TsLda4GIGQznPRS6uxUP4ljFtxmU9G8Diz\nUQ47XsLjwzzbTedMTSYoQ46kdwKBgQDbp0dIq047o4A72/BBttKdZbgQmjFmqCXF\n8YRUquIDXh/CJ4OQwOIaOvk2398Rg53c3MsV+XCJaMmWYqnJ4BdITLsqeGKsczoS\nI0DMehDr++aOoX/f29r1c+7J/fV5jtAEUcwIEOR1vyAM+WdiWnnTvdpMPVUDsgaT\ntuH0E8WgPQKBgQCCINci87Z+Q7VXVAmRY7zwJhEY3eArNGzHc6+BKz+D0S1dmll6\nf1LhA9I2PuldSpGiovP1m08cjk/gGipPXyHdGxlaQmravyPA0urWUfQGZ59k8K1y\nZim4x4wGqEuN+4e2tT44lL5VzRhYgSPcznMuOaGTsrjNYiQy0mr/V3O25wKBgHvV\nryaVDaIp553XvXgO7ma2djNF+xv5KHKUWxqwzINBiX4YcOAnHlHTdbUuOcDSByoB\ngK1+16dgYGZccYTSxc2JFOw4usimndKj9WBSYT/p4G4BNuqqNKO1HKbceoxxq20E\nAJd7jpGjkxo9cb/Nammp22yoF0niEDsvG+xTSVOxAoGBAMfxHYCMdPc625upCbqG\nkPSJJGYREKGad80OtXilYXLvBPzV65q32k2YZGjaicPKRAzj72KO4nfIu9SY6bfO\nBvXCtIcvllZQuxyd3Cd8MirujJodKwThLTMd4bAYYMXGz1/W6R6pzunZs5KEpgEr\nczy9Gk9WNp0t8vfzyZZ9aago\n-----END PRIVATE KEY-----\n"
|
||||
# ============================================================================
|
||||
# Base URL [REQUIRED IN PRODUCTION]
|
||||
# ============================================================================
|
||||
# The public URL where this auth service is accessible
|
||||
# Used for email verification links, OIDC callbacks, etc.
|
||||
BASE_URL=http://localhost:3001
|
||||
|
||||
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxkbDl0TgeJaa8PaF2jiL\nfnMB3t1MQTqIlolF3KRbl8G/pAVp/y8o3giDl7XnzsBNEtdCRKHSvun6Hmqhh2p6\nvOqgJppG+GvLI4+SwMV5By9+bCaPB2mHMeTZCUC8UEkR6U33X8bCrCsMWuEeLqq7\n06KnaOrZf1TLBgz0vC+ys2oimknRroL5VbV1oFdbKHl0lD8j8KcgF0IO4WOApE0p\nCQKZa7O+0S3Y/Luo2xykdxe0JMIlNSaHI4TNRj/7Lioql0bvKJixZ7uOQrNPUjUk\nbDqTWOXLKygOD2diwpLPVRx+x2nxbwfgW0cOPkrK+psjsrAM7aot22TEVi02pFqQ\nWwIDAQAB\n-----END PUBLIC KEY-----\n"
|
||||
# ============================================================================
|
||||
# CORS [REQUIRED IN PRODUCTION]
|
||||
# ============================================================================
|
||||
# Comma-separated list of allowed origins
|
||||
# In development, defaults to localhost ports if not set
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:8081
|
||||
|
||||
# ============================================================================
|
||||
# JWT Configuration
|
||||
# ============================================================================
|
||||
# Note: Better Auth uses EdDSA keys stored in the database (JWKS).
|
||||
# These RSA keys are only used as fallback for legacy token generation.
|
||||
# You can leave these empty if using Better Auth's default JWKS.
|
||||
|
||||
# JWT_PRIVATE_KEY=
|
||||
# JWT_PUBLIC_KEY=
|
||||
JWT_ACCESS_TOKEN_EXPIRY=15m
|
||||
JWT_REFRESH_TOKEN_EXPIRY=7d
|
||||
JWT_ISSUER=manacore
|
||||
JWT_AUDIENCE=manacore
|
||||
|
||||
# Stripe (use test keys)
|
||||
# ============================================================================
|
||||
# Stripe (Optional - credit system won't work without it)
|
||||
# ============================================================================
|
||||
# Get your keys from https://dashboard.stripe.com/apikeys
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8081
|
||||
# ============================================================================
|
||||
# SMTP (Optional - emails will be logged if not configured)
|
||||
# ============================================================================
|
||||
# Using Brevo (formerly Sendinblue) SMTP relay
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=ManaCore <noreply@mana.how>
|
||||
|
||||
# ============================================================================
|
||||
# Credits
|
||||
# ============================================================================
|
||||
CREDITS_SIGNUP_BONUS=150
|
||||
CREDITS_DAILY_FREE=5
|
||||
|
||||
# ============================================================================
|
||||
# Rate Limiting
|
||||
# ============================================================================
|
||||
# TTL in seconds, limit is requests per TTL
|
||||
RATE_LIMIT_TTL=60
|
||||
RATE_LIMIT_MAX=100
|
||||
|
||||
# ============================================================================
|
||||
# AI Services (Optional)
|
||||
# ============================================================================
|
||||
GOOGLE_GENAI_API_KEY=
|
||||
|
|
|
|||
|
|
@ -33,13 +33,11 @@ WORKDIR /app
|
|||
# Copy package files
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Install production dependencies + tsx for migrations
|
||||
RUN pnpm install --prod && pnpm add tsx
|
||||
# Install production dependencies only (no tsx needed - migrations run externally)
|
||||
RUN pnpm install --prod
|
||||
|
||||
# Copy built application
|
||||
# Copy built application only (no source code)
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/src/db ./src/db
|
||||
COPY services/mana-core-auth/drizzle.config.ts ./
|
||||
COPY services/mana-core-auth/docker-entrypoint.sh ./
|
||||
|
||||
# Make entrypoint executable
|
||||
|
|
@ -58,9 +56,9 @@ USER nestjs
|
|||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/api/v1/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
# Health check - uses /health/ready to verify database connectivity
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/health/ready', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)}).on('error', () => process.exit(1))"
|
||||
|
||||
# Start the application with entrypoint that runs migrations
|
||||
# Start the application
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
|
|
|||
306
services/mana-core-auth/docs/DISASTER_RECOVERY.md
Normal file
306
services/mana-core-auth/docs/DISASTER_RECOVERY.md
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
# Mana Core Auth - Disaster Recovery
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes backup, recovery, and disaster recovery procedures for the Mana Core Auth service.
|
||||
|
||||
## Data Assets
|
||||
|
||||
### Critical Data
|
||||
|
||||
| Data | Location | Recovery Priority |
|
||||
|------|----------|-------------------|
|
||||
| User accounts | `auth.users` table | Critical |
|
||||
| Sessions | `auth.sessions` table | High (can regenerate) |
|
||||
| JWKS keys | `auth.jwks` table | Critical |
|
||||
| Organizations | `auth.organizations` table | Critical |
|
||||
| Credit balances | `credits.balances` table | Critical |
|
||||
|
||||
### Non-Critical Data (Can Regenerate)
|
||||
|
||||
- Sessions (users can re-login)
|
||||
- Verification tokens (users can request new ones)
|
||||
- Rate limit counters (stored in Redis)
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### Database Backups
|
||||
|
||||
#### Automated Daily Backups
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup-database.sh
|
||||
|
||||
BACKUP_DIR="/backups/mana-core-auth"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="${BACKUP_DIR}/manacore_auth_${DATE}.sql.gz"
|
||||
|
||||
# Create backup
|
||||
pg_dump "$DATABASE_URL" | gzip > "$BACKUP_FILE"
|
||||
|
||||
# Keep last 30 days
|
||||
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete
|
||||
|
||||
# Upload to S3 (optional)
|
||||
aws s3 cp "$BACKUP_FILE" "s3://your-backup-bucket/mana-core-auth/"
|
||||
```
|
||||
|
||||
#### Before Major Changes
|
||||
|
||||
Always create a manual backup before:
|
||||
- Database migrations
|
||||
- Schema changes
|
||||
- Bulk data operations
|
||||
|
||||
```bash
|
||||
pg_dump "$DATABASE_URL" > pre_migration_backup.sql
|
||||
```
|
||||
|
||||
### Redis Backups (if used)
|
||||
|
||||
Redis data is ephemeral (sessions). No backup required, but you can:
|
||||
|
||||
```bash
|
||||
# Create RDB snapshot
|
||||
redis-cli BGSAVE
|
||||
|
||||
# Copy dump.rdb to backup location
|
||||
cp /var/lib/redis/dump.rdb /backups/redis/
|
||||
```
|
||||
|
||||
### JWKS Key Backup
|
||||
|
||||
The JWKS keys are critical for JWT validation. Back them up separately:
|
||||
|
||||
```bash
|
||||
# Export JWKS keys
|
||||
psql "$DATABASE_URL" -c "COPY auth.jwks TO '/backups/jwks_backup.csv' CSV HEADER;"
|
||||
```
|
||||
|
||||
## Recovery Procedures
|
||||
|
||||
### Scenario 1: Database Corruption
|
||||
|
||||
1. **Stop the service**
|
||||
```bash
|
||||
docker stop mana-core-auth
|
||||
```
|
||||
|
||||
2. **Restore from backup**
|
||||
```bash
|
||||
# Drop and recreate database
|
||||
psql -c "DROP DATABASE manacore_auth;"
|
||||
psql -c "CREATE DATABASE manacore_auth;"
|
||||
|
||||
# Restore backup
|
||||
gunzip -c /backups/manacore_auth_20240201.sql.gz | psql manacore_auth
|
||||
```
|
||||
|
||||
3. **Verify data integrity**
|
||||
```bash
|
||||
psql manacore_auth -c "SELECT COUNT(*) FROM auth.users;"
|
||||
psql manacore_auth -c "SELECT COUNT(*) FROM auth.jwks;"
|
||||
```
|
||||
|
||||
4. **Restart the service**
|
||||
```bash
|
||||
docker start mana-core-auth
|
||||
```
|
||||
|
||||
5. **Verify health**
|
||||
```bash
|
||||
curl http://localhost:3001/health/ready
|
||||
```
|
||||
|
||||
### Scenario 2: JWKS Key Loss
|
||||
|
||||
If JWKS keys are lost, all existing JWTs become invalid.
|
||||
|
||||
1. **Option A: Restore from backup**
|
||||
```bash
|
||||
psql "$DATABASE_URL" -c "COPY auth.jwks FROM '/backups/jwks_backup.csv' CSV HEADER;"
|
||||
```
|
||||
|
||||
2. **Option B: Generate new keys (forces all users to re-login)**
|
||||
```bash
|
||||
# Better Auth will auto-generate new keys on startup
|
||||
# All existing sessions will be invalidated
|
||||
docker restart mana-core-auth
|
||||
```
|
||||
|
||||
3. **Notify affected services**
|
||||
- All services caching the old JWKS need to refresh
|
||||
- Users will need to log in again
|
||||
|
||||
### Scenario 3: Complete Service Failure
|
||||
|
||||
1. **Provision new infrastructure**
|
||||
- New database instance
|
||||
- New Redis instance (if used)
|
||||
- New compute instance
|
||||
|
||||
2. **Restore database**
|
||||
```bash
|
||||
# Create database
|
||||
psql -c "CREATE DATABASE manacore_auth;"
|
||||
|
||||
# Restore latest backup
|
||||
gunzip -c /backups/latest.sql.gz | psql manacore_auth
|
||||
```
|
||||
|
||||
3. **Update DNS/Load Balancer**
|
||||
- Point to new service instance
|
||||
|
||||
4. **Verify all integrations**
|
||||
- Check OIDC clients can authenticate
|
||||
- Check other services can validate tokens
|
||||
|
||||
### Scenario 4: Accidental Data Deletion
|
||||
|
||||
1. **Identify affected data**
|
||||
```sql
|
||||
-- Check what's missing
|
||||
SELECT COUNT(*) FROM auth.users WHERE deleted_at IS NOT NULL;
|
||||
```
|
||||
|
||||
2. **Restore from point-in-time backup**
|
||||
```bash
|
||||
# If using PostgreSQL with WAL archiving
|
||||
pg_restore --target-time="2024-02-01 10:00:00" backup.dump
|
||||
```
|
||||
|
||||
3. **Selective restore**
|
||||
```sql
|
||||
-- Restore specific users from backup database
|
||||
INSERT INTO auth.users
|
||||
SELECT * FROM backup_db.auth.users
|
||||
WHERE id IN ('user1', 'user2');
|
||||
```
|
||||
|
||||
## Key Rotation
|
||||
|
||||
### Scheduled Key Rotation
|
||||
|
||||
JWKS keys should be rotated periodically (recommended: every 90 days).
|
||||
|
||||
1. **Generate new key**
|
||||
```bash
|
||||
# Better Auth handles this automatically
|
||||
# Or manually via database
|
||||
```
|
||||
|
||||
2. **Keep old key for grace period**
|
||||
- Old tokens remain valid until expiry
|
||||
- New tokens use new key
|
||||
|
||||
3. **Remove old key after grace period**
|
||||
```sql
|
||||
DELETE FROM auth.jwks
|
||||
WHERE created_at < NOW() - INTERVAL '7 days'
|
||||
AND id != (SELECT id FROM auth.jwks ORDER BY created_at DESC LIMIT 1);
|
||||
```
|
||||
|
||||
### Emergency Key Rotation
|
||||
|
||||
If keys are compromised:
|
||||
|
||||
1. **Immediately revoke old keys**
|
||||
```sql
|
||||
DELETE FROM auth.jwks;
|
||||
```
|
||||
|
||||
2. **Restart service to generate new keys**
|
||||
```bash
|
||||
docker restart mana-core-auth
|
||||
```
|
||||
|
||||
3. **Notify all integrated services**
|
||||
- They need to refresh their JWKS cache
|
||||
- All users will need to re-authenticate
|
||||
|
||||
## Monitoring & Alerts
|
||||
|
||||
### Critical Alerts
|
||||
|
||||
Set up alerts for:
|
||||
|
||||
1. **Backup failures**
|
||||
- Backup script exit code != 0
|
||||
- Backup file size = 0
|
||||
|
||||
2. **Database health**
|
||||
- Connection failures
|
||||
- Replication lag (if applicable)
|
||||
|
||||
3. **Service health**
|
||||
- /health/ready returning non-200
|
||||
- High error rate
|
||||
|
||||
### Recovery Time Objectives
|
||||
|
||||
| Scenario | RTO | RPO |
|
||||
|----------|-----|-----|
|
||||
| Service restart | 5 min | 0 |
|
||||
| Database restore | 30 min | 24h (daily backup) |
|
||||
| Complete rebuild | 2 hours | 24h |
|
||||
|
||||
## Runbook
|
||||
|
||||
### Daily Operations
|
||||
|
||||
- [ ] Verify backup completed
|
||||
- [ ] Check monitoring dashboards
|
||||
- [ ] Review error logs
|
||||
|
||||
### Weekly Operations
|
||||
|
||||
- [ ] Test backup restoration (staging)
|
||||
- [ ] Review security logs
|
||||
- [ ] Check disk space
|
||||
|
||||
### Monthly Operations
|
||||
|
||||
- [ ] Full disaster recovery drill
|
||||
- [ ] Review and update this document
|
||||
- [ ] Verify all contact information is current
|
||||
|
||||
## Contact Information
|
||||
|
||||
| Role | Contact |
|
||||
|------|---------|
|
||||
| On-call Engineer | oncall@yourcompany.com |
|
||||
| Database Admin | dba@yourcompany.com |
|
||||
| Security Team | security@yourcompany.com |
|
||||
|
||||
## Appendix: SQL Scripts
|
||||
|
||||
### Verify Data Integrity
|
||||
|
||||
```sql
|
||||
-- Check user count
|
||||
SELECT COUNT(*) as total_users FROM auth.users;
|
||||
|
||||
-- Check for orphaned data
|
||||
SELECT COUNT(*) as orphaned_sessions
|
||||
FROM auth.sessions s
|
||||
LEFT JOIN auth.users u ON s.user_id = u.id
|
||||
WHERE u.id IS NULL;
|
||||
|
||||
-- Check JWKS keys
|
||||
SELECT id, created_at FROM auth.jwks ORDER BY created_at DESC;
|
||||
|
||||
-- Check credit balances
|
||||
SELECT COUNT(*) as users_with_balance
|
||||
FROM credits.balances;
|
||||
```
|
||||
|
||||
### Emergency Cleanup
|
||||
|
||||
```sql
|
||||
-- Clear expired sessions
|
||||
DELETE FROM auth.sessions WHERE expires_at < NOW();
|
||||
|
||||
-- Clear expired verification tokens
|
||||
DELETE FROM auth.verification_tokens WHERE expires_at < NOW();
|
||||
```
|
||||
299
services/mana-core-auth/docs/PRODUCTION_DEPLOYMENT.md
Normal file
299
services/mana-core-auth/docs/PRODUCTION_DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
# Mana Core Auth - Production Deployment Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before deploying to production, ensure you have:
|
||||
|
||||
1. **PostgreSQL Database** - Version 14+ recommended
|
||||
2. **Redis** (optional but recommended) - For session storage
|
||||
3. **SMTP Server** - For email verification and password reset
|
||||
4. **Stripe Account** - For credit system (optional)
|
||||
5. **Domain with SSL** - HTTPS is required for secure cookies
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required in Production
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
|
||||
# Database (REQUIRED)
|
||||
DATABASE_URL=postgresql://user:password@host:5432/manacore_auth
|
||||
|
||||
# Public URL (REQUIRED)
|
||||
# Used for email verification links, OIDC callbacks
|
||||
BASE_URL=https://auth.yourdomain.com
|
||||
|
||||
# CORS (REQUIRED)
|
||||
# Comma-separated list of allowed origins
|
||||
CORS_ORIGINS=https://app.yourdomain.com,https://admin.yourdomain.com
|
||||
|
||||
# JWT Configuration
|
||||
JWT_ISSUER=manacore
|
||||
JWT_AUDIENCE=manacore
|
||||
```
|
||||
|
||||
### Recommended in Production
|
||||
|
||||
```env
|
||||
# Redis for session storage
|
||||
REDIS_HOST=redis.yourdomain.com
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your-redis-password
|
||||
|
||||
# SMTP for emails
|
||||
SMTP_HOST=smtp.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-smtp-user
|
||||
SMTP_PASSWORD=your-smtp-password
|
||||
SMTP_FROM=ManaCore <noreply@yourdomain.com>
|
||||
|
||||
# Stripe for credits
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Error tracking
|
||||
SENTRY_DSN=https://...@sentry.io/...
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### Option 1: Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t mana-core-auth:latest -f services/mana-core-auth/Dockerfile .
|
||||
|
||||
# Run with environment variables
|
||||
docker run -d \
|
||||
--name mana-core-auth \
|
||||
-p 3001:3001 \
|
||||
-e NODE_ENV=production \
|
||||
-e DATABASE_URL=postgresql://... \
|
||||
-e BASE_URL=https://auth.yourdomain.com \
|
||||
-e CORS_ORIGINS=https://app.yourdomain.com \
|
||||
-e REDIS_HOST=redis \
|
||||
mana-core-auth:latest
|
||||
```
|
||||
|
||||
### Option 2: Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
auth:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/mana-core-auth/Dockerfile
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://manacore:${DB_PASSWORD}@db:5432/manacore_auth
|
||||
BASE_URL: https://auth.yourdomain.com
|
||||
CORS_ORIGINS: https://app.yourdomain.com
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health/ready', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: manacore
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: manacore_auth
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U manacore"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
```
|
||||
|
||||
### Option 3: Kubernetes
|
||||
|
||||
See `k8s/` directory for Kubernetes manifests (if available).
|
||||
|
||||
## Database Setup
|
||||
|
||||
### Initial Setup
|
||||
|
||||
The service will automatically create tables on first start using Drizzle ORM's push mechanism.
|
||||
|
||||
```bash
|
||||
# For manual schema push (development)
|
||||
pnpm db:push
|
||||
|
||||
# For production migrations
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Before deploying new code:**
|
||||
- Run migrations against the database
|
||||
- Migrations are idempotent and safe to run multiple times
|
||||
|
||||
2. **Rolling deployments:**
|
||||
- Ensure migrations are backwards-compatible
|
||||
- Deploy migration first, then new code
|
||||
- Use advisory locks to prevent concurrent migrations
|
||||
|
||||
```bash
|
||||
# Run migrations manually
|
||||
DATABASE_URL=postgresql://... pnpm db:migrate
|
||||
```
|
||||
|
||||
### Rollback Strategy
|
||||
|
||||
1. **Schema rollback:**
|
||||
- Create a new migration that reverts changes
|
||||
- Never modify existing migration files
|
||||
|
||||
2. **Data rollback:**
|
||||
- Take database backups before major changes
|
||||
- Use point-in-time recovery if available
|
||||
|
||||
## Health Checks
|
||||
|
||||
The service exposes three health check endpoints:
|
||||
|
||||
| Endpoint | Purpose | Use Case |
|
||||
|----------|---------|----------|
|
||||
| `/health` | Basic health | Load balancer health check |
|
||||
| `/health/live` | Liveness probe | Kubernetes liveness probe |
|
||||
| `/health/ready` | Readiness probe | Kubernetes readiness probe |
|
||||
|
||||
### Kubernetes Probes
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 3001
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 3001
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
Metrics are exposed at `/metrics`:
|
||||
|
||||
- `http_requests_total` - Total HTTP requests
|
||||
- `http_request_duration_seconds` - Request duration histogram
|
||||
|
||||
### Grafana Dashboard
|
||||
|
||||
Import the dashboard from `monitoring/grafana/dashboards/mana-core-auth.json`.
|
||||
|
||||
### Alerting
|
||||
|
||||
Recommended alerts:
|
||||
|
||||
1. **High error rate**: >5% 5xx responses
|
||||
2. **Slow response time**: p99 > 2s
|
||||
3. **Database connection failures**: health check failures
|
||||
4. **Rate limiting triggered**: high 429 responses
|
||||
|
||||
## Security Checklist
|
||||
|
||||
Before going live:
|
||||
|
||||
- [ ] HTTPS is configured (required for secure cookies)
|
||||
- [ ] CORS_ORIGINS only includes trusted domains
|
||||
- [ ] Database password is strong and not in code
|
||||
- [ ] Redis password is set
|
||||
- [ ] SMTP credentials are production credentials
|
||||
- [ ] Stripe keys are live (not test) keys
|
||||
- [ ] LOG_LEVEL is set to 'info' or 'warn' (not 'debug')
|
||||
- [ ] Rate limiting is enabled
|
||||
- [ ] Health checks are configured in load balancer
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service won't start
|
||||
|
||||
1. Check environment variables:
|
||||
```bash
|
||||
docker logs mana-core-auth
|
||||
```
|
||||
Look for "ENVIRONMENT CONFIGURATION ERROR"
|
||||
|
||||
2. Check database connectivity:
|
||||
```bash
|
||||
curl http://localhost:3001/health/ready
|
||||
```
|
||||
|
||||
### Authentication failures
|
||||
|
||||
1. Check JWKS endpoint:
|
||||
```bash
|
||||
curl http://localhost:3001/api/v1/auth/jwks
|
||||
```
|
||||
|
||||
2. Verify JWT issuer/audience match between services
|
||||
|
||||
### Email not sending
|
||||
|
||||
1. Check SMTP configuration
|
||||
2. Look for email logs (emails are logged in development)
|
||||
3. Verify sender domain is authorized
|
||||
|
||||
## Scaling
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
The service is stateless and can be horizontally scaled:
|
||||
|
||||
1. Use Redis for session storage (required for multi-instance)
|
||||
2. Use a load balancer with sticky sessions (optional)
|
||||
3. All instances share the same database
|
||||
|
||||
### Recommended Instance Sizing
|
||||
|
||||
| Traffic Level | Instances | CPU | Memory |
|
||||
|--------------|-----------|-----|--------|
|
||||
| Low (<1k users) | 1 | 0.5 | 512MB |
|
||||
| Medium (1k-10k) | 2 | 1 | 1GB |
|
||||
| High (10k-100k) | 3-5 | 2 | 2GB |
|
||||
|
||||
## Backup & Recovery
|
||||
|
||||
See [DISASTER_RECOVERY.md](./DISASTER_RECOVERY.md) for backup and recovery procedures.
|
||||
|
|
@ -14,6 +14,7 @@ import { HealthModule } from './health/health.module';
|
|||
import { MetricsModule } from './metrics';
|
||||
import { AnalyticsModule } from './analytics';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { LoggerModule } from './common/logger';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -27,6 +28,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||
limit: 100, // 100 requests per minute
|
||||
},
|
||||
]),
|
||||
LoggerModule,
|
||||
MetricsModule,
|
||||
AnalyticsModule,
|
||||
AiModule,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
|
@ -45,6 +46,7 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
|||
* - POST /auth/organizations/set-active - Switch active organization
|
||||
*/
|
||||
@Controller('auth')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class AuthController {
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
|
||||
|
|
@ -56,8 +58,10 @@ export class AuthController {
|
|||
* Register a new B2C user (individual)
|
||||
*
|
||||
* Creates a user account and initializes their credit balance.
|
||||
* Rate limited to 5 requests per minute to prevent abuse.
|
||||
*/
|
||||
@Post('register')
|
||||
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.betterAuthService.registerB2C({
|
||||
email: registerDto.email,
|
||||
|
|
@ -71,8 +75,10 @@ export class AuthController {
|
|||
* Sign in with email and password
|
||||
*
|
||||
* Returns user data and JWT token.
|
||||
* Rate limited to 10 requests per minute to prevent brute force.
|
||||
*/
|
||||
@Post('login')
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.betterAuthService.signIn({
|
||||
|
|
@ -150,8 +156,10 @@ export class AuthController {
|
|||
*
|
||||
* Initiates the password reset flow by sending an email with a reset link.
|
||||
* Always returns success to prevent email enumeration attacks.
|
||||
* Rate limited to 3 requests per minute to prevent abuse.
|
||||
*/
|
||||
@Post('forgot-password')
|
||||
@Throttle({ default: { ttl: 60000, limit: 3 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
|
||||
return this.betterAuthService.requestPasswordReset(
|
||||
|
|
@ -164,8 +172,10 @@ export class AuthController {
|
|||
* Reset password with token
|
||||
*
|
||||
* Completes the password reset using the token from the email link.
|
||||
* Rate limited to 5 requests per minute.
|
||||
*/
|
||||
@Post('reset-password')
|
||||
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
|
||||
return this.betterAuthService.resetPassword(
|
||||
|
|
@ -179,8 +189,10 @@ export class AuthController {
|
|||
*
|
||||
* Sends a new verification email to the user.
|
||||
* Always returns success to prevent email enumeration attacks.
|
||||
* Rate limited to 3 requests per minute to prevent abuse.
|
||||
*/
|
||||
@Post('resend-verification')
|
||||
@Throttle({ default: { ttl: 60000, limit: 3 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resendVerification(@Body() resendVerificationDto: ResendVerificationDto) {
|
||||
return this.betterAuthService.resendVerificationEmail(
|
||||
|
|
@ -198,8 +210,10 @@ export class AuthController {
|
|||
*
|
||||
* Creates an organization with the registering user as owner.
|
||||
* Also creates organization credit balance.
|
||||
* Rate limited to 3 requests per minute.
|
||||
*/
|
||||
@Post('register/b2b')
|
||||
@Throttle({ default: { ttl: 60000, limit: 3 } })
|
||||
async registerB2B(@Body() registerDto: RegisterB2BDto) {
|
||||
return this.betterAuthService.registerB2B(registerDto);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,19 @@
|
|||
import { Controller, Get, Param, Query, Res, HttpStatus } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { LoggerService } from '../common/logger';
|
||||
|
||||
@Controller('api/auth')
|
||||
export class BetterAuthPassthroughController {
|
||||
private readonly defaultFrontendUrl = 'https://mana.how';
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
constructor(
|
||||
private readonly betterAuthService: BetterAuthService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('BetterAuthPassthrough');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate redirect URL for security
|
||||
|
|
@ -113,7 +120,10 @@ export class BetterAuthPassthroughController {
|
|||
return res.redirect(`${fallbackUrl}/verification-failed?error=${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[verify-email] Error:', error);
|
||||
this.logger.error(
|
||||
'Email verification failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.redirect(`${fallbackUrl}/verification-failed?error=verification_failed`);
|
||||
}
|
||||
}
|
||||
|
|
@ -156,10 +166,13 @@ export class BetterAuthPassthroughController {
|
|||
const resetUrl = new URL('/reset-password', baseUrl);
|
||||
resetUrl.searchParams.set('token', token);
|
||||
|
||||
console.log(`[reset-password] Redirecting to: ${resetUrl.toString()}`);
|
||||
this.logger.debug('Password reset redirect', { destination: baseUrl });
|
||||
return res.redirect(resetUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('[reset-password] Error:', error);
|
||||
this.logger.error(
|
||||
'Password reset redirect failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.redirect(`${fallbackUrl}/login?error=reset_failed`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,7 +319,9 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
loginPage: '/login',
|
||||
// Consent page (skipped for trusted clients)
|
||||
consentPage: '/consent',
|
||||
// Use JWT plugin for token signing
|
||||
// Use JWT plugin for token signing (EdDSA instead of HS256)
|
||||
// This is required for Synapse OIDC which verifies via JWKS
|
||||
useJWTPlugin: true,
|
||||
metadata: {
|
||||
issuer: process.env.BASE_URL || 'http://localhost:3001',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,10 +20,18 @@
|
|||
import { Controller, Get, Post, All, Req, Res, HttpStatus } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { LoggerService } from '../common/logger';
|
||||
|
||||
@Controller()
|
||||
export class OidcController {
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(
|
||||
private readonly betterAuthService: BetterAuthService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('OidcController');
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Discovery Document
|
||||
|
|
@ -45,9 +53,7 @@ export class OidcController {
|
|||
*/
|
||||
@Get('api/auth/oauth2/authorize')
|
||||
async authorizeOauth2(@Req() req: Request, @Res() res: Response) {
|
||||
console.log('[OIDC Authorize] URL:', req.originalUrl);
|
||||
console.log('[OIDC Authorize] Query:', req.query);
|
||||
console.log('[OIDC Authorize] redirect_uri:', req.query.redirect_uri);
|
||||
this.logger.debug('OIDC authorize request', { clientId: req.query.client_id });
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +162,7 @@ export class OidcController {
|
|||
|
||||
return res.end();
|
||||
} catch (error) {
|
||||
console.error('[BetterAuth] Error handling request:', error);
|
||||
this.logger.error('OIDC request failed', error instanceof Error ? error.stack : undefined);
|
||||
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error',
|
||||
|
|
@ -243,7 +249,10 @@ export class OidcController {
|
|||
|
||||
return res.end();
|
||||
} catch (error) {
|
||||
console.error('[OIDC] Error handling request:', error);
|
||||
this.logger.error(
|
||||
'OIDC alternative path request failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
forwardRef,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { LoggerService } from '../../common/logger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createBetterAuth } from '../better-auth.config';
|
||||
import type { BetterAuthInstance } from '../better-auth.config';
|
||||
|
|
@ -64,7 +65,6 @@ import type {
|
|||
BetterAuthUser,
|
||||
BetterAuthSession,
|
||||
} from '../types/better-auth.types';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
|
||||
// Re-export DTOs and result types for external use
|
||||
|
|
@ -89,6 +89,7 @@ export type {
|
|||
export class BetterAuthService {
|
||||
private auth: BetterAuthInstance;
|
||||
private databaseUrl: string;
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
/**
|
||||
* Typed accessor for organization plugin API methods
|
||||
|
|
@ -117,8 +118,10 @@ export class BetterAuthService {
|
|||
private referralTierService: ReferralTierService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReferralTrackingService))
|
||||
private referralTrackingService: ReferralTrackingService
|
||||
private referralTrackingService: ReferralTrackingService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('BetterAuthService');
|
||||
this.databaseUrl = this.configService.get<string>('database.url')!;
|
||||
this.auth = createBetterAuth(this.databaseUrl);
|
||||
}
|
||||
|
|
@ -346,7 +349,10 @@ export class BetterAuthService {
|
|||
// Use type guard for safe access
|
||||
return hasMembers(result) ? result.members : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization members:', error);
|
||||
this.logger.error(
|
||||
'Failed to fetch organization members',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -477,43 +483,13 @@ export class BetterAuthService {
|
|||
throw new Error('Better Auth signJWT returned empty token');
|
||||
}
|
||||
} catch (jwtError) {
|
||||
console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError);
|
||||
this.logger.warn('Better Auth signJWT failed, using session token as fallback', {
|
||||
error: jwtError instanceof Error ? jwtError.message : 'Unknown error',
|
||||
});
|
||||
|
||||
// Fallback: Generate JWT manually using jsonwebtoken
|
||||
const privateKey = this.configService.get<string>('jwt.privateKey');
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
console.log('[signIn] Private key exists:', !!privateKey);
|
||||
console.log('[signIn] Private key length:', privateKey?.length);
|
||||
console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30));
|
||||
console.log('[signIn] Issuer:', issuer);
|
||||
console.log('[signIn] Audience:', audience);
|
||||
|
||||
if (privateKey) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
};
|
||||
|
||||
accessToken = jwt.sign(payload, privateKey, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '15m',
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50));
|
||||
// Decode to verify
|
||||
const decoded = jwt.decode(accessToken, { complete: true });
|
||||
console.log('[signIn] Generated JWT header:', decoded?.header);
|
||||
console.log('[signIn] Generated JWT payload:', decoded?.payload);
|
||||
} else {
|
||||
console.error('[signIn] No JWT private key configured');
|
||||
accessToken = sessionToken;
|
||||
}
|
||||
// Fallback: Use session token (Better Auth manages JWT signing via JWKS)
|
||||
// NOTE: If signJWT fails repeatedly, check that the auth.jwks table has valid EdDSA keys
|
||||
accessToken = sessionToken;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -562,7 +538,9 @@ export class BetterAuthService {
|
|||
} catch (error: unknown) {
|
||||
// Even if signOut fails, we treat it as success for the user
|
||||
// The session will expire naturally
|
||||
console.error('Error during sign out:', error);
|
||||
this.logger.warn('Sign out error (session will expire naturally)', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return { success: true, message: 'Signed out successfully' };
|
||||
}
|
||||
}
|
||||
|
|
@ -628,7 +606,10 @@ export class BetterAuthService {
|
|||
|
||||
return { organizations };
|
||||
} catch (error: unknown) {
|
||||
console.error('Error listing organizations:', error);
|
||||
this.logger.error(
|
||||
'Failed to list organizations',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return { organizations: [] };
|
||||
}
|
||||
}
|
||||
|
|
@ -821,18 +802,13 @@ export class BetterAuthService {
|
|||
*/
|
||||
async validateToken(token: string): Promise<ValidateTokenResult> {
|
||||
try {
|
||||
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
|
||||
|
||||
// Decode to check the algorithm
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
console.log('[validateToken] Decoded header:', decoded?.header);
|
||||
|
||||
// Use our JWKS endpoint (NestJS prefix: /api/v1)
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
|
||||
|
||||
console.log('[validateToken] Using JWKS from:', jwksUrl.toString());
|
||||
|
||||
// Create JWKS fetcher
|
||||
const JWKS = createRemoteJWKSet(jwksUrl);
|
||||
|
||||
|
|
@ -840,17 +816,13 @@ export class BetterAuthService {
|
|||
const issuer = this.configService.get<string>('jwt.issuer') || baseUrl;
|
||||
const audience = this.configService.get<string>('jwt.audience') || baseUrl;
|
||||
|
||||
console.log('[validateToken] Issuer:', issuer);
|
||||
console.log('[validateToken] Audience:', audience);
|
||||
|
||||
// Verify using jose library with Better Auth's JWKS
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[validateToken] Verification SUCCESS');
|
||||
console.log('[validateToken] Payload:', payload);
|
||||
this.logger.debug('Token validation successful', { userId: payload.sub });
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
|
|
@ -858,7 +830,7 @@ export class BetterAuthService {
|
|||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[validateToken] Verification FAILED:', errorMessage);
|
||||
this.logger.warn('Token validation failed', { error: errorMessage });
|
||||
return {
|
||||
valid: false,
|
||||
error: errorMessage,
|
||||
|
|
@ -906,7 +878,10 @@ export class BetterAuthService {
|
|||
message: 'If an account with that email exists, a password reset link has been sent',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[requestPasswordReset] Error:', error);
|
||||
this.logger.error(
|
||||
'Password reset request failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
// Always return success to prevent email enumeration attacks
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -977,10 +952,9 @@ export class BetterAuthService {
|
|||
query: { token },
|
||||
});
|
||||
|
||||
console.log('[verifyEmail] Result:', result);
|
||||
|
||||
// Extract email from result if available
|
||||
const email = result?.user?.email || result?.email;
|
||||
this.logger.debug('Email verification successful', { email });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -988,7 +962,7 @@ export class BetterAuthService {
|
|||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[verifyEmail] Error:', errorMessage);
|
||||
this.logger.warn('Email verification failed', { error: errorMessage });
|
||||
|
||||
if (errorMessage.includes('invalid') || errorMessage.includes('expired')) {
|
||||
return {
|
||||
|
|
@ -1038,7 +1012,10 @@ export class BetterAuthService {
|
|||
message: 'If an account with that email exists, a verification email has been sent',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[resendVerificationEmail] Error:', error);
|
||||
this.logger.error(
|
||||
'Resend verification email failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
// Always return success to prevent email enumeration attacks
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -1082,7 +1059,7 @@ export class BetterAuthService {
|
|||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[getJwks] Error:', error);
|
||||
this.logger.error('Failed to get JWKS', error instanceof Error ? error.stack : undefined);
|
||||
return { keys: [] };
|
||||
}
|
||||
}
|
||||
|
|
@ -1132,7 +1109,9 @@ export class BetterAuthService {
|
|||
totalSpent: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating personal credit balance:', error);
|
||||
this.logger.warn('Failed to create personal credit balance (non-critical)', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
|
@ -1163,7 +1142,9 @@ export class BetterAuthService {
|
|||
totalAllocated: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating organization credit balance:', error);
|
||||
this.logger.warn('Failed to create organization credit balance (non-critical)', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
|
@ -1227,12 +1208,15 @@ export class BetterAuthService {
|
|||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.warn('[initializeUserReferrals] Failed to apply referral code:', result.error);
|
||||
this.logger.warn('Failed to apply referral code', { error: result.error, referralCode });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't fail registration if referral setup fails
|
||||
console.error('[initializeUserReferrals] Error setting up referrals:', error);
|
||||
this.logger.error(
|
||||
'Error setting up referrals',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1353,7 +1337,10 @@ export class BetterAuthService {
|
|||
body,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[handleOidcRequest] Error:', error);
|
||||
this.logger.error(
|
||||
'OIDC request handling failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { TestingModule } from '@nestjs/testing';
|
|||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { LoggerService } from '../logger';
|
||||
import { createMockConfigService, httpMockHelpers } from '../../__tests__/utils/test-helpers';
|
||||
import { mockTokenFactory } from '../../__tests__/utils/mock-factories';
|
||||
import { silentError } from '../../__tests__/utils/silent-error.decorator';
|
||||
|
|
@ -21,6 +22,18 @@ import { jwtVerify } from 'jose';
|
|||
// Mock jose (auto-mocked via jest.config.js moduleNameMapper)
|
||||
jest.mock('jose');
|
||||
|
||||
// Mock LoggerService
|
||||
const createMockLoggerService = (): LoggerService =>
|
||||
({
|
||||
setContext: jest.fn().mockReturnThis(),
|
||||
log: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
}) as unknown as LoggerService;
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
let configService: ConfigService;
|
||||
|
|
@ -41,6 +54,10 @@ describe('JwtAuthGuard', () => {
|
|||
'jwt.audience': 'manacore',
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: LoggerService,
|
||||
useValue: createMockLoggerService(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
|
@ -344,7 +361,8 @@ describe('JwtAuthGuard', () => {
|
|||
createMockConfigService({
|
||||
'jwt.issuer': 'manacore',
|
||||
'jwt.audience': 'manacore',
|
||||
})
|
||||
}),
|
||||
createMockLoggerService()
|
||||
);
|
||||
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
|
|
@ -375,7 +393,8 @@ describe('JwtAuthGuard', () => {
|
|||
BASE_URL: 'http://localhost:3001',
|
||||
'jwt.issuer': 'custom-issuer',
|
||||
'jwt.audience': 'custom-audience',
|
||||
})
|
||||
}),
|
||||
createMockLoggerService()
|
||||
);
|
||||
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
import { LoggerService } from '../logger';
|
||||
|
||||
/**
|
||||
* JWT Auth Guard using JWKS (Better Auth compatible)
|
||||
|
|
@ -16,17 +17,20 @@ import { jwtVerify, createRemoteJWKSet } from 'jose';
|
|||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('JwtAuthGuard');
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
console.log('[JwtAuthGuard] Token (first 50 chars):', token?.substring(0, 50));
|
||||
|
||||
if (!token) {
|
||||
console.log('[JwtAuthGuard] No token provided');
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
|
|
@ -35,21 +39,18 @@ export class JwtAuthGuard implements CanActivate {
|
|||
if (!this.jwks) {
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
|
||||
console.log('[JwtAuthGuard] Initializing JWKS from:', jwksUrl.toString());
|
||||
this.jwks = createRemoteJWKSet(jwksUrl);
|
||||
}
|
||||
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
console.log('[JwtAuthGuard] Verifying with issuer:', issuer, 'audience:', audience);
|
||||
|
||||
const { payload } = await jwtVerify(token, this.jwks, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[JwtAuthGuard] Verification SUCCESS, user:', payload.sub);
|
||||
this.logger.debug('Token verification successful', { userId: payload.sub });
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
|
|
@ -60,7 +61,9 @@ export class JwtAuthGuard implements CanActivate {
|
|||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[JwtAuthGuard] Token verification FAILED:', error);
|
||||
this.logger.warn('Token verification failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
services/mana-core-auth/src/common/logger/index.ts
Normal file
2
services/mana-core-auth/src/common/logger/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { LoggerService, getLogger } from './logger.service';
|
||||
export { LoggerModule } from './logger.module';
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [LoggerService],
|
||||
exports: [LoggerService],
|
||||
})
|
||||
export class LoggerModule {}
|
||||
95
services/mana-core-auth/src/common/logger/logger.service.ts
Normal file
95
services/mana-core-auth/src/common/logger/logger.service.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Injectable, LoggerService as NestLoggerService, Scope } from '@nestjs/common';
|
||||
import * as winston from 'winston';
|
||||
|
||||
const { combine, timestamp, printf, colorize, json } = winston.format;
|
||||
|
||||
// Custom format for development (readable)
|
||||
const devFormat = printf(({ level, message, timestamp, context, ...meta }) => {
|
||||
const ctx = context ? `[${context}]` : '';
|
||||
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
|
||||
return `${timestamp} ${level} ${ctx} ${message}${metaStr}`;
|
||||
});
|
||||
|
||||
// Create winston logger instance
|
||||
function createLogger(): winston.Logger {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
return winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
|
||||
format: isProduction
|
||||
? combine(timestamp(), json())
|
||||
: combine(timestamp({ format: 'HH:mm:ss' }), colorize(), devFormat),
|
||||
transports: [new winston.transports.Console()],
|
||||
// Don't exit on error
|
||||
exitOnError: false,
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class LoggerService implements NestLoggerService {
|
||||
private logger: winston.Logger;
|
||||
private context?: string;
|
||||
|
||||
constructor() {
|
||||
this.logger = createLogger();
|
||||
}
|
||||
|
||||
setContext(context: string): this {
|
||||
this.context = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
log(message: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.info(message, this.formatMeta(optionalParams));
|
||||
}
|
||||
|
||||
info(message: string, meta?: Record<string, unknown>): void {
|
||||
this.logger.info(message, { context: this.context, ...meta });
|
||||
}
|
||||
|
||||
error(message: string, trace?: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.error(message, {
|
||||
context: this.context,
|
||||
trace,
|
||||
...this.formatMeta(optionalParams),
|
||||
});
|
||||
}
|
||||
|
||||
warn(message: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.warn(message, this.formatMeta(optionalParams));
|
||||
}
|
||||
|
||||
debug(message: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.debug(message, this.formatMeta(optionalParams));
|
||||
}
|
||||
|
||||
verbose(message: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.verbose(message, this.formatMeta(optionalParams));
|
||||
}
|
||||
|
||||
private formatMeta(optionalParams: unknown[]): Record<string, unknown> {
|
||||
const meta: Record<string, unknown> = { context: this.context };
|
||||
|
||||
if (optionalParams.length === 1 && typeof optionalParams[0] === 'string') {
|
||||
// NestJS passes context as last param
|
||||
meta.context = optionalParams[0];
|
||||
} else if (optionalParams.length > 0) {
|
||||
meta.params = optionalParams;
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for use outside DI (main.ts, scripts)
|
||||
let globalLogger: LoggerService | null = null;
|
||||
|
||||
export function getLogger(context?: string): LoggerService {
|
||||
if (!globalLogger) {
|
||||
globalLogger = new LoggerService();
|
||||
}
|
||||
if (context) {
|
||||
return new LoggerService().setContext(context);
|
||||
}
|
||||
return globalLogger;
|
||||
}
|
||||
|
|
@ -1,52 +1,76 @@
|
|||
/**
|
||||
* Application Configuration
|
||||
*
|
||||
* Loads and validates environment variables.
|
||||
* Fails fast at startup if required variables are missing.
|
||||
*/
|
||||
|
||||
import { validateEnv, isDevelopment } from './env.validation';
|
||||
|
||||
// Validate environment on module load
|
||||
const env = validateEnv();
|
||||
|
||||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(env.PORT, 10),
|
||||
nodeEnv: env.NODE_ENV,
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
|
||||
// In development, allow fallback to local database
|
||||
// In production, DATABASE_URL is validated as required
|
||||
url:
|
||||
env.DATABASE_URL ||
|
||||
(isDevelopment() ? 'postgresql://manacore:manacore@localhost:5432/manacore_auth' : ''),
|
||||
},
|
||||
|
||||
jwt: {
|
||||
// Convert \n string literals to actual newlines for PEM format
|
||||
publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
|
||||
privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
|
||||
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
|
||||
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||
// Better Auth uses JWKS from database, these are legacy/fallback
|
||||
publicKey: (env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
|
||||
privateKey: (env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
|
||||
accessTokenExpiry: env.JWT_ACCESS_TOKEN_EXPIRY,
|
||||
refreshTokenExpiry: env.JWT_REFRESH_TOKEN_EXPIRY,
|
||||
issuer: env.JWT_ISSUER,
|
||||
audience: env.JWT_AUDIENCE,
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
host: env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(env.REDIS_PORT || '6379', 10),
|
||||
password: env.REDIS_PASSWORD,
|
||||
},
|
||||
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
|
||||
secretKey: env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
|
||||
publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:8081',
|
||||
],
|
||||
origin:
|
||||
env.CORS_ORIGINS?.split(',').map((o) => o.trim()) ||
|
||||
(isDevelopment()
|
||||
? [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://localhost:8081',
|
||||
]
|
||||
: []),
|
||||
credentials: true,
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
ttl: parseInt(process.env.RATE_LIMIT_TTL || '60', 10),
|
||||
limit: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
||||
ttl: parseInt(env.RATE_LIMIT_TTL || '60', 10),
|
||||
limit: parseInt(env.RATE_LIMIT_MAX || '100', 10),
|
||||
},
|
||||
|
||||
credits: {
|
||||
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
|
||||
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
|
||||
signupBonus: parseInt(env.CREDITS_SIGNUP_BONUS || '150', 10),
|
||||
dailyFreeCredits: parseInt(env.CREDITS_DAILY_FREE || '5', 10),
|
||||
},
|
||||
|
||||
ai: {
|
||||
geminiApiKey: process.env.GOOGLE_GENAI_API_KEY || '',
|
||||
geminiApiKey: env.GOOGLE_GENAI_API_KEY || '',
|
||||
},
|
||||
|
||||
baseUrl: env.BASE_URL || (isDevelopment() ? 'http://localhost:3001' : ''),
|
||||
});
|
||||
|
|
|
|||
145
services/mana-core-auth/src/config/env.validation.ts
Normal file
145
services/mana-core-auth/src/config/env.validation.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Environment Variable Validation
|
||||
*
|
||||
* Validates all required environment variables at startup.
|
||||
* Fails fast with clear error messages if configuration is invalid.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for environment variables
|
||||
const envSchema = z.object({
|
||||
// Node environment
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().regex(/^\d+$/).default('3001'),
|
||||
|
||||
// Database - REQUIRED in production
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||
|
||||
// Redis - optional in development, recommended in production
|
||||
REDIS_HOST: z.string().optional(),
|
||||
REDIS_PORT: z.string().regex(/^\d+$/).optional(),
|
||||
REDIS_PASSWORD: z.string().optional(),
|
||||
|
||||
// JWT - Better Auth uses JWKS, so these are optional legacy config
|
||||
JWT_PUBLIC_KEY: z.string().optional(),
|
||||
JWT_PRIVATE_KEY: z.string().optional(),
|
||||
JWT_ISSUER: z.string().default('manacore'),
|
||||
JWT_AUDIENCE: z.string().default('manacore'),
|
||||
JWT_ACCESS_TOKEN_EXPIRY: z.string().default('15m'),
|
||||
JWT_REFRESH_TOKEN_EXPIRY: z.string().default('7d'),
|
||||
|
||||
// CORS - REQUIRED in production
|
||||
CORS_ORIGINS: z.string().optional(),
|
||||
|
||||
// Stripe - optional, but credit system won't work without it
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
|
||||
// SMTP - optional, emails will be logged if not configured
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
|
||||
// Rate limiting
|
||||
RATE_LIMIT_TTL: z.string().regex(/^\d+$/).optional(),
|
||||
RATE_LIMIT_MAX: z.string().regex(/^\d+$/).optional(),
|
||||
|
||||
// Credits
|
||||
CREDITS_SIGNUP_BONUS: z.string().regex(/^\d+$/).optional(),
|
||||
CREDITS_DAILY_FREE: z.string().regex(/^\d+$/).optional(),
|
||||
|
||||
// AI
|
||||
GOOGLE_GENAI_API_KEY: z.string().optional(),
|
||||
|
||||
// Base URL for callbacks
|
||||
BASE_URL: z.string().url().optional(),
|
||||
|
||||
// Log level
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).optional(),
|
||||
});
|
||||
|
||||
// Production-specific schema with stricter requirements
|
||||
const productionEnvSchema = envSchema.extend({
|
||||
// In production, these are mandatory
|
||||
CORS_ORIGINS: z.string().min(1, 'CORS_ORIGINS is required in production'),
|
||||
BASE_URL: z.string().url('BASE_URL must be a valid URL in production'),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
/**
|
||||
* Validate environment variables
|
||||
*
|
||||
* @throws Error with detailed message if validation fails
|
||||
*/
|
||||
export function validateEnv(): EnvConfig {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const schema = isProduction ? productionEnvSchema : envSchema;
|
||||
|
||||
const result = schema.safeParse(process.env);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors
|
||||
.map((err) => ` - ${err.path.join('.')}: ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
const message = `
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ ENVIRONMENT CONFIGURATION ERROR ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
The following environment variables are missing or invalid:
|
||||
|
||||
${errors}
|
||||
|
||||
${isProduction ? 'Production mode requires stricter configuration.' : ''}
|
||||
|
||||
Please check your .env file or environment variables.
|
||||
For development, copy .env.example to .env and fill in the values.
|
||||
`;
|
||||
|
||||
console.error(message);
|
||||
throw new Error(`Environment validation failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
// Additional production warnings (non-fatal)
|
||||
if (isProduction) {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!result.data.STRIPE_SECRET_KEY) {
|
||||
warnings.push('STRIPE_SECRET_KEY not set - credit system will not work');
|
||||
}
|
||||
if (!result.data.SMTP_HOST) {
|
||||
warnings.push('SMTP not configured - emails will only be logged');
|
||||
}
|
||||
if (!result.data.REDIS_HOST) {
|
||||
warnings.push('REDIS_HOST not set - using in-memory session storage (not recommended)');
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.warn('\n⚠️ Production Warnings:');
|
||||
warnings.forEach((w) => console.warn(` - ${w}`));
|
||||
console.warn('');
|
||||
}
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in development mode
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
return process.env.NODE_ENV !== 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production mode
|
||||
*/
|
||||
export function isProduction(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
|
@ -8,6 +8,9 @@
|
|||
*/
|
||||
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { getLogger } from '../common/logger';
|
||||
|
||||
const logger = getLogger('EmailService');
|
||||
|
||||
interface EmailOptions {
|
||||
to: string;
|
||||
|
|
@ -30,7 +33,7 @@ function getTransporter(): nodemailer.Transporter {
|
|||
const pass = process.env.SMTP_PASSWORD;
|
||||
|
||||
if (!user || !pass) {
|
||||
console.warn('[Email] SMTP credentials not configured, emails will be logged only');
|
||||
logger.warn('SMTP credentials not configured, emails will be logged only');
|
||||
return null as any;
|
||||
}
|
||||
|
||||
|
|
@ -54,15 +57,12 @@ export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
|||
const { to, subject, html, text } = options;
|
||||
const from = process.env.SMTP_FROM || 'ManaCore <noreply@mana.how>';
|
||||
|
||||
console.log(`[Email] Sending to: ${to}, subject: ${subject}`);
|
||||
logger.info('Sending email', { to, subject });
|
||||
|
||||
const transport = getTransporter();
|
||||
|
||||
if (!transport) {
|
||||
console.log('[Email] No SMTP configured, logging email content:');
|
||||
console.log(` To: ${to}`);
|
||||
console.log(` Subject: ${subject}`);
|
||||
console.log(` HTML: ${html.substring(0, 200)}...`);
|
||||
logger.debug('No SMTP configured, email not sent', { to, subject });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -75,10 +75,10 @@ export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
|||
text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text version
|
||||
});
|
||||
|
||||
console.log(`[Email] Sent successfully, messageId: ${result.messageId}`);
|
||||
logger.info('Email sent successfully', { to, messageId: result.messageId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Email] Failed to send:', error);
|
||||
logger.error('Failed to send email', error instanceof Error ? error.stack : undefined, { to });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,181 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
/**
|
||||
* Health Check Controller
|
||||
*
|
||||
* Provides health check endpoints for Kubernetes/Docker:
|
||||
* - /health - Basic health check (always returns ok if server is running)
|
||||
* - /health/live - Liveness probe (is the process running?)
|
||||
* - /health/ready - Readiness probe (is the service ready to accept traffic?)
|
||||
*
|
||||
* Readiness checks database connectivity to ensure the service
|
||||
* can actually handle requests before receiving traffic.
|
||||
*/
|
||||
|
||||
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'ok' | 'error';
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
checks?: {
|
||||
database?: { status: 'ok' | 'error'; latency?: number; error?: string };
|
||||
redis?: { status: 'ok' | 'error' | 'not_configured'; latency?: number; error?: string };
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
private readonly startTime = Date.now();
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Basic health check
|
||||
* Returns ok if the server is running
|
||||
*/
|
||||
@Get()
|
||||
check() {
|
||||
check(): HealthStatus {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liveness probe
|
||||
* Used by Kubernetes to determine if the process should be restarted
|
||||
* Only checks if the process is alive, not if dependencies are healthy
|
||||
*/
|
||||
@Get('live')
|
||||
live(): { status: 'ok' } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe
|
||||
* Used by Kubernetes to determine if the service should receive traffic
|
||||
* Checks database connectivity before marking as ready
|
||||
*/
|
||||
@Get('ready')
|
||||
async ready(): Promise<HealthStatus> {
|
||||
const checks: HealthStatus['checks'] = {};
|
||||
let allHealthy = true;
|
||||
|
||||
// Check database
|
||||
const dbCheck = await this.checkDatabase();
|
||||
checks.database = dbCheck;
|
||||
if (dbCheck.status === 'error') {
|
||||
allHealthy = false;
|
||||
}
|
||||
|
||||
// Check Redis (optional - don't fail if not configured)
|
||||
const redisCheck = await this.checkRedis();
|
||||
checks.redis = redisCheck;
|
||||
// Don't fail readiness if Redis is just not configured
|
||||
if (redisCheck.status === 'error') {
|
||||
allHealthy = false;
|
||||
}
|
||||
|
||||
const status: HealthStatus = {
|
||||
status: allHealthy ? 'ok' : 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
checks,
|
||||
};
|
||||
|
||||
if (!allHealthy) {
|
||||
throw new ServiceUnavailableException(status);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check database connectivity
|
||||
*/
|
||||
private async checkDatabase(): Promise<{
|
||||
status: 'ok' | 'error';
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
if (!databaseUrl) {
|
||||
return { status: 'error', error: 'DATABASE_URL not configured' };
|
||||
}
|
||||
|
||||
const db = getDb(databaseUrl);
|
||||
await db.execute(sql`SELECT 1`);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
latency: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
latency: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Unknown database error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Redis connectivity (optional)
|
||||
*/
|
||||
private async checkRedis(): Promise<{
|
||||
status: 'ok' | 'error' | 'not_configured';
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const redisHost = this.configService.get<string>('redis.host');
|
||||
|
||||
// Redis is optional - if not configured, that's fine
|
||||
if (!redisHost) {
|
||||
return { status: 'not_configured' };
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Simple TCP connection check to Redis
|
||||
const net = await import('net');
|
||||
const redisPort = this.configService.get<number>('redis.port') || 6379;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new net.Socket();
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 2000);
|
||||
|
||||
socket.connect(redisPort, redisHost, () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
latency: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
latency: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Unknown Redis error',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import cookieParser from 'cookie-parser';
|
|||
import * as bodyParser from 'body-parser';
|
||||
import { AppModule } from './app.module';
|
||||
import { MetricsService } from './metrics/metrics.service';
|
||||
import { getLogger } from './common/logger';
|
||||
|
||||
const logger = getLogger('Bootstrap');
|
||||
|
||||
// Normalize route paths to prevent high cardinality
|
||||
function normalizeRoute(path: string): string {
|
||||
|
|
@ -76,7 +79,7 @@ async function bootstrap() {
|
|||
|
||||
// CORS configuration
|
||||
const corsOrigins = configService.get<string[]>('cors.origin') || [];
|
||||
console.log('📋 CORS Origins configured:', corsOrigins);
|
||||
logger.info('CORS Origins configured', { origins: corsOrigins });
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
|
|
@ -128,8 +131,10 @@ async function bootstrap() {
|
|||
const port = configService.get<number>('port') || 3001;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`);
|
||||
console.log(`📚 Environment: ${configService.get<string>('nodeEnv')}`);
|
||||
logger.info(`Mana Core Auth running on http://localhost:${port}`, {
|
||||
port,
|
||||
environment: configService.get<string>('nodeEnv'),
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue