mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
feat(infra): delete mana-core-auth (NestJS), replace with mana-auth (Hono+Bun)
Remove the entire NestJS-based mana-core-auth service (~36,000 lines including tests, config, and package files). The new mana-auth service (Hono + Bun, ~1,900 LOC) is the complete replacement on the same port. Deleted: - services/mana-core-auth/ — 169 files, 36,123 lines (NestJS 10, Express, class-validator, all NestJS infrastructure) Updated: - docker-compose.macmini.yml: mana-auth now builds from services/mana-auth with Bun healthcheck, simplified env vars (no Redis, no DuckDB needed) - CLAUDE.md: mana-core-auth → mana-auth in services list - Overview plan: marked Phase 4+5 as DONE, updated next steps The ManaCore auth ecosystem is now: - mana-auth (3001) — Auth, JWT, SSO, OIDC, Guilds, API Keys, GDPR - mana-credits (3061) — Credits, Gifts, Guild Pools, Stripe - mana-user (3062) — Settings, Tags, Storage - mana-subscriptions (3063) — Plans, Billing, Invoices - mana-analytics (3064) — Feedback, Voting Total: ~6,600 LOC across 5 Hono+Bun services Replaces: ~20,000 LOC in 1 NestJS service (67% reduction) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14099cc42c
commit
5b673282f9
169 changed files with 43 additions and 36123 deletions
|
|
@ -53,74 +53,50 @@ Apps 9-17 und 19 haben die Datenschicht (IndexedDB), aber die Svelte-Stores lese
|
|||
|
||||
**Ziel:** mana-core-auth aufteilen in fokussierte Microservices auf Hono + Bun.
|
||||
|
||||
### Erledigt
|
||||
### Erledigt — KOMPLETT
|
||||
|
||||
| Service | Port | Was extrahiert | LOC (neu) | LOC (entfernt aus Auth) |
|
||||
| ---------------- | ---- | ---------------------------------------------- | --------- | ----------------------- |
|
||||
| **mana-credits** | 3061 | Credits, Gifts, Guild Pools, Stripe Payments | ~2.400 | ~4.200 |
|
||||
| **mana-user** | 3062 | Settings, Tags, Tag-Groups, Tag-Links, Storage | ~780 | ~2.800 |
|
||||
| Service | Port | Runtime | LOC | Was |
|
||||
| ---------------------- | ---- | -------- | ------ | --------------------------------------- |
|
||||
| **mana-auth** | 3001 | Hono+Bun | ~1.900 | Auth, JWT, SSO, OIDC, 2FA, Orgs, Guilds |
|
||||
| **mana-credits** | 3061 | Hono+Bun | ~2.400 | Credits, Gifts, Guild Pools, Stripe |
|
||||
| **mana-user** | 3062 | Hono+Bun | ~780 | Settings, Tags, Tag-Groups, Storage |
|
||||
| **mana-subscriptions** | 3063 | Hono+Bun | ~990 | Plans, Subscriptions, Invoices, Stripe |
|
||||
| **mana-analytics** | 3064 | Hono+Bun | ~550 | Feedback, Voting, AI Titles |
|
||||
|
||||
**Ergebnis:** mana-core-auth von ~20k auf ~13k LOC reduziert.
|
||||
**Gesamt: ~6.620 LOC** in 5 Hono/Bun Services ersetzt **~20.000 LOC** in 1 NestJS Service.
|
||||
|
||||
**Was gemacht wurde:**
|
||||
**mana-core-auth (NestJS) wurde gelöscht.** mana-auth ist der Drop-in-Ersatz auf Port 3001.
|
||||
|
||||
- Neuer Service mit Hono + Bun (kein NestJS)
|
||||
- Drizzle ORM Schemas adaptiert (keine FK zu Auth-Tabellen)
|
||||
- Zod statt class-validator für Validation
|
||||
- JWT-Validierung via JWKS von mana-core-auth
|
||||
### Was gemacht wurde:
|
||||
|
||||
- 5 eigenständige Hono + Bun Services (kein NestJS mehr)
|
||||
- Better Auth nativ auf Hono (kein Express↔Fetch-Konvertierung)
|
||||
- Drizzle ORM Schemas adaptiert (keine FK zwischen Services)
|
||||
- Zod statt class-validator, jose für JWT
|
||||
- Service-to-Service Auth via X-Service-Key
|
||||
- CreditClientService URL auf `MANA_CREDITS_URL` umgestellt
|
||||
- mana-core-auth Registration Hooks auf HTTP-Calls umgestellt
|
||||
- Docker-Compose Einträge + Cloudflare Tunnel Labels
|
||||
- Alter Code komplett aus mana-core-auth entfernt
|
||||
|
||||
### Noch zu extrahieren
|
||||
|
||||
| Service | Was | LOC in Auth | Priorität |
|
||||
| ---------------------- | ------------------------------------ | ----------- | --------- |
|
||||
| **mana-subscriptions** | Subscriptions, Pläne, Stripe Billing | ~1.100 | Mittel |
|
||||
| **mana-analytics** | Feedback, Analytics (DuckDB), AI | ~1.000 | Niedrig |
|
||||
|
||||
### Nach vollständiger Extraktion bleibt in mana-core-auth:
|
||||
|
||||
- Better Auth (JWT, Sessions, 2FA, Passkeys, Magic Links)
|
||||
- OIDC Provider (Matrix/Synapse SSO)
|
||||
- Organizations (Better Auth Org Plugin)
|
||||
- Guilds (Org-Wrapper, ohne Pool — Pool ist in mana-credits)
|
||||
- API Keys
|
||||
- Security (Audit Logs, Lockout)
|
||||
- Me (GDPR Export/Delete)
|
||||
- Health, Metrics
|
||||
- Docker-Compose für alle Services
|
||||
- Alter NestJS-Code komplett gelöscht
|
||||
|
||||
→ Geschätzt ~8-10k LOC reines Auth → Dann Hono-Rewrite (Phase 5)
|
||||
|
||||
---
|
||||
|
||||
## Teil 3: Hono-Rewrite von mana-core-auth (Phase 5)
|
||||
## Teil 3: Hono-Rewrite von mana-core-auth (Phase 5) — DONE
|
||||
|
||||
**Noch nicht begonnen.** Geplante Schritte:
|
||||
**mana-auth (Hono + Bun) ersetzt mana-core-auth (NestJS).** Alter Code gelöscht.
|
||||
|
||||
1. Hono App-Skeleton + Better Auth native Handler
|
||||
2. JWT Middleware + Auth-Guards als Hono Middleware
|
||||
3. Health + JWKS + Token-Validation Endpoints
|
||||
4. Auth-Endpoints (Register, Login, Refresh, SSO)
|
||||
5. Organizations/Guilds
|
||||
6. OIDC Provider + Matrix Session
|
||||
7. API Keys, Me (GDPR), Admin
|
||||
8. Tests + Umschalten
|
||||
|
||||
**Voraussetzung:** Subscriptions + Analytics zuerst extrahieren.
|
||||
Fertige Endpoints: Better Auth nativ, Auth (Register/Login/Logout/Validate), Guilds, API Keys, Me (GDPR), Security (Lockout/Audit), OIDC Provider, Login Page.
|
||||
|
||||
---
|
||||
|
||||
## Teil 4: Infrastruktur (Phase 5b)
|
||||
## Teil 4: Verbleibende Aufgaben
|
||||
|
||||
- [ ] NestJS Dependencies aus dem Monorepo entfernen
|
||||
- [ ] NestJS Dependencies aus dem Monorepo entfernen (`@nestjs/*`)
|
||||
- [ ] `packages/shared-nestjs-auth` → `packages/shared-hono-auth`
|
||||
- [ ] `@mana-core/nestjs-integration` → `@mana-core/hono-integration`
|
||||
- [ ] Docker-Images auf Bun Base Image umstellen
|
||||
- [ ] Store-Migrationen vertiefen (11 Apps: Stores von API → IndexedDB)
|
||||
- [ ] mana-sync Go Server — Collections aller 19 Apps registrieren
|
||||
- [ ] CI/CD Pipeline anpassen (Go Build + Bun Build)
|
||||
- [ ] Monitoring: Prometheus Metrics für neue Services
|
||||
- [ ] Load Testing: Sync-Protokoll unter Last testen
|
||||
|
||||
---
|
||||
|
|
@ -147,8 +123,10 @@ ef19018e feat(services): create mana-user + remove from auth (-2,834 LOC)
|
|||
|
||||
## Nächste Schritte (Priorität)
|
||||
|
||||
1. **mana-subscriptions extrahieren** — Stripe Billing raus aus Auth
|
||||
2. **mana-analytics extrahieren** — Feedback + DuckDB raus aus Auth
|
||||
3. **Auth Hono-Rewrite** — Better Auth mit nativem Hono-Adapter
|
||||
4. **Store-Migrationen vertiefen** — Apps 9-17, 19: Stores auf IndexedDB umschreiben
|
||||
1. ~~mana-subscriptions extrahieren~~ ✅
|
||||
2. ~~mana-analytics extrahieren~~ ✅
|
||||
3. ~~Auth Hono-Rewrite~~ ✅
|
||||
4. **Store-Migrationen vertiefen** — 11 Apps: Stores von API auf IndexedDB umschreiben
|
||||
5. **mana-sync Go Server** — Collections aller 19 Apps registrieren
|
||||
6. **NestJS Cleanup** — Dependencies + shared packages migrieren
|
||||
7. **App-Backend NestJS → Hono** — Chat, Picture, etc. Backends umschreiben
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ manacore-monorepo/
|
|||
├── games/ # Game projects
|
||||
│ └── {game-name}/ # Individual games
|
||||
├── services/ # Standalone microservices
|
||||
│ ├── mana-core-auth/ # Central authentication service
|
||||
│ ├── mana-auth/ # Central authentication (Hono + Bun + Better Auth)
|
||||
│ ├── mana-credits/ # Credit system (Hono + Bun, extracted from auth)
|
||||
│ ├── mana-user/ # User settings, tags, storage (Hono + Bun, extracted from auth)
|
||||
│ ├── mana-subscriptions/ # Subscription billing (Hono + Bun, extracted from auth)
|
||||
|
|
|
|||
|
|
@ -242,62 +242,35 @@ services:
|
|||
|
||||
mana-auth:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/mana-core-auth/Dockerfile
|
||||
image: mana-core-auth:local
|
||||
container_name: mana-core-auth
|
||||
context: services/mana-auth
|
||||
dockerfile: Dockerfile
|
||||
image: mana-auth:local
|
||||
container_name: mana-auth
|
||||
restart: always
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_auth
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
|
||||
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-change-me}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-${JWT_SECRET:-your-jwt-secret-change-me}}
|
||||
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY:-}
|
||||
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:-}
|
||||
BASE_URL: https://auth.mana.how
|
||||
# Cross-domain SSO: share session cookies across all *.mana.how subdomains
|
||||
COOKIE_DOMAIN: .mana.how
|
||||
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||
MANA_CREDITS_URL: http://mana-credits:3002
|
||||
MANA_CREDITS_URL: http://mana-credits:3061
|
||||
MANA_SUBSCRIPTIONS_URL: http://mana-subscriptions:3063
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-${JWT_SECRET:-your-jwt-secret-change-me}}
|
||||
SMTP_HOST: smtp-relay.brevo.com
|
||||
SMTP_PORT: 587
|
||||
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM: Mana <noreply@mana.how>
|
||||
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
|
||||
DUCKDB_PATH: /data/analytics/metrics.duckdb
|
||||
SMTP_PASS: ${SMTP_PASSWORD}
|
||||
SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-}
|
||||
# Backend URLs for user data aggregation (GDPR self-service)
|
||||
CHAT_BACKEND_URL: http://chat-backend:3030
|
||||
TODO_BACKEND_URL: http://todo-backend:3031
|
||||
CALENDAR_BACKEND_URL: http://calendar-backend:3032
|
||||
CONTACTS_BACKEND_URL: http://contacts-backend:3033
|
||||
PICTURE_BACKEND_URL: http://picture-backend:3035
|
||||
# PRESI_BACKEND_URL: removed — replaced by Hono server
|
||||
# ZITARE_BACKEND_URL: removed — migrated to local-first
|
||||
# PHOTOS_BACKEND_URL: removed — migrated to local-first
|
||||
# CLOCK_BACKEND_URL: removed — migrated to local-first
|
||||
STORAGE_BACKEND_URL: http://storage-backend:3034
|
||||
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||
MANA_LLM_URL: http://mana-llm:3020
|
||||
# WebAuthn / Passkeys
|
||||
WEBAUTHN_RP_ID: mana.how
|
||||
WEBAUTHN_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://manadeck.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://zitare.mana.how
|
||||
volumes:
|
||||
- analytics_data:/data/analytics
|
||||
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
|
||||
ports:
|
||||
- "3001:3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://127.0.0.1:3001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 120s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
# 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,104 +0,0 @@
|
|||
# ============================================================================
|
||||
# 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
|
||||
|
||||
# Logging
|
||||
# Options: debug, info, warn, error
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# ============================================================================
|
||||
# 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=
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
|
||||
# ============================================================================
|
||||
# Cross-Domain SSO [REQUIRED IN PRODUCTION]
|
||||
# ============================================================================
|
||||
# Cookie domain for Single Sign-On across subdomains.
|
||||
# Set to '.mana.how' (with leading dot) to share sessions across:
|
||||
# - calendar.mana.how
|
||||
# - todo.mana.how
|
||||
# - chat.mana.how
|
||||
# - etc.
|
||||
#
|
||||
# Leave empty/unset for local development (cookies will be domain-specific)
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# ============================================================================
|
||||
# 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 (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_...
|
||||
|
||||
# ============================================================================
|
||||
# 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=
|
||||
50
services/mana-core-auth/.gitignore
vendored
50
services/mana-core-auth/.gitignore
vendored
|
|
@ -1,50 +0,0 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Keys (NEVER commit these!)
|
||||
*.pem
|
||||
private.key
|
||||
public.key
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Misc
|
||||
.cache/
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# DuckDB local data
|
||||
data/
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
# Mana Core Auth - Claude Code Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
Mana Core Auth is the central authentication service for the Mana Universe ecosystem. It uses **Better Auth** for all authentication functionality.
|
||||
|
||||
## ⚠️ CRITICAL RULES FOR CLAUDE CODE
|
||||
|
||||
### 1. ALWAYS USE BETTER AUTH - NO EXCEPTIONS
|
||||
|
||||
**DO NOT** implement custom authentication logic. Better Auth handles:
|
||||
|
||||
- User registration and sign-in
|
||||
- JWT token generation (EdDSA algorithm)
|
||||
- JWT token verification (via JWKS)
|
||||
- Session management
|
||||
- Organization/multi-tenant support
|
||||
- Password hashing
|
||||
- Token refresh
|
||||
|
||||
### 2. JWT Rules
|
||||
|
||||
| DO | DON'T |
|
||||
| ----------------------------------------- | ----------------------------------- |
|
||||
| Use `jose` library for JWT operations | Use `jsonwebtoken` library |
|
||||
| Use Better Auth's JWKS endpoint | Configure RSA keys in `.env` |
|
||||
| Use EdDSA algorithm (Better Auth default) | Use RS256 or HS256 |
|
||||
| Fetch JWKS from `/api/v1/auth/jwks` | Hardcode public keys |
|
||||
| Keep JWT claims minimal | Add credit_balance, org data to JWT |
|
||||
|
||||
### 3. Before Making Auth Changes
|
||||
|
||||
1. **Read the docs first**: `docs/AUTHENTICATION_ARCHITECTURE.md`
|
||||
2. **Check Better Auth docs**: https://www.better-auth.com/docs
|
||||
3. **Ask**: "Does Better Auth already provide this?" - Usually YES
|
||||
4. **Use Context7**: Fetch Better Auth documentation before implementing
|
||||
|
||||
### 4. Token Validation Pattern
|
||||
|
||||
```typescript
|
||||
// CORRECT - Use jose with JWKS
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
|
||||
const JWKS = createRemoteJWKSet(new URL('/api/v1/auth/jwks', baseUrl));
|
||||
const { payload } = await jwtVerify(token, JWKS, { issuer, audience });
|
||||
```
|
||||
|
||||
```typescript
|
||||
// WRONG - Never do this
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Auth**: Better Auth with JWT + Organization plugins
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **JWT Library**: `jose` (NOT `jsonwebtoken`)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm start:dev # Start with hot reload
|
||||
|
||||
# Build
|
||||
pnpm build # Production build
|
||||
|
||||
# Database
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:generate # Generate migrations
|
||||
pnpm db:migrate # Run migrations
|
||||
|
||||
# Testing
|
||||
pnpm test # Unit tests
|
||||
pnpm test:e2e # E2E tests
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
services/mana-core-auth/
|
||||
├── src/
|
||||
│ ├── auth/
|
||||
│ │ ├── better-auth.config.ts # Better Auth setup
|
||||
│ │ ├── services/
|
||||
│ │ │ └── better-auth.service.ts # Auth service
|
||||
│ │ ├── auth.controller.ts # Auth endpoints
|
||||
│ │ └── dto/ # Request DTOs
|
||||
│ ├── credits/ # Credit system
|
||||
│ │ ├── credits.service.ts # Personal credit operations
|
||||
│ │ ├── guild-pool.service.ts # Guild shared Mana pool
|
||||
│ │ ├── guild.controller.ts # /credits/guild/* endpoints
|
||||
│ │ └── dto/ # Credit DTOs (incl. creditSource)
|
||||
│ ├── guilds/ # Gilden (guild management)
|
||||
│ │ ├── guilds.controller.ts # /gilden/* endpoints (RPG-branded)
|
||||
│ │ ├── guilds.service.ts # Wraps Better Auth orgs + sub limits
|
||||
│ │ └── guilds.module.ts
|
||||
│ ├── db/
|
||||
│ │ ├── schema/ # Drizzle schemas
|
||||
│ │ │ ├── guilds.schema.ts # guild_pools, spending_limits, transactions
|
||||
│ │ │ └── ...
|
||||
│ │ ├── migrations/ # Generated migration files
|
||||
│ │ ├── connection.ts # DB connection
|
||||
│ │ └── migrate.ts # Migration script with advisory locks
|
||||
│ └── config/
|
||||
│ └── configuration.ts # App config
|
||||
├── postgres/init/
|
||||
│ ├── 03-organization-rls.sql # Org RLS policies
|
||||
│ └── 04-guild-rls.sql # Guild pool RLS policies
|
||||
├── docs/
|
||||
│ └── AUTHENTICATION_ARCHITECTURE.md # READ THIS FIRST
|
||||
└── test/
|
||||
└── e2e/
|
||||
└── guild-journey.e2e-spec.ts # Full guild E2E tests
|
||||
```
|
||||
|
||||
## Gilden (Guilds) - Shared Mana Pools
|
||||
|
||||
Guilds allow users to share a Mana pool (family, friends, teams). Uses Better Auth's organization plugin under the hood.
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Gilde** = Organization with a shared credit pool
|
||||
- **Gildenmeister** = Owner who manages the pool and members
|
||||
- **Mana-Pool** = Shared credit balance members spend from directly
|
||||
- **Spending Limits** = Optional per-member daily/monthly limits
|
||||
|
||||
### Endpoints
|
||||
|
||||
**Guild Management** (`/gilden/*`):
|
||||
|
||||
| Method | Endpoint | Who | Description |
|
||||
|--------|----------|-----|-------------|
|
||||
| POST | `/gilden` | Auth user | Create guild + pool |
|
||||
| GET | `/gilden` | Auth user | List user's guilds |
|
||||
| GET | `/gilden/:id` | Member | Guild details + pool + members |
|
||||
| PUT | `/gilden/:id` | Owner/Admin | Update guild |
|
||||
| DELETE | `/gilden/:id` | Owner | Delete guild (cascades pool) |
|
||||
| POST | `/gilden/:id/invite` | Owner/Admin | Invite member |
|
||||
| POST | `/gilden/accept-invitation` | Invitee | Accept invitation |
|
||||
| DELETE | `/gilden/:id/members/:mid` | Owner/Admin | Remove member |
|
||||
| PUT | `/gilden/:id/members/:mid/role` | Owner/Admin | Change role |
|
||||
|
||||
**Guild Credits** (`/credits/guild/*`):
|
||||
|
||||
| Method | Endpoint | Who | Description |
|
||||
|--------|----------|-----|-------------|
|
||||
| GET | `/credits/guild/:id/balance` | Member | Pool balance |
|
||||
| POST | `/credits/guild/:id/fund` | Owner/Admin | Fund from personal balance |
|
||||
| POST | `/credits/guild/:id/use` | Member | Use credits from pool |
|
||||
| GET | `/credits/guild/:id/transactions` | Member | Transaction history |
|
||||
| GET | `/credits/guild/:id/members/:uid/spending` | Member/Owner | Spending summary |
|
||||
| GET | `/credits/guild/:id/members/:uid/limits` | Member/Owner | Get limits |
|
||||
| PUT | `/credits/guild/:id/members/:uid/limits` | Owner/Admin | Set limits |
|
||||
|
||||
**Credit Source Routing**: `POST /credits/use` accepts optional `creditSource`:
|
||||
```json
|
||||
{
|
||||
"amount": 10,
|
||||
"appId": "chat",
|
||||
"description": "AI chat",
|
||||
"creditSource": { "type": "guild", "guildId": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### Subscription Limits
|
||||
|
||||
Guild creation and invites respect the user's subscription plan:
|
||||
- `maxOrganizations` = max guilds a user can own
|
||||
- `maxTeamMembers` = max members per guild
|
||||
- Free tier: 1 guild, 1 member (just themselves)
|
||||
|
||||
## Database Migrations
|
||||
|
||||
For comprehensive migration documentation, see **[docs/DATABASE_MIGRATIONS.md](/docs/DATABASE_MIGRATIONS.md)**.
|
||||
|
||||
Key points:
|
||||
- Use `db:push` for development (fast iteration)
|
||||
- Use `db:generate` + `db:migrate` for production (tracked migrations)
|
||||
- Migrations use advisory locks to prevent concurrent execution
|
||||
- CI/CD runs migrations automatically before code deployment
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------ | ------------------------------------------------ |
|
||||
| `src/auth/better-auth.config.ts` | Better Auth configuration with JWT + Org plugins |
|
||||
| `src/auth/services/better-auth.service.ts` | Main auth service - ALL auth logic here |
|
||||
| `src/db/schema/auth.schema.ts` | User, session, account, jwks tables |
|
||||
| `docs/AUTHENTICATION_ARCHITECTURE.md` | Comprehensive auth documentation |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Required
|
||||
DATABASE_URL=postgresql://...
|
||||
JWT_ISSUER=manacore
|
||||
JWT_AUDIENCE=manacore
|
||||
|
||||
# NOT required for Better Auth JWT (auto-generates EdDSA keys)
|
||||
# JWT_PRIVATE_KEY=... # DON'T USE - Better Auth uses jwks table
|
||||
# JWT_PUBLIC_KEY=... # DON'T USE - Better Auth uses jwks table
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a new auth endpoint
|
||||
|
||||
1. Check if Better Auth already provides it
|
||||
2. If yes, wrap it in `better-auth.service.ts`
|
||||
3. Expose via `auth.controller.ts`
|
||||
4. Add DTO validation
|
||||
|
||||
### Validating tokens from other services
|
||||
|
||||
Other services call `POST /api/v1/auth/validate` with the JWT. The validation uses Better Auth's JWKS (EdDSA keys from `auth.jwks` table).
|
||||
|
||||
### Adding JWT claims
|
||||
|
||||
**DON'T** add dynamic data to JWT claims. Keep them minimal:
|
||||
|
||||
- `sub` (user ID)
|
||||
- `email`
|
||||
- `role`
|
||||
- `sid` (session ID)
|
||||
|
||||
For dynamic data (credits, org info), create API endpoints instead.
|
||||
|
||||
## Debugging
|
||||
|
||||
### Token not validating?
|
||||
|
||||
1. Check algorithm: `echo $TOKEN | cut -d'.' -f1 | base64 -d`
|
||||
- Should be `EdDSA`, NOT `RS256`
|
||||
2. Check JWKS endpoint: `curl localhost:3001/api/v1/auth/jwks`
|
||||
3. Check issuer/audience match between signing and validation
|
||||
|
||||
### User can't sign in?
|
||||
|
||||
1. Check database connection
|
||||
2. Check `auth.users` table exists
|
||||
3. Check `auth.accounts` table for credential record
|
||||
|
||||
## Cross-Domain SSO
|
||||
|
||||
Session cookies are shared across all `*.mana.how` subdomains via `COOKIE_DOMAIN=.mana.how`.
|
||||
|
||||
**How it works:**
|
||||
1. User logs in on any app (e.g., `calendar.mana.how`)
|
||||
2. Session cookie set with `Domain=.mana.how`
|
||||
3. User navigates to another app (e.g., `todo.mana.how`)
|
||||
4. Browser sends the same cookie → User is already authenticated
|
||||
|
||||
**Configuration** (`better-auth.config.ts`):
|
||||
```typescript
|
||||
advanced: {
|
||||
cookiePrefix: 'mana',
|
||||
crossSubDomainCookies: {
|
||||
enabled: !!process.env.COOKIE_DOMAIN,
|
||||
domain: process.env.COOKIE_DOMAIN, // '.mana.how' in production
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Environment Variable:**
|
||||
- Production: `COOKIE_DOMAIN=.mana.how`
|
||||
- Development: Leave empty (cookies domain-specific)
|
||||
|
||||
**Adding a new app to SSO** (all 3 steps required):
|
||||
1. Add `https://{app}.mana.how` to `trustedOrigins` in `better-auth.config.ts`
|
||||
2. Add `https://{app}.mana.how` to `CORS_ORIGINS` for mana-auth in `docker-compose.macmini.yml`
|
||||
3. Run `pnpm test -- src/auth/sso-config.spec.ts` to verify alignment (47 contract tests)
|
||||
|
||||
## Test Credentials (Production)
|
||||
|
||||
For automated testing against `auth.mana.how`:
|
||||
|
||||
| Field | Value |
|
||||
| -------- | -------------------------- |
|
||||
| Email | `claude-test@mana.how` |
|
||||
| Password | `ClaudeTest2024` |
|
||||
| User ID | `kxMeQZSM1HhdiM1ed5EOQ9z0o0aCiXux` |
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Login (returns JWT tokens)
|
||||
curl -X POST https://auth.mana.how/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"claude-test@mana.how","password":"ClaudeTest2024"}'
|
||||
|
||||
# Login with cookies (Better Auth native - for SSO testing)
|
||||
curl -c cookies.txt -X POST https://auth.mana.how/api/auth/sign-in/email \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"claude-test@mana.how","password":"ClaudeTest2024"}'
|
||||
|
||||
# Verify cookie has Domain=.mana.how
|
||||
cat cookies.txt | grep mana.how
|
||||
```
|
||||
|
||||
## Testing Auth Flow (Local Development)
|
||||
|
||||
```bash
|
||||
# Register
|
||||
curl -X POST http://localhost:3001/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com", "password": "password123", "name": "Test"}'
|
||||
|
||||
# Login
|
||||
curl -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com", "password": "password123"}'
|
||||
|
||||
# Validate token
|
||||
curl -X POST http://localhost:3001/api/v1/auth/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"token": "eyJhbGciOiJFZERTQSIs..."}'
|
||||
```
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Build stage
|
||||
# Using node:20-slim instead of alpine for DuckDB glibc compatibility
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
# Install pnpm (no build tools needed — bcryptjs is pure JS, DuckDB ships prebuilt binaries)
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace files
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
COPY patches/ ./patches/
|
||||
|
||||
# Copy shared packages (required dependencies)
|
||||
COPY packages/shared-storage ./packages/shared-storage
|
||||
COPY packages/shared-llm ./packages/shared-llm
|
||||
|
||||
# Copy mana-core-auth
|
||||
COPY services/mana-core-auth ./services/mana-core-auth
|
||||
|
||||
# Install all dependencies (without ignore-scripts to build native modules like bcrypt)
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --no-frozen-lockfile --filter mana-core-auth... --filter @manacore/shared-storage --filter @manacore/shared-llm
|
||||
|
||||
# Build shared packages first
|
||||
WORKDIR /app/packages/shared-storage
|
||||
RUN pnpm build || true
|
||||
|
||||
WORKDIR /app/packages/shared-llm
|
||||
RUN pnpm build || true
|
||||
|
||||
# Build the application
|
||||
WORKDIR /app/services/mana-core-auth
|
||||
RUN pnpm build
|
||||
|
||||
# Remove devDependencies but keep native modules intact
|
||||
WORKDIR /app
|
||||
RUN pnpm prune --prod --no-optional 2>/dev/null || true \
|
||||
&& find node_modules -name '*.ts' -not -name '*.d.ts' -delete 2>/dev/null || true \
|
||||
&& find node_modules -name '*.map' -delete 2>/dev/null || true \
|
||||
&& find node_modules -type d -name 'test' -prune -exec rm -rf {} + 2>/dev/null || true \
|
||||
&& find node_modules -type d -name 'tests' -prune -exec rm -rf {} + 2>/dev/null || true \
|
||||
&& find node_modules -type d -name '__tests__' -prune -exec rm -rf {} + 2>/dev/null || true \
|
||||
&& find node_modules -type d -name 'docs' -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Production stage
|
||||
# Using node:20-slim instead of alpine for DuckDB glibc compatibility
|
||||
FROM node:20-slim AS production
|
||||
|
||||
# Install wget for health checks
|
||||
RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user before copying files
|
||||
RUN groupadd -g 1001 nodejs && \
|
||||
useradd -u 1001 -g nodejs nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy files with correct ownership (avoids expensive chown -R layer)
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages ./packages
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-core-auth ./services/mana-core-auth
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY --chown=nestjs:nodejs services/mana-core-auth/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
WORKDIR /app/services/mana-core-auth
|
||||
|
||||
# Switch to non-root user
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
|
||||
|
||||
# Start the application
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# Database Setup - Mana Core Auth
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses **Drizzle ORM** with a push-based approach for database schema management. Since this is a greenfield project, we use `db:push` to sync schemas directly to PostgreSQL.
|
||||
|
||||
## Schema Files
|
||||
|
||||
All database tables are defined in TypeScript:
|
||||
|
||||
```
|
||||
src/db/schema/
|
||||
├── auth.schema.ts # Users, sessions, passwords, 2FA
|
||||
├── organizations.schema.ts # B2B orgs, members, invitations
|
||||
├── credits.schema.ts # Balances, transactions, packages
|
||||
└── index.ts # Export all schemas
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | ------------------------------------- |
|
||||
| `pnpm db:push` | Sync schema to database |
|
||||
| `pnpm db:studio` | Open Drizzle Studio to view/edit data |
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
### 1. Start PostgreSQL
|
||||
|
||||
```bash
|
||||
docker compose up postgres -d
|
||||
```
|
||||
|
||||
### 2. Push Schema
|
||||
|
||||
```bash
|
||||
cd services/mana-core-auth
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
### 3. Apply RLS Policies
|
||||
|
||||
```bash
|
||||
# These run automatically in Docker, or manually:
|
||||
psql $DATABASE_URL -f postgres/init/01-init-schemas.sql
|
||||
psql $DATABASE_URL -f postgres/init/02-init-rls.sql
|
||||
psql $DATABASE_URL -f postgres/init/03-organization-rls.sql
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
When using Docker Compose, the entrypoint script automatically runs `pnpm db:push --force` before starting the service. No manual intervention needed.
|
||||
|
||||
## Making Schema Changes
|
||||
|
||||
1. Edit the schema files in `src/db/schema/`
|
||||
2. Run `pnpm db:push` to sync changes
|
||||
3. Commit schema changes to git
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@host:5432/dbname
|
||||
```
|
||||
|
||||
## Postgres Init Scripts
|
||||
|
||||
Located in `postgres/init/`:
|
||||
|
||||
- `01-init-schemas.sql` - Creates auth and credits schemas
|
||||
- `02-init-rls.sql` - Base RLS policies
|
||||
- `03-organization-rls.sql` - Organization RLS policies
|
||||
|
||||
These run automatically when PostgreSQL container starts for the first time.
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
# Quick Start Guide - Mana Core Auth
|
||||
|
||||
Get the authentication system running in 5 minutes!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9.15.0+
|
||||
- Docker & Docker Compose
|
||||
- OpenSSL (for key generation)
|
||||
|
||||
## Step 1: Configure Environment (1 minute)
|
||||
|
||||
```bash
|
||||
# Copy the example
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env and add:
|
||||
# 1. Change default passwords
|
||||
# 2. Add Stripe test keys (optional for now)
|
||||
```
|
||||
|
||||
**Minimum required changes in .env:**
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://mana:mana@localhost:5432/mana_auth
|
||||
REDIS_HOST=localhost
|
||||
```
|
||||
|
||||
Note: JWT keys are auto-generated by Better Auth (EdDSA algorithm) and stored in the database.
|
||||
|
||||
## Step 2: Start Infrastructure (30 seconds)
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
docker-compose up postgres redis -d
|
||||
|
||||
# Wait for services to be healthy
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## Step 3: Run Migrations (10 seconds)
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
pnpm migration:run
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
Running migrations...
|
||||
Migrations completed successfully
|
||||
```
|
||||
|
||||
## Step 4: Start the Service (10 seconds)
|
||||
|
||||
```bash
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```
|
||||
🚀 Mana Core Auth running on: http://localhost:3001
|
||||
📚 Environment: development
|
||||
```
|
||||
|
||||
## Test It Works!
|
||||
|
||||
### 1. Register a User
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "test@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "Test User"
|
||||
}'
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"createdAt": "2025-11-25T..."
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Login
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "test@example.com",
|
||||
"password": "SecurePass123!"
|
||||
}'
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "uuid-here",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"role": "user"
|
||||
},
|
||||
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
|
||||
"refreshToken": "long-random-string",
|
||||
"expiresIn": 900,
|
||||
"tokenType": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Check Credit Balance
|
||||
|
||||
```bash
|
||||
# Replace YOUR_TOKEN with accessToken from login
|
||||
curl -X GET http://localhost:3001/api/v1/credits/balance \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 0,
|
||||
"totalEarned": 0,
|
||||
"totalSpent": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Some Credits
|
||||
|
||||
First, you'll need to add credits via Stripe or a gift code. Then:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/v1/credits/use \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"amount": 10,
|
||||
"appId": "test",
|
||||
"description": "Test credit usage",
|
||||
"idempotencyKey": "test-unique-123"
|
||||
}'
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"transaction": {
|
||||
"id": "uuid-here",
|
||||
"userId": "uuid-here",
|
||||
"type": "usage",
|
||||
"status": "completed",
|
||||
"amount": -10,
|
||||
"balanceBefore": 100,
|
||||
"balanceAfter": 90,
|
||||
"appId": "test",
|
||||
"description": "Test credit usage"
|
||||
},
|
||||
"newBalance": {
|
||||
"balance": 90,
|
||||
"totalSpent": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## You're Done! 🎉
|
||||
|
||||
The authentication system is now running and ready to use.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Integrate with your apps**
|
||||
- Add the auth endpoints to your mobile/web apps
|
||||
- Implement token refresh logic
|
||||
- Store tokens securely (SecureStore on mobile, httpOnly cookies on web)
|
||||
|
||||
2. **Add Stripe integration**
|
||||
- Get Stripe API keys
|
||||
- Add webhook endpoint
|
||||
- Create credit packages
|
||||
- Test payment flow
|
||||
|
||||
3. **Production deployment**
|
||||
- Follow DOCKER_DEPLOYMENT_GUIDE.md
|
||||
- Set up on VPS
|
||||
- Configure domain and SSL
|
||||
- Enable monitoring
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Connection refused" to PostgreSQL
|
||||
|
||||
**Problem:** Database not ready yet
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
docker-compose ps # Check if postgres is healthy
|
||||
docker-compose logs postgres # Check logs
|
||||
```
|
||||
|
||||
### "JWKS not found" error
|
||||
|
||||
**Problem:** Better Auth hasn't initialized JWT keys yet
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Make sure the database is running and migrations have been applied
|
||||
pnpm db:push
|
||||
|
||||
# The JWKS keys are auto-generated on first request
|
||||
# Try making a login request to initialize them
|
||||
```
|
||||
|
||||
### Migrations fail
|
||||
|
||||
**Problem:** Database schema issues
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Drop and recreate database
|
||||
docker-compose down -v
|
||||
docker-compose up postgres -d
|
||||
# Wait 10 seconds
|
||||
pnpm migration:run
|
||||
```
|
||||
|
||||
### Port 3001 already in use
|
||||
|
||||
**Problem:** Another service is using the port
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Change PORT in .env
|
||||
echo "PORT=3002" >> .env
|
||||
|
||||
# Or kill the process using 3001
|
||||
lsof -ti:3001 | xargs kill
|
||||
```
|
||||
|
||||
## Development Tips
|
||||
|
||||
### Watch Database Changes
|
||||
|
||||
```bash
|
||||
pnpm db:studio
|
||||
# Opens Drizzle Studio at http://localhost:4983
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
# The service prints to console when running in dev mode
|
||||
|
||||
# Docker logs
|
||||
docker-compose logs -f postgres
|
||||
docker-compose logs -f redis
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
pnpm test:watch
|
||||
pnpm test:cov
|
||||
```
|
||||
|
||||
### Format Code
|
||||
|
||||
```bash
|
||||
pnpm format
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
pnpm start:dev
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
# Start production server
|
||||
pnpm start:prod
|
||||
|
||||
# Generate new migration
|
||||
pnpm migration:generate
|
||||
|
||||
# Run migrations
|
||||
pnpm migration:run
|
||||
|
||||
# Open database GUI
|
||||
pnpm db:studio
|
||||
```
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
### Required
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
|
||||
### Optional (have defaults)
|
||||
|
||||
- `PORT` - Server port (default: 3001)
|
||||
- `NODE_ENV` - Environment (default: development)
|
||||
- `REDIS_HOST` - Redis host (default: localhost)
|
||||
- `CORS_ORIGINS` - Allowed origins (default: localhost:3000,localhost:8081)
|
||||
- `BASE_URL` - Base URL for JWKS (default: http://localhost:3001)
|
||||
|
||||
### For Production
|
||||
|
||||
- `STRIPE_SECRET_KEY` - Stripe secret key
|
||||
- `STRIPE_WEBHOOK_SECRET` - Stripe webhook signing secret
|
||||
- `ACME_EMAIL` - Email for Let's Encrypt SSL
|
||||
- `AUTH_DOMAIN` - Domain name for the service
|
||||
|
||||
## Resources
|
||||
|
||||
- **Full Documentation:** `README.md`
|
||||
- **Database Schema:** `docs/DATABASE_SCHEMA.md`
|
||||
- **Migration Guide:** `MIGRATIONS.md`
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Check this guide first
|
||||
2. Review the logs
|
||||
3. Consult the master plan
|
||||
4. Ask the development team
|
||||
|
||||
---
|
||||
|
||||
**Time to Complete:** ~5 minutes
|
||||
|
||||
**Status:** Ready for Development & Testing
|
||||
|
|
@ -1,277 +0,0 @@
|
|||
# Mana Core Auth
|
||||
|
||||
Central authentication and credit management system for the Mana Universe ecosystem.
|
||||
|
||||
## Features
|
||||
|
||||
- **JWT-based Authentication** (EdDSA algorithm via Better Auth)
|
||||
- User registration and login
|
||||
- Refresh token rotation
|
||||
- Multi-session management
|
||||
- JWKS endpoint for token verification
|
||||
|
||||
- **Credit System**
|
||||
- User balance management
|
||||
- Transaction ledger (purchase, usage, refund, gift)
|
||||
- Optimistic locking for concurrency
|
||||
- Idempotency for credit operations
|
||||
- Gift code system with auto-redemption on registration
|
||||
|
||||
- **Security**
|
||||
- Row-Level Security (RLS) on PostgreSQL
|
||||
- Rate limiting
|
||||
- CORS protection
|
||||
- Helmet security headers
|
||||
- SCRAM-SHA-256 password authentication
|
||||
|
||||
- **Infrastructure**
|
||||
- Docker-based deployment
|
||||
- Traefik reverse proxy with automatic SSL
|
||||
- PgBouncer connection pooling
|
||||
- Redis caching
|
||||
- Prometheus + Grafana monitoring
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Generate JWT keys**
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
./scripts/generate-keys.sh
|
||||
```
|
||||
|
||||
3. **Set up environment variables**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and add your JWT keys and other configuration
|
||||
```
|
||||
|
||||
4. **Start PostgreSQL and Redis** (using Docker)
|
||||
|
||||
```bash
|
||||
docker-compose up postgres redis -d
|
||||
```
|
||||
|
||||
5. **Run migrations**
|
||||
|
||||
```bash
|
||||
pnpm migration:generate
|
||||
pnpm migration:run
|
||||
```
|
||||
|
||||
6. **Start development server**
|
||||
|
||||
```bash
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
The server will be available at `http://localhost:3001/api/v1`
|
||||
|
||||
### Production Deployment (Docker)
|
||||
|
||||
1. **Set up environment variables**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with production values
|
||||
```
|
||||
|
||||
2. **Generate JWT keys**
|
||||
|
||||
```bash
|
||||
./mana-core-auth/scripts/generate-keys.sh
|
||||
# Add the generated keys to .env
|
||||
```
|
||||
|
||||
3. **Start all services**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Check service health**
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker-compose logs -f mana-core-auth
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
**POST** `/api/v1/auth/register`
|
||||
|
||||
- Register a new user
|
||||
- Body: `{ email, password, name? }`
|
||||
- Returns: User object
|
||||
|
||||
**POST** `/api/v1/auth/login`
|
||||
|
||||
- Login with email and password
|
||||
- Body: `{ email, password, deviceId?, deviceName? }`
|
||||
- Returns: `{ user, accessToken, refreshToken, expiresIn, tokenType }`
|
||||
|
||||
**POST** `/api/v1/auth/refresh`
|
||||
|
||||
- Refresh access token
|
||||
- Body: `{ refreshToken }`
|
||||
- Returns: New token pair
|
||||
|
||||
**POST** `/api/v1/auth/logout`
|
||||
|
||||
- Logout and revoke session
|
||||
- Requires: Bearer token
|
||||
- Returns: Success message
|
||||
|
||||
**POST** `/api/v1/auth/validate`
|
||||
|
||||
- Validate a JWT token
|
||||
- Body: `{ token }`
|
||||
- Returns: `{ valid, payload }`
|
||||
|
||||
### Credits
|
||||
|
||||
**GET** `/api/v1/credits/balance`
|
||||
|
||||
- Get current credit balance
|
||||
- Requires: Bearer token
|
||||
- Returns: `{ balance, totalEarned, totalSpent }`
|
||||
|
||||
**POST** `/api/v1/credits/use`
|
||||
|
||||
- Deduct credits from balance
|
||||
- Requires: Bearer token
|
||||
- Body: `{ amount, appId, description, idempotencyKey?, metadata? }`
|
||||
- Returns: Transaction details
|
||||
|
||||
**GET** `/api/v1/credits/transactions?limit=50&offset=0`
|
||||
|
||||
- Get transaction history
|
||||
- Requires: Bearer token
|
||||
- Returns: Array of transactions
|
||||
|
||||
**GET** `/api/v1/credits/purchases`
|
||||
|
||||
- Get purchase history
|
||||
- Requires: Bearer token
|
||||
- Returns: Array of purchases
|
||||
|
||||
**GET** `/api/v1/credits/packages`
|
||||
|
||||
- Get available credit packages
|
||||
- Requires: Bearer token
|
||||
- Returns: Array of packages
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Auth Schema
|
||||
|
||||
- `auth.users` - User accounts
|
||||
- `auth.sessions` - Active sessions
|
||||
- `auth.passwords` - Hashed passwords
|
||||
- `auth.accounts` - OAuth provider accounts
|
||||
- `auth.verification_tokens` - Email verification & password reset
|
||||
- `auth.two_factor_auth` - 2FA configuration
|
||||
- `auth.security_events` - Security audit log
|
||||
|
||||
### Credits Schema
|
||||
|
||||
- `credits.balances` - User credit balances
|
||||
- `credits.transactions` - Transaction ledger
|
||||
- `credits.packages` - Available credit packages
|
||||
- `credits.purchases` - Purchase history
|
||||
- `credits.usage_stats` - Usage analytics
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for all available configuration options.
|
||||
|
||||
Key variables:
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` - Redis configuration
|
||||
- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` - Stripe integration
|
||||
- `CORS_ORIGINS` - Allowed origins for CORS
|
||||
- `BASE_URL` - Base URL for JWKS endpoint (e.g., http://localhost:3001)
|
||||
|
||||
## Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Start development server with hot-reload
|
||||
pnpm start:dev
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
# Start production server
|
||||
pnpm start:prod
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Generate database migration
|
||||
pnpm migration:generate
|
||||
|
||||
# Run migrations
|
||||
pnpm migration:run
|
||||
|
||||
# Open Drizzle Studio (database GUI)
|
||||
pnpm db:studio
|
||||
|
||||
# Lint and format
|
||||
pnpm lint
|
||||
pnpm format
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Token Flow
|
||||
|
||||
1. User registers/logs in → Receives `accessToken` (15min) + `refreshToken` (7 days)
|
||||
2. Client stores tokens securely (httpOnly cookies on web, SecureStore on mobile)
|
||||
3. Client includes `Authorization: Bearer <accessToken>` in requests
|
||||
4. When access token expires, client uses refresh token to get new pair
|
||||
5. Refresh tokens are single-use (rotation for security)
|
||||
|
||||
### Credit System
|
||||
|
||||
- **Paid Credits**: Purchased via Stripe (100 mana = €1)
|
||||
- **Gift Codes**: Can be created and redeemed, auto-redeem on registration if pending
|
||||
- **Transaction Types**: purchase, usage, refund, gift
|
||||
- **Idempotency**: Duplicate requests with same key are detected and ignored
|
||||
- **Concurrency**: Optimistic locking prevents race conditions
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **JWT Keys**: Better Auth auto-generates EdDSA keys stored in `auth.jwks` table
|
||||
2. **Database**: Use strong passwords and enable SSL in production
|
||||
3. **Redis**: Always set a password for Redis
|
||||
4. **CORS**: Only allow trusted origins
|
||||
5. **Rate Limiting**: Configured via Traefik and NestJS throttler
|
||||
6. **RLS Policies**: Enforce data isolation at database level
|
||||
7. **HTTPS**: Always use SSL/TLS in production (via Traefik)
|
||||
|
||||
## Monitoring
|
||||
|
||||
- **Prometheus**: Available at `http://localhost:9090`
|
||||
- **Grafana**: Available at `http://localhost:3000`
|
||||
- **Logs**: `docker-compose logs -f mana-core-auth`
|
||||
|
||||
## License
|
||||
|
||||
Private - Mana Universe
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions, contact the development team.
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/**
|
||||
* Better Auth CLI configuration file
|
||||
* This file is used by the Better Auth CLI to generate the schema.
|
||||
* Run: npx @better-auth/cli generate --output ./src/db/schema/better-auth-schema.ts
|
||||
*/
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { organization } from 'better-auth/plugins/organization';
|
||||
import { jwt } from 'better-auth/plugins/jwt';
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Use simple URL-based connection for CLI
|
||||
database: {
|
||||
type: 'postgres',
|
||||
url: 'postgresql://manacore:devpassword@localhost:5432/manacore',
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [
|
||||
organization({
|
||||
allowUserToCreateOrganization: true,
|
||||
}),
|
||||
jwt(),
|
||||
],
|
||||
});
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Skip migrations in Docker - tables are managed via 'pnpm db:push' locally
|
||||
# For fresh databases, run 'pnpm db:push' manually first
|
||||
echo "📋 Skipping migrations (run 'pnpm db:push' locally if needed)"
|
||||
|
||||
# Start the application
|
||||
echo "🚀 Starting Mana Core Auth..."
|
||||
exec node dist/main.js
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
# Authentication Architecture
|
||||
|
||||
> **Decision Date**: December 2024
|
||||
> **Status**: Active
|
||||
> **Last Updated**: February 16, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
Mana Core Auth uses [Better Auth](https://www.better-auth.com/) as the authentication framework. This document explains the architecture, common pitfalls, and how to correctly implement authentication.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL: Always Use Better Auth Native Features
|
||||
|
||||
**DO NOT** implement custom JWT signing/verification. Better Auth handles everything.
|
||||
|
||||
### Better Auth Provides:
|
||||
- ✅ JWT signing with EdDSA (via JWT plugin)
|
||||
- ✅ JWKS endpoint for public keys
|
||||
- ✅ Session management
|
||||
- ✅ Organization/multi-tenant support
|
||||
- ✅ Token refresh
|
||||
|
||||
### DO NOT:
|
||||
- ❌ Use `jsonwebtoken` library for signing (Better Auth uses `jose` with EdDSA)
|
||||
- ❌ Configure RS256 keys in `.env` (Better Auth uses EdDSA with auto-generated keys)
|
||||
- ❌ Implement custom JWKS endpoints (Better Auth exposes `/api/auth/jwks`)
|
||||
- ❌ Store JWT keys manually (Better Auth stores them in `jwks` table)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ MANA CORE AUTH │
|
||||
│ (localhost:3001) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
|
||||
│ │ Better Auth │ │ JWT Plugin │ │ Organization │ │
|
||||
│ │ (Core) │ │ (EdDSA) │ │ Plugin │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ - Sign Up │ │ - Sign JWT │ │ - Create Org │ │
|
||||
│ │ - Sign In │ │ - Verify JWT │ │ - Invite │ │
|
||||
│ │ - Sessions │ │ - JWKS Endpoint │ │ - Roles │ │
|
||||
│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────────┼──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────▼─────────────┐ │
|
||||
│ │ PostgreSQL (auth) │ │
|
||||
│ │ │ │
|
||||
│ │ - users │ │
|
||||
│ │ - sessions │ │
|
||||
│ │ - accounts │ │
|
||||
│ │ - jwks (EdDSA keys) │ │
|
||||
│ │ - organizations │ │
|
||||
│ │ - members │ │
|
||||
│ └───────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ JWT (EdDSA)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ CLIENT SERVICES │
|
||||
│ (Chat Backend, Mobile App, Web App) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Client sends JWT in Authorization header │
|
||||
│ 2. Service calls POST /api/v1/auth/validate │
|
||||
│ 3. mana-core-auth verifies via JWKS (EdDSA) │
|
||||
│ 4. Returns { valid: true, payload: {...} } │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JWT Configuration
|
||||
|
||||
### Better Auth JWT Plugin (EdDSA - DEFAULT)
|
||||
|
||||
Better Auth's JWT plugin uses **EdDSA** algorithm by default with auto-generated keys stored in the `jwks` table.
|
||||
|
||||
```typescript
|
||||
// src/auth/better-auth.config.ts
|
||||
jwt({
|
||||
jwt: {
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||
expirationTime: '15m',
|
||||
|
||||
definePayload({ user, session }) {
|
||||
return {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role || 'user',
|
||||
sid: session.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
### JWT Claims (Minimal)
|
||||
|
||||
**ONLY these claims should be in the JWT:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
sub: string; // User ID
|
||||
email: string; // User email
|
||||
role: string; // User role (user, admin, service)
|
||||
sid: string; // Session ID for reference
|
||||
iss: string; // Issuer (manacore)
|
||||
aud: string; // Audience (manacore)
|
||||
exp: number; // Expiration timestamp
|
||||
}
|
||||
```
|
||||
|
||||
**DO NOT add:**
|
||||
- `credit_balance` - Changes too frequently, fetch via API
|
||||
- `organization` - Use Better Auth org plugin APIs
|
||||
- `customer_type` - Derive from `activeOrganizationId`
|
||||
- `permissions` - Fetch from org membership API
|
||||
|
||||
---
|
||||
|
||||
## Token Validation Flow
|
||||
|
||||
### How Services Validate JWTs
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Chat Backend│ │ mana-core-auth │ │ jwks table │
|
||||
└─────┬───────┘ └────────┬─────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ POST /api/v1/auth/validate │
|
||||
│ { token: "eyJ..." } │ │
|
||||
│───────────────────────>│ │
|
||||
│ │ │
|
||||
│ │ GET /api/v1/auth/jwks │
|
||||
│ │─────────────────────────>│
|
||||
│ │ │
|
||||
│ │<─────────────────────────│
|
||||
│ │ { keys: [...] } │
|
||||
│ │ │
|
||||
│ │ jwtVerify(token, JWKS) │
|
||||
│ │ (using jose library) │
|
||||
│ │ │
|
||||
│<───────────────────────│ │
|
||||
│ { valid: true, │ │
|
||||
│ payload: {...} } │ │
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// src/auth/services/better-auth.service.ts
|
||||
async validateToken(token: string): Promise<ValidateTokenResult> {
|
||||
// Use jose library (NOT jsonwebtoken!)
|
||||
const JWKS = createRemoteJWKSet(
|
||||
new URL('/api/v1/auth/jwks', 'http://localhost:3001')
|
||||
);
|
||||
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer: 'manacore',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
return { valid: true, payload };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes & Fixes
|
||||
|
||||
### ❌ Mistake 1: Using RS256 with jsonwebtoken
|
||||
|
||||
```typescript
|
||||
// WRONG - Don't do this!
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
const token = jwt.sign(payload, privateKey, {
|
||||
algorithm: 'RS256', // Better Auth uses EdDSA!
|
||||
});
|
||||
|
||||
jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'], // Will fail for Better Auth tokens
|
||||
});
|
||||
```
|
||||
|
||||
**Fix:** Use `jose` library with Better Auth's JWKS:
|
||||
|
||||
```typescript
|
||||
// CORRECT
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
|
||||
const JWKS = createRemoteJWKSet(new URL('/api/v1/auth/jwks', baseUrl));
|
||||
const { payload } = await jwtVerify(token, JWKS, { issuer, audience });
|
||||
```
|
||||
|
||||
### ❌ Mistake 2: Configuring JWT keys in .env
|
||||
|
||||
```env
|
||||
# WRONG - These are for RS256, Better Auth uses EdDSA
|
||||
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----..."
|
||||
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----..."
|
||||
```
|
||||
|
||||
**Fix:** Better Auth auto-generates EdDSA keys and stores them in `auth.jwks` table. No manual key configuration needed for JWT signing.
|
||||
|
||||
### ❌ Mistake 3: Issuer Mismatch
|
||||
|
||||
```typescript
|
||||
// WRONG - Hardcoded issuer different from config
|
||||
jwt({
|
||||
jwt: {
|
||||
issuer: 'mana-core', // Signing with this
|
||||
},
|
||||
});
|
||||
|
||||
// But validating with:
|
||||
jwtVerify(token, JWKS, {
|
||||
issuer: 'manacore', // Different! Will fail.
|
||||
});
|
||||
```
|
||||
|
||||
**Fix:** Use consistent issuer from environment:
|
||||
|
||||
```typescript
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/auth/register` | POST | Register B2C user |
|
||||
| `/api/v1/auth/login` | POST | Sign in, returns JWT |
|
||||
| `/api/v1/auth/logout` | POST | Sign out |
|
||||
| `/api/v1/auth/refresh` | POST | Refresh access token |
|
||||
| `/api/v1/auth/validate` | POST | Validate JWT token |
|
||||
| `/api/v1/auth/jwks` | GET | Get JWKS public keys |
|
||||
| `/api/v1/auth/session` | GET | Get current session |
|
||||
|
||||
### Organizations (B2B)
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/auth/register/b2b` | POST | Register organization |
|
||||
| `/api/v1/auth/organizations` | GET | List user's orgs |
|
||||
| `/api/v1/auth/organizations/:id` | GET | Get org details |
|
||||
| `/api/v1/auth/organizations/:id` | PUT | Update org (name, logo, metadata) |
|
||||
| `/api/v1/auth/organizations/:id` | DELETE | Delete organization (owner only) |
|
||||
| `/api/v1/auth/organizations/:id/invite` | POST | Invite employee |
|
||||
| `/api/v1/auth/organizations/:id/members` | GET | List org members |
|
||||
| `/api/v1/auth/organizations/:id/members/:memberId` | DELETE | Remove member |
|
||||
| `/api/v1/auth/organizations/:orgId/members/:memberId/role` | PATCH | Update member role |
|
||||
| `/api/v1/auth/organizations/:id/invitations` | GET | List org invitations |
|
||||
| `/api/v1/auth/organizations/set-active` | POST | Switch active org |
|
||||
| `/api/v1/auth/organizations/accept-invitation` | POST | Accept invitation |
|
||||
|
||||
### Invitations
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/auth/invitations` | GET | List user's pending invitations |
|
||||
| `/api/v1/auth/invitations/:id` | DELETE | Cancel or reject invitation |
|
||||
|
||||
---
|
||||
|
||||
## Token Storage (Frontend)
|
||||
|
||||
```typescript
|
||||
// Storage keys used by @manacore/shared-auth
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: '@auth/appToken', // JWT access token
|
||||
REFRESH_TOKEN: '@auth/refreshToken', // Session token for refresh
|
||||
USER_EMAIL: '@auth/userEmail',
|
||||
};
|
||||
|
||||
// Reading token for API calls
|
||||
const token = localStorage.getItem('@auth/appToken');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### jwks Table (Better Auth JWT Plugin)
|
||||
|
||||
```sql
|
||||
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 (JSON)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
Better Auth automatically:
|
||||
1. Creates keys on first JWT sign
|
||||
2. Stores them in this table
|
||||
3. Uses them for all subsequent operations
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Check JWT Algorithm
|
||||
|
||||
```bash
|
||||
# Decode JWT header (without verification)
|
||||
echo "eyJhbG..." | cut -d'.' -f1 | base64 -d
|
||||
|
||||
# Should show: { "alg": "EdDSA", "kid": "..." }
|
||||
# If you see "RS256", something is wrong!
|
||||
```
|
||||
|
||||
### Test JWKS Endpoint
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/v1/auth/jwks
|
||||
# Should return: { "keys": [{ "crv": "Ed25519", "kty": "OKP", ... }] }
|
||||
```
|
||||
|
||||
### Test Token Validation
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/v1/auth/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"token": "eyJhbGciOiJFZERTQSIs..."}'
|
||||
|
||||
# Should return: { "valid": true, "payload": {...} }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/auth/better-auth.config.ts` | Better Auth configuration |
|
||||
| `src/auth/services/better-auth.service.ts` | Auth service with JWT validation |
|
||||
| `src/auth/auth.controller.ts` | Auth endpoints including `/jwks` |
|
||||
| `src/db/schema/auth.schema.ts` | Database schema including `jwks` table |
|
||||
| `src/config/configuration.ts` | Environment configuration |
|
||||
|
||||
---
|
||||
|
||||
## Checklist for New Developers
|
||||
|
||||
- [ ] Read Better Auth documentation: https://www.better-auth.com/docs
|
||||
- [ ] Understand that Better Auth uses **EdDSA**, not RS256
|
||||
- [ ] Never use `jsonwebtoken` for Better Auth tokens - use `jose`
|
||||
- [ ] JWT validation must use JWKS endpoint, not static keys
|
||||
- [ ] Keep JWT claims minimal - fetch dynamic data via APIs
|
||||
- [ ] Test with actual Better Auth tokens, not manually created ones
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
# Database Schema Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Mana Core authentication service uses PostgreSQL with two main schemas:
|
||||
|
||||
- `auth` - User authentication, sessions, and organization management
|
||||
- `credits` - Credit system for users
|
||||
|
||||
## Schema Diagrams
|
||||
|
||||
### Authentication Schema (auth)
|
||||
|
||||
```
|
||||
auth.users (UUID)
|
||||
├── auth.sessions (user sessions)
|
||||
├── auth.accounts (OAuth providers + credentials)
|
||||
├── auth.verifications (email verification, password reset)
|
||||
├── auth.jwks (EdDSA keys for JWT signing)
|
||||
├── auth.members (organization membership) ──┐
|
||||
└── auth.invitations (org invitations) ───────┤
|
||||
│
|
||||
auth.organizations (TEXT) ←───────────────────┘
|
||||
```
|
||||
|
||||
### Credits Schema (credits)
|
||||
|
||||
```
|
||||
credits.balances (user credit balances)
|
||||
├── credits.transactions (all credit movements)
|
||||
├── credits.purchases (credit purchases via Stripe)
|
||||
├── credits.packages (pricing tiers)
|
||||
└── credits.gift_codes (gift codes for sharing credits)
|
||||
```
|
||||
|
||||
## Core Tables
|
||||
|
||||
### auth.users
|
||||
|
||||
Main user table managed by Better Auth.
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
email_verified BOOLEAN DEFAULT false,
|
||||
image TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### auth.sessions
|
||||
|
||||
Active user sessions.
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### auth.jwks
|
||||
|
||||
EdDSA keys for JWT signing (managed by Better Auth).
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.jwks (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Credit Tables
|
||||
|
||||
### credits.balances
|
||||
|
||||
User credit balances with optimistic locking.
|
||||
|
||||
```sql
|
||||
CREATE TABLE credits.balances (
|
||||
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
balance INTEGER DEFAULT 0 NOT NULL,
|
||||
total_earned INTEGER DEFAULT 0 NOT NULL,
|
||||
total_spent INTEGER DEFAULT 0 NOT NULL,
|
||||
version INTEGER DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
|
||||
- `balance`: Current available credits
|
||||
- `total_earned`: Lifetime credits received (purchases + gifts)
|
||||
- `total_spent`: Lifetime credits spent
|
||||
- `version`: Enables optimistic locking to prevent race conditions
|
||||
|
||||
### credits.transactions
|
||||
|
||||
Immutable ledger of all credit movements.
|
||||
|
||||
```sql
|
||||
CREATE TABLE credits.transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL, -- 'purchase', 'usage', 'refund', 'gift'
|
||||
status TEXT NOT NULL, -- 'pending', 'completed', 'failed'
|
||||
amount INTEGER NOT NULL, -- Positive for credits in, negative for out
|
||||
balance_before INTEGER NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
app_id TEXT, -- Which app used credits
|
||||
description TEXT,
|
||||
idempotency_key TEXT UNIQUE, -- Prevent duplicate transactions
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX transactions_user_id_idx ON credits.transactions(user_id);
|
||||
CREATE INDEX transactions_created_at_idx ON credits.transactions(created_at);
|
||||
CREATE INDEX transactions_app_id_idx ON credits.transactions(app_id);
|
||||
```
|
||||
|
||||
**Transaction Types:**
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `purchase` | Credits bought via Stripe |
|
||||
| `usage` | Credits spent in an app |
|
||||
| `refund` | Credits returned (e.g., failed operation) |
|
||||
| `gift` | Credits received via gift code |
|
||||
|
||||
### credits.packages
|
||||
|
||||
Available credit packages for purchase.
|
||||
|
||||
```sql
|
||||
CREATE TABLE credits.packages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
credits INTEGER NOT NULL,
|
||||
price_euro_cents INTEGER NOT NULL,
|
||||
stripe_price_id TEXT,
|
||||
active BOOLEAN DEFAULT true,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### credits.purchases
|
||||
|
||||
Purchase history linked to Stripe.
|
||||
|
||||
```sql
|
||||
CREATE TABLE credits.purchases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
package_id UUID REFERENCES credits.packages(id),
|
||||
credits INTEGER NOT NULL,
|
||||
price_euro_cents INTEGER NOT NULL,
|
||||
stripe_payment_intent_id TEXT,
|
||||
stripe_checkout_session_id TEXT,
|
||||
status TEXT NOT NULL, -- 'pending', 'completed', 'failed', 'refunded'
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### credits.gift_codes
|
||||
|
||||
Gift codes for sharing credits.
|
||||
|
||||
```sql
|
||||
CREATE TABLE credits.gift_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
credits INTEGER NOT NULL,
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
redeemed_by UUID REFERENCES auth.users(id),
|
||||
target_email TEXT, -- If set, only this email can redeem
|
||||
expires_at TIMESTAMPTZ,
|
||||
redeemed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `target_email`: Pre-assign gift to specific email (auto-redeems on registration)
|
||||
- `expires_at`: Optional expiration date
|
||||
- `redeemed_by` + `redeemed_at`: Track redemption
|
||||
|
||||
## Organization Tables (for Auth only)
|
||||
|
||||
Organizations are used for team management, not credits.
|
||||
|
||||
### auth.organizations
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE,
|
||||
logo TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### auth.members
|
||||
|
||||
Links users to organizations with roles.
|
||||
|
||||
```sql
|
||||
CREATE TABLE auth.members (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL, -- 'owner', 'admin', 'member'
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Optimistic Locking
|
||||
|
||||
The `credits.balances` table uses a `version` column for optimistic locking:
|
||||
|
||||
```typescript
|
||||
// Prevent race conditions when using credits
|
||||
const result = await db
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: sql`balance - ${amount}`,
|
||||
totalSpent: sql`total_spent + ${amount}`,
|
||||
version: sql`version + 1`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(balances.userId, userId),
|
||||
eq(balances.version, currentVersion),
|
||||
gte(balances.balance, amount)
|
||||
)
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error('Concurrent modification or insufficient balance');
|
||||
}
|
||||
```
|
||||
|
||||
## Idempotency
|
||||
|
||||
The `idempotency_key` column in `credits.transactions` prevents duplicate operations:
|
||||
|
||||
```typescript
|
||||
// Check if transaction already exists
|
||||
const existing = await db.query.transactions.findFirst({
|
||||
where: eq(transactions.idempotencyKey, idempotencyKey)
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return existing; // Return existing transaction, don't create duplicate
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Files
|
||||
|
||||
All database tables are defined in TypeScript using Drizzle ORM:
|
||||
|
||||
```
|
||||
src/db/schema/
|
||||
├── auth.schema.ts # Users, sessions, accounts, jwks
|
||||
├── organizations.schema.ts # Organizations, members, invitations
|
||||
├── credits.schema.ts # Balances, transactions, packages, gifts
|
||||
└── index.ts # Export all schemas
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Push schema to database (development)
|
||||
pnpm db:push
|
||||
|
||||
# Open Drizzle Studio to view/edit data
|
||||
pnpm db:studio
|
||||
```
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
# 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();
|
||||
```
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
# Gift Code System
|
||||
|
||||
User-generated gift codes for sharing credits across the Mana ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
Users can create gift codes to share credits with others. The system supports various modes:
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `simple` | Single-use, one recipient |
|
||||
| `personalized` | Restricted to specific email/Matrix ID |
|
||||
| `split` | Divided into portions (e.g., 100 credits / 5 = 20 each) |
|
||||
| `first_come` | First N users get full amount |
|
||||
| `riddle` | Requires correct answer to redeem |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Base URL: `/api/v1/gifts`
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| GET | `/:code` | - | Get gift code info (public preview) |
|
||||
| POST | `/` | JWT | Create new gift code |
|
||||
| POST | `/:code/redeem` | JWT | Redeem a gift code |
|
||||
| GET | `/me/created` | JWT | List codes you created |
|
||||
| GET | `/me/received` | JWT | List gifts you received |
|
||||
| DELETE | `/:id` | JWT | Cancel code & refund unclaimed |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Create Gift Code
|
||||
|
||||
```bash
|
||||
curl -X POST "https://auth.mana.how/api/v1/gifts" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"credits": 100,
|
||||
"message": "Happy Birthday!",
|
||||
"type": "simple"
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"code": "ABC123",
|
||||
"url": "https://mana.how/g/ABC123",
|
||||
"totalCredits": 100,
|
||||
"creditsPerPortion": 100,
|
||||
"totalPortions": 1,
|
||||
"type": "simple",
|
||||
"expiresAt": "2026-05-14T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Create Split Gift (5 portions)
|
||||
|
||||
```bash
|
||||
curl -X POST "https://auth.mana.how/api/v1/gifts" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"credits": 100,
|
||||
"type": "split",
|
||||
"portions": 5,
|
||||
"message": "Share this with friends!"
|
||||
}'
|
||||
```
|
||||
|
||||
Each recipient gets 20 credits (100 / 5).
|
||||
|
||||
### Create Riddle Gift
|
||||
|
||||
```bash
|
||||
curl -X POST "https://auth.mana.how/api/v1/gifts" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"credits": 50,
|
||||
"type": "riddle",
|
||||
"riddleQuestion": "What is the capital of Germany?",
|
||||
"riddleAnswer": "Berlin"
|
||||
}'
|
||||
```
|
||||
|
||||
### Get Gift Info (Public)
|
||||
|
||||
```bash
|
||||
curl "https://auth.mana.how/api/v1/gifts/ABC123"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"code": "ABC123",
|
||||
"type": "simple",
|
||||
"status": "active",
|
||||
"creditsPerPortion": 100,
|
||||
"totalPortions": 1,
|
||||
"remainingPortions": 1,
|
||||
"message": "Happy Birthday!",
|
||||
"hasRiddle": false,
|
||||
"isPersonalized": false,
|
||||
"expiresAt": "2026-05-14T00:00:00Z",
|
||||
"creatorName": "John Doe"
|
||||
}
|
||||
```
|
||||
|
||||
### Redeem Gift Code
|
||||
|
||||
```bash
|
||||
curl -X POST "https://auth.mana.how/api/v1/gifts/ABC123/redeem" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
For riddle gifts, include the answer:
|
||||
```bash
|
||||
curl -X POST "https://auth.mana.how/api/v1/gifts/ABC123/redeem" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"answer": "Berlin"}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"creditsReceived": 100,
|
||||
"newBalance": 250,
|
||||
"message": "Happy Birthday!"
|
||||
}
|
||||
```
|
||||
|
||||
## Matrix Bot Commands
|
||||
|
||||
### German
|
||||
```
|
||||
!geschenk 50 # Simple gift
|
||||
!geschenk 100 /5 # Split into 5 portions
|
||||
!geschenk 50 ?="Berlin" # With riddle
|
||||
!geschenk 50 "Viel Spass!" # With message
|
||||
!einloesen ABC123 # Redeem code
|
||||
!einloesen ABC123 Berlin # Redeem with riddle answer
|
||||
!meine-geschenke # List your gifts
|
||||
```
|
||||
|
||||
### English
|
||||
```
|
||||
!gift 50
|
||||
!gift 100 /5
|
||||
!gift 50 ?="Berlin"
|
||||
!redeem ABC123
|
||||
!my-gifts
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
**gifts.gift_codes**
|
||||
- `id` - UUID primary key
|
||||
- `code` - Unique 6-char code (e.g., "ABC123")
|
||||
- `short_url` - Full URL (e.g., "mana.how/g/ABC123")
|
||||
- `creator_id` - FK to auth.users
|
||||
- `total_credits` - Reserved amount
|
||||
- `credits_per_portion` - Credits per redemption
|
||||
- `total_portions` - Number of portions
|
||||
- `claimed_portions` - Portions already redeemed
|
||||
- `type` - simple|personalized|split|first_come|riddle
|
||||
- `status` - active|depleted|expired|cancelled|refunded
|
||||
- `target_email` - For personalized gifts
|
||||
- `target_matrix_id` - For personalized gifts
|
||||
- `riddle_question` - Question text
|
||||
- `riddle_answer_hash` - bcrypt hash of answer
|
||||
- `message` - Optional message
|
||||
- `expires_at` - Expiration timestamp
|
||||
- `reservation_transaction_id` - FK to credits.transactions
|
||||
|
||||
**gifts.gift_redemptions**
|
||||
- `id` - UUID primary key
|
||||
- `gift_code_id` - FK to gift_codes
|
||||
- `redeemer_user_id` - FK to auth.users
|
||||
- `status` - success|failed_wrong_answer|failed_wrong_user|...
|
||||
- `credits_received` - Amount credited
|
||||
- `portion_number` - Which portion was claimed
|
||||
- `credit_transaction_id` - FK to credits.transactions
|
||||
- `source_app_id` - 'matrix-bot', 'web', etc.
|
||||
|
||||
### Transaction Types
|
||||
|
||||
Credits schema includes gift-related transaction types:
|
||||
- `gift_reserve` - Credits reserved when creating gift
|
||||
- `gift_release` - Credits returned when cancelling gift
|
||||
- `gift_receive` - Credits received when redeeming gift
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Web Apps (SvelteKit)
|
||||
```typescript
|
||||
// Fetch gift info
|
||||
const response = await fetch(`/api/v1/gifts/${code}`);
|
||||
const giftInfo = await response.json();
|
||||
|
||||
// Redeem
|
||||
const result = await fetch(`/api/v1/gifts/${code}/redeem`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ answer: riddleAnswer })
|
||||
});
|
||||
```
|
||||
|
||||
### Matrix Bots
|
||||
```typescript
|
||||
import { GiftService } from '@manacore/bot-services';
|
||||
import { handleGiftCommand } from '@manacore/matrix-bot-common';
|
||||
|
||||
// In bot command handler
|
||||
if (isGiftCommand(command)) {
|
||||
return handleGiftCommand(this, roomId, userId, command, args);
|
||||
}
|
||||
```
|
||||
|
||||
### Mobile Apps (Expo)
|
||||
Same REST API, use fetch or axios with JWT token.
|
||||
|
||||
## Security
|
||||
|
||||
- Gift codes use 6-char alphanumeric codes (no ambiguous chars)
|
||||
- Riddle answers are bcrypt hashed
|
||||
- Row-level locking prevents race conditions
|
||||
- Credits are reserved atomically when creating gifts
|
||||
- Personalized gifts verify email or Matrix ID
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
```env
|
||||
# Base URL for short links
|
||||
APP_BASE_URL=https://mana.how
|
||||
```
|
||||
|
||||
Gift code rules (hardcoded):
|
||||
```typescript
|
||||
const GIFT_CODE_RULES = {
|
||||
minCredits: 1,
|
||||
maxCredits: 10000,
|
||||
maxPortions: 100,
|
||||
maxMessageLength: 500,
|
||||
maxRiddleQuestionLength: 200,
|
||||
defaultExpirationDays: 90,
|
||||
};
|
||||
```
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
# Matrix SSO Integration
|
||||
|
||||
This document describes how Mana Core Auth provides Single Sign-On (SSO) for Matrix/Synapse using OpenID Connect (OIDC).
|
||||
|
||||
## Overview
|
||||
|
||||
Mana Core Auth acts as an **OIDC Provider** (Identity Provider), allowing Matrix Synapse to authenticate users via SSO. Users can sign in to Matrix using their Mana Core credentials.
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Matrix Client │────▶│ Synapse │────▶│ Mana Core Auth │
|
||||
│ (Element) │ │ (matrix.mana.how) │ │ (auth.mana.how) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ 1. Click "SSO" │ │
|
||||
│─────────────────────▶│ │
|
||||
│ │ 2. Redirect to │
|
||||
│ │ OIDC authorize │
|
||||
│ │──────────────────────▶│
|
||||
│ │ │
|
||||
│ │ 3. Show login page │
|
||||
│◀─────────────────────────────────────────────│
|
||||
│ │ │
|
||||
│ 4. User logs in │ │
|
||||
│─────────────────────────────────────────────▶│
|
||||
│ │ │
|
||||
│ │ 5. Redirect with │
|
||||
│ │ auth code │
|
||||
│ │◀──────────────────────│
|
||||
│ │ │
|
||||
│ │ 6. Exchange code │
|
||||
│ │ for tokens │
|
||||
│ │──────────────────────▶│
|
||||
│ │ │
|
||||
│ 7. Login complete │◀──────────────────────│
|
||||
│◀─────────────────────│ │
|
||||
```
|
||||
|
||||
## OIDC Endpoints
|
||||
|
||||
Mana Core Auth exposes the following OIDC endpoints:
|
||||
|
||||
| Endpoint | URL | Description |
|
||||
|----------|-----|-------------|
|
||||
| Discovery | `https://auth.mana.how/.well-known/openid-configuration` | OIDC discovery document |
|
||||
| Authorize | `https://auth.mana.how/api/auth/oauth2/authorize` | Authorization endpoint |
|
||||
| Token | `https://auth.mana.how/api/auth/oauth2/token` | Token endpoint |
|
||||
| UserInfo | `https://auth.mana.how/api/auth/oauth2/userinfo` | User info endpoint |
|
||||
| JWKS | `https://auth.mana.how/api/auth/jwks` | JSON Web Key Set |
|
||||
| Login | `https://auth.mana.how/login` | SSO login page |
|
||||
|
||||
## Synapse Configuration
|
||||
|
||||
The Matrix Synapse server is configured with OIDC in `docker/matrix/homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
oidc_providers:
|
||||
- idp_id: manacore
|
||||
idp_name: "Mana Core"
|
||||
idp_brand: "org.matrix.custom"
|
||||
discover: true
|
||||
issuer: "https://auth.mana.how"
|
||||
client_id: "matrix-synapse"
|
||||
client_secret: "<secret>"
|
||||
scopes: ["openid", "profile", "email"]
|
||||
user_mapping_provider:
|
||||
config:
|
||||
subject_claim: "sub"
|
||||
localpart_template: "{{ user.email.split('@')[0] }}"
|
||||
display_name_template: "{{ user.name }}"
|
||||
email_template: "{{ user.email }}"
|
||||
```
|
||||
|
||||
## OAuth Application Registration
|
||||
|
||||
The Matrix Synapse client is registered in the auth database:
|
||||
|
||||
```sql
|
||||
INSERT INTO auth.oauth_applications (
|
||||
id, name, client_id, client_secret, redirect_urls, type
|
||||
) VALUES (
|
||||
'matrix-synapse-client',
|
||||
'Matrix Synapse',
|
||||
'matrix-synapse',
|
||||
'<hashed-secret>',
|
||||
'["https://matrix.mana.how/_synapse/client/oidc/callback"]',
|
||||
'web'
|
||||
);
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. **User initiates SSO**: User clicks "Sign in with Mana Core" on Element/Matrix client
|
||||
2. **Synapse redirects**: Synapse redirects to Mana Core Auth's authorization endpoint
|
||||
3. **Login page**: If not logged in, user sees the Mana Core login page
|
||||
4. **User authenticates**: User enters email and password
|
||||
5. **Authorization**: After successful login, user is redirected back to authorization endpoint
|
||||
6. **Token exchange**: Synapse exchanges the authorization code for tokens
|
||||
7. **User mapping**: Synapse creates/links the Matrix user based on OIDC claims
|
||||
8. **Login complete**: User is logged into Matrix
|
||||
|
||||
## Claims Provided
|
||||
|
||||
The OIDC tokens include the following claims:
|
||||
|
||||
| Claim | Description |
|
||||
|-------|-------------|
|
||||
| `sub` | User ID |
|
||||
| `email` | User's email address |
|
||||
| `email_verified` | Whether email is verified |
|
||||
| `name` | User's display name |
|
||||
|
||||
## Testing the Integration
|
||||
|
||||
### Test OIDC Discovery
|
||||
|
||||
```bash
|
||||
curl https://auth.mana.how/.well-known/openid-configuration | jq
|
||||
```
|
||||
|
||||
### Test Matrix SSO Redirect
|
||||
|
||||
```bash
|
||||
curl -I "https://matrix.mana.how/_matrix/client/v3/login/sso/redirect/oidc-manacore?redirectUrl=https://element.mana.how"
|
||||
```
|
||||
|
||||
### Check Matrix Login Methods
|
||||
|
||||
```bash
|
||||
curl https://matrix.mana.how/_matrix/client/v3/login | jq '.flows[] | select(.type | contains("sso"))'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```json
|
||||
{
|
||||
"type": "m.login.sso",
|
||||
"identity_providers": [
|
||||
{
|
||||
"id": "oidc-manacore",
|
||||
"name": "Mana Core",
|
||||
"brand": "org.matrix.custom"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### JWKS Fetch Fails
|
||||
|
||||
If Synapse can't fetch JWKS:
|
||||
1. Check JWKS endpoint: `curl https://auth.mana.how/api/auth/jwks`
|
||||
2. Verify Synapse can reach auth service (network/DNS)
|
||||
3. Check Synapse logs for OIDC errors
|
||||
|
||||
### Login Page Not Found
|
||||
|
||||
If the login page returns 404:
|
||||
1. Check that `/login` is excluded from global prefix in `main.ts`
|
||||
2. Verify `OidcLoginController` is registered in `AuthModule`
|
||||
|
||||
### Authorization Fails
|
||||
|
||||
If authorization returns errors:
|
||||
1. Check client_id matches registered OAuth application
|
||||
2. Verify redirect_uri matches exactly (including trailing slash)
|
||||
3. Check that required scopes are requested
|
||||
|
||||
### Token Exchange Fails
|
||||
|
||||
If token exchange fails:
|
||||
1. Check client_secret is correct
|
||||
2. Verify token endpoint is accessible
|
||||
3. Check Synapse logs for detailed error messages
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Client Secret**: The OAuth client secret is stored securely and should never be exposed
|
||||
2. **HTTPS Only**: All OIDC endpoints use HTTPS
|
||||
3. **Token Expiry**: ID tokens expire after 15 minutes
|
||||
4. **PKCE**: Authorization code flow uses PKCE for added security
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/auth/better-auth.config.ts` | OIDC Provider plugin configuration |
|
||||
| `src/auth/oidc.controller.ts` | OIDC endpoint routing |
|
||||
| `src/auth/oidc-login.controller.ts` | SSO login page |
|
||||
| `src/db/schema/auth.schema.ts` | OAuth tables (oauth_applications, etc.) |
|
||||
| `docker/matrix/homeserver.yaml` | Synapse OIDC configuration |
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'manacore',
|
||||
schemaFilter: ['auth', 'credits', 'gifts', 'subscriptions', 'public'],
|
||||
});
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
// @ts-check
|
||||
import {
|
||||
baseConfig,
|
||||
typescriptConfig,
|
||||
nestjsConfig,
|
||||
prettierConfig,
|
||||
} from '@manacore/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
...baseConfig,
|
||||
...typescriptConfig,
|
||||
...nestjsConfig,
|
||||
...prettierConfig,
|
||||
];
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'**/*.(t|j)s',
|
||||
'!**/*.module.ts',
|
||||
'!**/*.interface.ts',
|
||||
'!**/main.ts',
|
||||
'!**/*.dto.ts',
|
||||
'!**/*.schema.ts',
|
||||
'!**/index.ts',
|
||||
'!**/migrate.ts',
|
||||
'!**/connection.ts',
|
||||
],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
// Handle ESM modules (nanoid, better-auth)
|
||||
transformIgnorePatterns: ['node_modules/(?!(nanoid|better-auth)/)'],
|
||||
moduleNameMapper: {
|
||||
'^src/(.*)$': '<rootDir>/$1',
|
||||
'^nanoid$': '<rootDir>/../test/__mocks__/nanoid.ts',
|
||||
'^jose$': '<rootDir>/../test/__mocks__/jose.ts',
|
||||
'^better-auth$': '<rootDir>/../test/__mocks__/better-auth.ts',
|
||||
'^better-auth/types$': '<rootDir>/../test/__mocks__/better-auth.ts',
|
||||
'^better-auth/plugins$': '<rootDir>/../test/__mocks__/better-auth-plugins.ts',
|
||||
'^better-auth/plugins/(.*)$': '<rootDir>/../test/__mocks__/better-auth-plugins.ts',
|
||||
'^better-auth/adapters/(.*)$': '<rootDir>/../test/__mocks__/better-auth-adapters.ts',
|
||||
},
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
// Critical paths require 100% coverage
|
||||
'./auth/auth.service.ts': {
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
statements: 100,
|
||||
},
|
||||
'./credits/credits.service.ts': {
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
statements: 100,
|
||||
},
|
||||
'./common/guards/jwt-auth.guard.ts': {
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
statements: 100,
|
||||
},
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/../test/setup.ts'],
|
||||
testTimeout: 10000,
|
||||
};
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false,
|
||||
"tsConfigPath": "tsconfig.json"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
{
|
||||
"name": "mana-core-auth",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Mana Core Authentication and Credit System",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed:dev": "tsx src/db/seed-dev-user.ts",
|
||||
"db:seed:oidc": "tsx src/db/seeds/seed-oidc-clients.ts",
|
||||
"db:seed:plans": "tsx src/db/seeds/seed-subscription-plans.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/shared-llm": "workspace:^",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"axios": "^1.7.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-auth": "^1.4.3",
|
||||
"body-parser": "^2.2.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"duckdb-async": "^1.1.1",
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^6.1.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"nodemailer": "^7.0.12",
|
||||
"postgres": "^3.4.5",
|
||||
"prom-client": "^15.1.0",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^17.5.0",
|
||||
"winston": "^3.17.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/body-parser": "^1.19.6",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
}
|
||||
7948
services/mana-core-auth/pnpm-lock.yaml
generated
7948
services/mana-core-auth/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +0,0 @@
|
|||
-- Create schemas
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
CREATE SCHEMA IF NOT EXISTS credits;
|
||||
|
||||
-- Enable necessary extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Create enums
|
||||
CREATE TYPE auth.user_role AS ENUM ('user', 'admin', 'service');
|
||||
CREATE TYPE credits.transaction_type AS ENUM ('purchase', 'usage', 'refund', 'bonus', 'expiry', 'adjustment');
|
||||
CREATE TYPE credits.transaction_status AS ENUM ('pending', 'completed', 'failed', 'cancelled');
|
||||
|
||||
-- Grant usage on schemas
|
||||
GRANT USAGE ON SCHEMA auth TO PUBLIC;
|
||||
GRANT USAGE ON SCHEMA credits TO PUBLIC;
|
||||
|
||||
-- Create updated_at trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
COMMENT ON SCHEMA auth IS 'Authentication and user management';
|
||||
COMMENT ON SCHEMA credits IS 'Credit system and transactions';
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
-- Enable Row Level Security on auth tables
|
||||
ALTER TABLE auth.users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE auth.passwords ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE auth.two_factor_auth ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Enable Row Level Security on credits tables
|
||||
ALTER TABLE credits.balances ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credits.transactions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credits.purchases ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credits.usage_stats ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for users table
|
||||
CREATE POLICY "Users can view their own profile"
|
||||
ON auth.users
|
||||
FOR SELECT
|
||||
USING (auth.uid() = id OR auth.role() = 'admin');
|
||||
|
||||
CREATE POLICY "Users can update their own profile"
|
||||
ON auth.users
|
||||
FOR UPDATE
|
||||
USING (auth.uid() = id)
|
||||
WITH CHECK (auth.uid() = id);
|
||||
|
||||
-- RLS Policies for sessions table
|
||||
CREATE POLICY "Users can view their own sessions"
|
||||
ON auth.sessions
|
||||
FOR SELECT
|
||||
USING (auth.uid() = user_id OR auth.role() = 'admin');
|
||||
|
||||
CREATE POLICY "Users can delete their own sessions"
|
||||
ON auth.sessions
|
||||
FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- RLS Policies for balances table
|
||||
CREATE POLICY "Users can view their own balance"
|
||||
ON credits.balances
|
||||
FOR SELECT
|
||||
USING (auth.uid() = user_id OR auth.role() = 'admin');
|
||||
|
||||
-- RLS Policies for transactions table
|
||||
CREATE POLICY "Users can view their own transactions"
|
||||
ON credits.transactions
|
||||
FOR SELECT
|
||||
USING (auth.uid() = user_id OR auth.role() = 'admin');
|
||||
|
||||
-- RLS Policies for purchases table
|
||||
CREATE POLICY "Users can view their own purchases"
|
||||
ON credits.purchases
|
||||
FOR SELECT
|
||||
USING (auth.uid() = user_id OR auth.role() = 'admin');
|
||||
|
||||
-- RLS Policies for usage_stats table
|
||||
CREATE POLICY "Users can view their own usage stats"
|
||||
ON credits.usage_stats
|
||||
FOR SELECT
|
||||
USING (auth.uid() = user_id OR auth.role() = 'admin');
|
||||
|
||||
-- Helper functions for RLS
|
||||
CREATE OR REPLACE FUNCTION auth.uid() RETURNS UUID AS $$
|
||||
SELECT NULLIF(current_setting('request.jwt.claims', true)::json->>'sub', '')::UUID;
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION auth.role() RETURNS TEXT AS $$
|
||||
SELECT NULLIF(current_setting('request.jwt.claims', true)::json->>'role', '')::TEXT;
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
-- =====================================================
|
||||
-- RLS POLICIES FOR BETTER AUTH ORGANIZATION TABLES
|
||||
-- =====================================================
|
||||
|
||||
-- Enable RLS on organization tables
|
||||
ALTER TABLE auth.organizations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE auth.members ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE auth.invitations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credits.organization_balances ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credits.credit_allocations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- =====================================================
|
||||
-- HELPER FUNCTIONS FOR ORGANIZATION RLS
|
||||
-- =====================================================
|
||||
|
||||
-- Get user's role in an organization
|
||||
CREATE OR REPLACE FUNCTION auth.user_organization_role(org_id TEXT) RETURNS TEXT AS $$
|
||||
SELECT role FROM auth.members
|
||||
WHERE organization_id = org_id
|
||||
AND user_id = auth.uid()::text
|
||||
LIMIT 1;
|
||||
$$ LANGUAGE SQL STABLE SECURITY DEFINER;
|
||||
|
||||
-- Check if user is member of organization
|
||||
CREATE OR REPLACE FUNCTION auth.is_organization_member(org_id TEXT) RETURNS BOOLEAN AS $$
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM auth.members
|
||||
WHERE organization_id = org_id
|
||||
AND user_id = auth.uid()::text
|
||||
);
|
||||
$$ LANGUAGE SQL STABLE SECURITY DEFINER;
|
||||
|
||||
-- Check if user is owner or admin of organization
|
||||
CREATE OR REPLACE FUNCTION auth.is_organization_owner_or_admin(org_id TEXT) RETURNS BOOLEAN AS $$
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM auth.members
|
||||
WHERE organization_id = org_id
|
||||
AND user_id = auth.uid()::text
|
||||
AND role IN ('owner', 'admin')
|
||||
);
|
||||
$$ LANGUAGE SQL STABLE SECURITY DEFINER;
|
||||
|
||||
-- Check if user is owner of organization
|
||||
CREATE OR REPLACE FUNCTION auth.is_organization_owner(org_id TEXT) RETURNS BOOLEAN AS $$
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM auth.members
|
||||
WHERE organization_id = org_id
|
||||
AND user_id = auth.uid()::text
|
||||
AND role = 'owner'
|
||||
);
|
||||
$$ LANGUAGE SQL STABLE SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- ORGANIZATIONS TABLE POLICIES
|
||||
-- =====================================================
|
||||
|
||||
-- Users can view organizations they are members of
|
||||
CREATE POLICY "Users can view their organizations"
|
||||
ON auth.organizations
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.is_organization_member(id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Users can create organizations (Better Auth will handle adding them as owner)
|
||||
CREATE POLICY "Users can create organizations"
|
||||
ON auth.organizations
|
||||
FOR INSERT
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Only owners can update organization
|
||||
CREATE POLICY "Owners can update their organizations"
|
||||
ON auth.organizations
|
||||
FOR UPDATE
|
||||
USING (auth.is_organization_owner(id))
|
||||
WITH CHECK (auth.is_organization_owner(id));
|
||||
|
||||
-- Only owners can delete organization
|
||||
CREATE POLICY "Owners can delete their organizations"
|
||||
ON auth.organizations
|
||||
FOR DELETE
|
||||
USING (auth.is_organization_owner(id));
|
||||
|
||||
-- =====================================================
|
||||
-- MEMBERS TABLE POLICIES
|
||||
-- =====================================================
|
||||
|
||||
-- Members can view other members in their organizations
|
||||
CREATE POLICY "Members can view organization members"
|
||||
ON auth.members
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.is_organization_member(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Owners and admins can add members (Better Auth handles invitation flow)
|
||||
CREATE POLICY "Owners and admins can add members"
|
||||
ON auth.members
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Owners and admins can update member roles
|
||||
CREATE POLICY "Owners and admins can update members"
|
||||
ON auth.members
|
||||
FOR UPDATE
|
||||
USING (auth.is_organization_owner_or_admin(organization_id))
|
||||
WITH CHECK (auth.is_organization_owner_or_admin(organization_id));
|
||||
|
||||
-- Owners and admins can remove members
|
||||
-- Members can remove themselves
|
||||
CREATE POLICY "Owners/admins can remove members, members can leave"
|
||||
ON auth.members
|
||||
FOR DELETE
|
||||
USING (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR user_id = auth.uid()::text
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- INVITATIONS TABLE POLICIES
|
||||
-- =====================================================
|
||||
|
||||
-- Members can view invitations in their organizations
|
||||
CREATE POLICY "Members can view organization invitations"
|
||||
ON auth.invitations
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.is_organization_member(organization_id)
|
||||
OR email = (SELECT email FROM auth.users WHERE id = auth.uid())
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Owners and admins can create invitations
|
||||
CREATE POLICY "Owners and admins can create invitations"
|
||||
ON auth.invitations
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Owners and admins can update invitations (cancel, etc)
|
||||
CREATE POLICY "Owners and admins can update invitations"
|
||||
ON auth.invitations
|
||||
FOR UPDATE
|
||||
USING (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
)
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Inviter can delete their invitations
|
||||
-- Invitee can delete (reject) invitations sent to them
|
||||
CREATE POLICY "Inviters and invitees can delete invitations"
|
||||
ON auth.invitations
|
||||
FOR DELETE
|
||||
USING (
|
||||
inviter_id = auth.uid()::text
|
||||
OR email = (SELECT email FROM auth.users WHERE id = auth.uid())
|
||||
OR auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- ORGANIZATION BALANCES TABLE POLICIES
|
||||
-- =====================================================
|
||||
|
||||
-- Members can view their organization's balance
|
||||
CREATE POLICY "Members can view organization balance"
|
||||
ON credits.organization_balances
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.is_organization_member(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Only owners can create organization balances (during org creation)
|
||||
CREATE POLICY "Owners can create organization balance"
|
||||
ON credits.organization_balances
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Only owners can update organization balances (allocations, purchases)
|
||||
CREATE POLICY "Owners can update organization balance"
|
||||
ON credits.organization_balances
|
||||
FOR UPDATE
|
||||
USING (auth.is_organization_owner(organization_id))
|
||||
WITH CHECK (auth.is_organization_owner(organization_id));
|
||||
|
||||
-- Only owners can delete (cascade handled by org deletion)
|
||||
CREATE POLICY "Owners can delete organization balance"
|
||||
ON credits.organization_balances
|
||||
FOR DELETE
|
||||
USING (auth.is_organization_owner(organization_id));
|
||||
|
||||
-- =====================================================
|
||||
-- CREDIT ALLOCATIONS TABLE POLICIES
|
||||
-- =====================================================
|
||||
|
||||
-- Employees can view allocations to them
|
||||
-- Owners/admins can view all allocations in their org
|
||||
CREATE POLICY "Users can view relevant credit allocations"
|
||||
ON credits.credit_allocations
|
||||
FOR SELECT
|
||||
USING (
|
||||
employee_id = auth.uid()
|
||||
OR auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Only owners can create credit allocations
|
||||
CREATE POLICY "Owners can create credit allocations"
|
||||
ON credits.credit_allocations
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- No updates to allocations (immutable audit trail)
|
||||
-- No deletes to allocations (immutable audit trail)
|
||||
|
||||
-- =====================================================
|
||||
-- COMMENTS
|
||||
-- =====================================================
|
||||
|
||||
COMMENT ON POLICY "Users can view their organizations" ON auth.organizations IS 'Members can view organizations they belong to';
|
||||
COMMENT ON POLICY "Users can create organizations" ON auth.organizations IS 'Any authenticated user can create an organization';
|
||||
COMMENT ON POLICY "Owners can update their organizations" ON auth.organizations IS 'Only owners can modify organization details';
|
||||
COMMENT ON POLICY "Owners can delete their organizations" ON auth.organizations IS 'Only owners can delete organizations';
|
||||
|
||||
COMMENT ON FUNCTION auth.user_organization_role IS 'Returns the role of the current user in the specified organization';
|
||||
COMMENT ON FUNCTION auth.is_organization_member IS 'Checks if current user is a member of the organization';
|
||||
COMMENT ON FUNCTION auth.is_organization_owner_or_admin IS 'Checks if current user is owner or admin of the organization';
|
||||
COMMENT ON FUNCTION auth.is_organization_owner IS 'Checks if current user is owner of the organization';
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
-- =====================================================
|
||||
-- RLS POLICIES FOR GUILD POOL TABLES
|
||||
-- =====================================================
|
||||
-- Uses helper functions from 03-organization-rls.sql:
|
||||
-- auth.is_organization_member(org_id)
|
||||
-- auth.is_organization_owner_or_admin(org_id)
|
||||
-- auth.is_organization_owner(org_id)
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE credits.guild_pools ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credits.guild_spending_limits ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credits.guild_transactions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- =====================================================
|
||||
-- GUILD POOLS TABLE POLICIES
|
||||
-- =====================================================
|
||||
|
||||
-- Members can view their guild's pool balance
|
||||
CREATE POLICY "Members can view guild pool"
|
||||
ON credits.guild_pools
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.is_organization_member(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Pool is created during guild creation (by owner or system)
|
||||
CREATE POLICY "Owners can create guild pool"
|
||||
ON credits.guild_pools
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Owners and admins can update pool (funding, spending)
|
||||
CREATE POLICY "Owners and admins can update guild pool"
|
||||
ON credits.guild_pools
|
||||
FOR UPDATE
|
||||
USING (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
)
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Only owners can delete pool (cascade from org deletion)
|
||||
CREATE POLICY "Owners can delete guild pool"
|
||||
ON credits.guild_pools
|
||||
FOR DELETE
|
||||
USING (
|
||||
auth.is_organization_owner(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- GUILD SPENDING LIMITS TABLE POLICIES
|
||||
-- =====================================================
|
||||
|
||||
-- Members can view their own limits; owners/admins can view all
|
||||
CREATE POLICY "Users can view guild spending limits"
|
||||
ON credits.guild_spending_limits
|
||||
FOR SELECT
|
||||
USING (
|
||||
user_id = auth.uid()::text
|
||||
OR auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Only owners and admins can set spending limits
|
||||
CREATE POLICY "Owners and admins can create spending limits"
|
||||
ON credits.guild_spending_limits
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Only owners and admins can update spending limits
|
||||
CREATE POLICY "Owners and admins can update spending limits"
|
||||
ON credits.guild_spending_limits
|
||||
FOR UPDATE
|
||||
USING (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
)
|
||||
WITH CHECK (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Only owners and admins can delete spending limits
|
||||
CREATE POLICY "Owners and admins can delete spending limits"
|
||||
ON credits.guild_spending_limits
|
||||
FOR DELETE
|
||||
USING (
|
||||
auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- GUILD TRANSACTIONS TABLE POLICIES
|
||||
-- =====================================================
|
||||
|
||||
-- Members can view their own transactions; owners/admins see all
|
||||
CREATE POLICY "Users can view guild transactions"
|
||||
ON credits.guild_transactions
|
||||
FOR SELECT
|
||||
USING (
|
||||
user_id = auth.uid()::text
|
||||
OR auth.is_organization_owner_or_admin(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- Any member can create transactions (via guild credit usage)
|
||||
CREATE POLICY "Members can create guild transactions"
|
||||
ON credits.guild_transactions
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.is_organization_member(organization_id)
|
||||
OR auth.role() = 'admin'
|
||||
);
|
||||
|
||||
-- No updates to transactions (immutable audit trail)
|
||||
-- No deletes to transactions (immutable audit trail)
|
||||
|
||||
-- =====================================================
|
||||
-- COMMENTS
|
||||
-- =====================================================
|
||||
|
||||
COMMENT ON POLICY "Members can view guild pool" ON credits.guild_pools IS 'Guild members can see the shared pool balance';
|
||||
COMMENT ON POLICY "Owners can create guild pool" ON credits.guild_pools IS 'Pool created during guild setup by owner';
|
||||
COMMENT ON POLICY "Owners and admins can update guild pool" ON credits.guild_pools IS 'Pool balance updated during funding and spending';
|
||||
COMMENT ON POLICY "Users can view guild spending limits" ON credits.guild_spending_limits IS 'Members see own limits, owners/admins see all';
|
||||
COMMENT ON POLICY "Users can view guild transactions" ON credits.guild_transactions IS 'Members see own transactions, owners/admins see all';
|
||||
COMMENT ON POLICY "Members can create guild transactions" ON credits.guild_transactions IS 'Any guild member can create transactions via credit usage';
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Generate RS256 key pair for JWT signing
|
||||
|
||||
echo "Generating RS256 key pair..."
|
||||
|
||||
# Generate private key
|
||||
openssl genrsa -out private.pem 2048
|
||||
|
||||
# Generate public key from private key
|
||||
openssl rsa -in private.pem -pubout -out public.pem
|
||||
|
||||
echo ""
|
||||
echo "Keys generated successfully!"
|
||||
echo ""
|
||||
echo "Private key: private.pem"
|
||||
echo "Public key: public.pem"
|
||||
echo ""
|
||||
echo "Add these to your .env file:"
|
||||
echo ""
|
||||
echo "JWT_PRIVATE_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)\""
|
||||
echo ""
|
||||
echo "JWT_PUBLIC_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem)\""
|
||||
echo ""
|
||||
echo "IMPORTANT: Keep private.pem secure and never commit it to version control!"
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
/**
|
||||
* Mock Factories for Testing
|
||||
*
|
||||
* Centralized factory functions for creating test data
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* Mock User Factory
|
||||
*/
|
||||
export const mockUserFactory = {
|
||||
create: (overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
email: `test-${nanoid(6)}@example.com`,
|
||||
emailVerified: true,
|
||||
name: 'Test User',
|
||||
avatarUrl: null,
|
||||
role: 'user',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
createMany: (count: number, overrides: Partial<any> = {}) => {
|
||||
return Array.from({ length: count }, () => mockUserFactory.create(overrides));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Session Factory
|
||||
*/
|
||||
export const mockSessionFactory = {
|
||||
create: (userId: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
token: nanoid(),
|
||||
refreshToken: nanoid(64),
|
||||
refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
deviceId: null,
|
||||
deviceName: null,
|
||||
lastActivityAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Password Factory
|
||||
*/
|
||||
export const mockPasswordFactory = {
|
||||
create: async (userId: string, password = 'TestPassword123!') => ({
|
||||
userId,
|
||||
hashedPassword: await bcrypt.hash(password, 12),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
|
||||
createSync: (userId: string, password = 'TestPassword123!') => ({
|
||||
userId,
|
||||
hashedPassword: bcrypt.hashSync(password, 12),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Balance Factory
|
||||
* Simplified - no free credits or daily reset
|
||||
*/
|
||||
export const mockBalanceFactory = {
|
||||
create: (userId: string, overrides: Partial<any> = {}) => ({
|
||||
userId,
|
||||
balance: 0,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
version: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
withBalance: (userId: string, balance: number) => {
|
||||
return mockBalanceFactory.create(userId, {
|
||||
balance,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Transaction Factory
|
||||
*/
|
||||
export const mockTransactionFactory = {
|
||||
create: (userId: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
type: 'usage',
|
||||
status: 'completed',
|
||||
amount: -10,
|
||||
balanceBefore: 100,
|
||||
balanceAfter: 90,
|
||||
appId: 'test-app',
|
||||
description: 'Test transaction',
|
||||
metadata: null,
|
||||
idempotencyKey: null,
|
||||
createdAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
createMany: (userId: string, count: number) => {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
mockTransactionFactory.create(userId, {
|
||||
amount: -(i + 1) * 10,
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Package Factory
|
||||
*/
|
||||
export const mockPackageFactory = {
|
||||
create: (overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
name: 'Test Package',
|
||||
description: '100 credits',
|
||||
credits: 100,
|
||||
priceEuroCents: 100,
|
||||
stripePriceId: `price_${nanoid()}`,
|
||||
active: true,
|
||||
sortOrder: 0,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
createMany: (count: number) => {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
mockPackageFactory.create({
|
||||
name: `Package ${i + 1}`,
|
||||
credits: (i + 1) * 100,
|
||||
priceEuroCents: (i + 1) * 100,
|
||||
sortOrder: i,
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Purchase Factory
|
||||
*/
|
||||
export const mockPurchaseFactory = {
|
||||
create: (userId: string, packageId: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
packageId,
|
||||
credits: 100,
|
||||
priceEuroCents: 100,
|
||||
stripePaymentIntentId: `pi_${nanoid()}`,
|
||||
stripeCustomerId: `cus_${nanoid()}`,
|
||||
status: 'completed',
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock DTO Factory
|
||||
*/
|
||||
export const mockDtoFactory = {
|
||||
register: (overrides: Partial<any> = {}) => ({
|
||||
email: `test-${nanoid(6)}@example.com`,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
login: (overrides: Partial<any> = {}) => ({
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
deviceId: undefined,
|
||||
deviceName: undefined,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
useCredits: (overrides: Partial<any> = {}) => ({
|
||||
amount: 10,
|
||||
appId: 'test-app',
|
||||
description: 'Test operation',
|
||||
metadata: undefined,
|
||||
idempotencyKey: undefined,
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock JWT Tokens
|
||||
*/
|
||||
export const mockTokenFactory = {
|
||||
validPayload: (overrides: Partial<any> = {}) => ({
|
||||
sub: nanoid(),
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
sessionId: nanoid(),
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 minutes
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
expiredPayload: (overrides: Partial<any> = {}) => ({
|
||||
sub: nanoid(),
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
sessionId: nanoid(),
|
||||
iat: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
||||
exp: Math.floor(Date.now() / 1000) - 1800, // 30 minutes ago (expired)
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Organization Factory
|
||||
*/
|
||||
export const mockOrganizationFactory = {
|
||||
create: (overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
name: 'Test Organization',
|
||||
slug: `test-org-${nanoid(6)}`,
|
||||
logo: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Organization Balance Factory
|
||||
*/
|
||||
export const mockOrganizationBalanceFactory = {
|
||||
create: (organizationId: string, overrides: Partial<any> = {}) => ({
|
||||
organizationId,
|
||||
balance: 0,
|
||||
allocatedCredits: 0,
|
||||
availableCredits: 0,
|
||||
totalPurchased: 0,
|
||||
totalAllocated: 0,
|
||||
version: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
withBalance: (organizationId: string, balance: number, allocated = 0) => {
|
||||
return mockOrganizationBalanceFactory.create(organizationId, {
|
||||
balance,
|
||||
allocatedCredits: allocated,
|
||||
availableCredits: balance - allocated,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Member Factory
|
||||
*/
|
||||
export const mockMemberFactory = {
|
||||
create: (organizationId: string, userId: string, overrides: Partial<any> = {}) => ({
|
||||
id: nanoid(),
|
||||
organizationId,
|
||||
userId,
|
||||
role: 'member',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
createOwner: (organizationId: string, userId: string) => {
|
||||
return mockMemberFactory.create(organizationId, userId, {
|
||||
role: 'owner',
|
||||
});
|
||||
},
|
||||
|
||||
createEmployee: (organizationId: string, userId: string) => {
|
||||
return mockMemberFactory.create(organizationId, userId, {
|
||||
role: 'member',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Credit Allocation Factory
|
||||
*/
|
||||
export const mockCreditAllocationFactory = {
|
||||
create: (
|
||||
organizationId: string,
|
||||
employeeId: string,
|
||||
allocatedBy: string,
|
||||
overrides: Partial<any> = {}
|
||||
) => ({
|
||||
id: nanoid(),
|
||||
organizationId,
|
||||
employeeId,
|
||||
amount: 100,
|
||||
allocatedBy,
|
||||
reason: 'Credit allocation',
|
||||
balanceBefore: 0,
|
||||
balanceAfter: 100,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock Database Responses
|
||||
*/
|
||||
export const mockDbFactory = {
|
||||
createSelectMock: () => ({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
for: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
}),
|
||||
|
||||
createInsertMock: () => ({
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
}),
|
||||
|
||||
createUpdateMock: () => ({
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
}),
|
||||
|
||||
createTransactionMock: () => ({
|
||||
transaction: jest.fn((callback) => callback(mockDbFactory.createSelectMock())),
|
||||
}),
|
||||
|
||||
createFullMock: () => ({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
for: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
transaction: jest.fn((callback) => callback(this)),
|
||||
}),
|
||||
};
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
/**
|
||||
* Test Helper: silentError
|
||||
*
|
||||
* Suppresses console.error output for tests that intentionally trigger errors.
|
||||
* This keeps test output clean while still verifying error handling behavior.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* it('should handle error gracefully', async () => {
|
||||
* await silentError(async () => {
|
||||
* // Test code that triggers console.error
|
||||
* await service.methodThatLogsErrors();
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function silentError<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Helper: silentConsole
|
||||
*
|
||||
* Suppresses all console output (log, warn, error) for cleaner test output.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* it('should work without console spam', async () => {
|
||||
* await silentConsole(async () => {
|
||||
* // Test code that logs to console
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function silentConsole<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Helper: suppressConsoleInTests
|
||||
*
|
||||
* Globally suppress console output for an entire test suite.
|
||||
* Use in beforeEach/afterEach for suite-wide suppression.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* describe('MyService', () => {
|
||||
* beforeEach(() => {
|
||||
* suppressConsoleInTests();
|
||||
* });
|
||||
*
|
||||
* afterEach(() => {
|
||||
* restoreConsoleInTests();
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
let consoleSpies: jest.SpyInstance[] = [];
|
||||
|
||||
export function suppressConsoleInTests() {
|
||||
consoleSpies = [
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {}),
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
];
|
||||
}
|
||||
|
||||
export function restoreConsoleInTests() {
|
||||
consoleSpies.forEach((spy) => spy.mockRestore());
|
||||
consoleSpies = [];
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
/**
|
||||
* Test Helper Utilities
|
||||
*
|
||||
* Common utilities for writing tests
|
||||
*/
|
||||
|
||||
import { type ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* Create mock ConfigService
|
||||
*/
|
||||
export const createMockConfigService = (overrides: Record<string, any> = {}): ConfigService => {
|
||||
const defaultConfig: Record<string, any> = {
|
||||
'database.url': 'postgresql://test:test@localhost:5432/test',
|
||||
'jwt.privateKey': 'mock-private-key',
|
||||
'jwt.publicKey': 'mock-public-key',
|
||||
'jwt.accessTokenExpiry': '15m',
|
||||
'jwt.refreshTokenExpiry': '7d',
|
||||
'jwt.issuer': 'mana-core',
|
||||
'jwt.audience': 'mana-universe',
|
||||
'redis.host': 'localhost',
|
||||
'redis.port': 6379,
|
||||
'redis.password': 'test',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
get: jest.fn((key: string) => defaultConfig[key]),
|
||||
getOrThrow: jest.fn((key: string) => {
|
||||
if (!defaultConfig[key]) {
|
||||
throw new Error(`Configuration key ${key} not found`);
|
||||
}
|
||||
return defaultConfig[key];
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a test date with specific offset
|
||||
*/
|
||||
export const createTestDate = (offsetMs = 0): Date => {
|
||||
return new Date(Date.now() + offsetMs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock timer utilities
|
||||
*/
|
||||
export const timerUtils = {
|
||||
/**
|
||||
* Fast-forward time
|
||||
*/
|
||||
advance: (ms: number) => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
},
|
||||
|
||||
/**
|
||||
* Use fake timers
|
||||
*/
|
||||
useFake: () => {
|
||||
jest.useFakeTimers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Use real timers
|
||||
*/
|
||||
useReal: () => {
|
||||
jest.useRealTimers();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert helpers for common patterns
|
||||
*/
|
||||
export const assertHelpers = {
|
||||
/**
|
||||
* Assert that a function throws a specific error
|
||||
*/
|
||||
assertThrowsAsync: async (fn: () => Promise<any>, expectedError: string | RegExp) => {
|
||||
await expect(fn()).rejects.toThrow(expectedError);
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that an object has specific properties
|
||||
*/
|
||||
assertHasProperties: (obj: any, properties: string[]) => {
|
||||
properties.forEach((prop) => {
|
||||
expect(obj).toHaveProperty(prop);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that an object does NOT have specific properties
|
||||
*/
|
||||
assertLacksProperties: (obj: any, properties: string[]) => {
|
||||
properties.forEach((prop) => {
|
||||
expect(obj).not.toHaveProperty(prop);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that a value is a valid UUID
|
||||
*/
|
||||
assertIsUuid: (value: string) => {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
expect(value).toMatch(uuidRegex);
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that a date is recent (within last N seconds)
|
||||
*/
|
||||
assertIsRecent: (date: Date, withinSeconds = 5) => {
|
||||
const now = Date.now();
|
||||
const dateMs = date.getTime();
|
||||
const diff = Math.abs(now - dateMs);
|
||||
expect(diff).toBeLessThan(withinSeconds * 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert that a value is between min and max
|
||||
*/
|
||||
assertBetween: (value: number, min: number, max: number) => {
|
||||
expect(value).toBeGreaterThanOrEqual(min);
|
||||
expect(value).toBeLessThanOrEqual(max);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Database test helpers
|
||||
*/
|
||||
export const dbTestHelpers = {
|
||||
/**
|
||||
* Create a mock database connection
|
||||
*/
|
||||
createMockDb: () => ({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
for: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mock successful query result
|
||||
*/
|
||||
mockSuccessResult: (data: any) => ({
|
||||
data,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mock error result
|
||||
*/
|
||||
mockErrorResult: (error: Error) => ({
|
||||
data: null,
|
||||
error,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Security test helpers
|
||||
*/
|
||||
export const securityTestHelpers = {
|
||||
/**
|
||||
* Common SQL injection payloads
|
||||
*/
|
||||
sqlInjectionPayloads: [
|
||||
"'; DROP TABLE users; --",
|
||||
"' OR '1'='1",
|
||||
"' OR '1'='1' --",
|
||||
"' OR '1'='1' /*",
|
||||
"admin'--",
|
||||
"' UNION SELECT NULL--",
|
||||
],
|
||||
|
||||
/**
|
||||
* Common XSS payloads
|
||||
*/
|
||||
xssPayloads: [
|
||||
'<script>alert("xss")</script>',
|
||||
'<img src=x onerror=alert("xss")>',
|
||||
'<svg onload=alert("xss")>',
|
||||
'javascript:alert("xss")',
|
||||
],
|
||||
|
||||
/**
|
||||
* Test for timing attacks
|
||||
*/
|
||||
measureExecutionTime: async (fn: () => Promise<any>): Promise<number> => {
|
||||
const start = process.hrtime.bigint();
|
||||
await fn();
|
||||
const end = process.hrtime.bigint();
|
||||
return Number(end - start) / 1_000_000; // Convert to milliseconds
|
||||
},
|
||||
|
||||
/**
|
||||
* Test for constant-time comparison
|
||||
*/
|
||||
isConstantTime: async (
|
||||
fn1: () => Promise<any>,
|
||||
fn2: () => Promise<any>,
|
||||
threshold = 10
|
||||
): Promise<boolean> => {
|
||||
const time1 = await securityTestHelpers.measureExecutionTime(fn1);
|
||||
const time2 = await securityTestHelpers.measureExecutionTime(fn2);
|
||||
const diff = Math.abs(time1 - time2);
|
||||
return diff < threshold;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock HTTP request/response
|
||||
*/
|
||||
export const httpMockHelpers = {
|
||||
/**
|
||||
* Create mock Express request
|
||||
*/
|
||||
createMockRequest: (overrides: Partial<any> = {}) => ({
|
||||
headers: {},
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
ip: '127.0.0.1',
|
||||
user: null,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create mock Express response
|
||||
*/
|
||||
createMockResponse: () => {
|
||||
const res: any = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
end: jest.fn().mockReturnThis(),
|
||||
};
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create mock NestJS ExecutionContext
|
||||
*/
|
||||
createMockExecutionContext: (request: any) => ({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => httpMockHelpers.createMockResponse(),
|
||||
}),
|
||||
getClass: () => ({}),
|
||||
getHandler: () => ({}),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Performance test helpers
|
||||
*/
|
||||
export const performanceHelpers = {
|
||||
/**
|
||||
* Run a function N times and measure average execution time
|
||||
*/
|
||||
benchmark: async (fn: () => Promise<any>, iterations = 100): Promise<number> => {
|
||||
const times: number[] = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
await fn();
|
||||
const end = process.hrtime.bigint();
|
||||
times.push(Number(end - start) / 1_000_000);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
return avg;
|
||||
},
|
||||
|
||||
/**
|
||||
* Assert function execution is under a time limit
|
||||
*/
|
||||
assertExecutionTime: async (fn: () => Promise<any>, maxMs: number) => {
|
||||
const start = process.hrtime.bigint();
|
||||
await fn();
|
||||
const end = process.hrtime.bigint();
|
||||
const duration = Number(end - start) / 1_000_000;
|
||||
expect(duration).toBeLessThan(maxMs);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { UserDataController } from './user-data.controller';
|
||||
import { UserDataService } from './user-data.service';
|
||||
import { AdminGuard } from './guards/admin.guard';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
HttpModule.register({
|
||||
timeout: 5000,
|
||||
maxRedirects: 3,
|
||||
}),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [UserDataController],
|
||||
providers: [UserDataService, AdminGuard],
|
||||
exports: [UserDataService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
export interface EntityCount {
|
||||
entity: string;
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProjectDataSummary {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
icon: string;
|
||||
available: boolean;
|
||||
error?: string;
|
||||
entities: EntityCount[];
|
||||
totalCount: number;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
export interface UserDataSummary {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
emailVerified: boolean;
|
||||
};
|
||||
auth: {
|
||||
sessionsCount: number;
|
||||
accountsCount: number;
|
||||
has2FA: boolean;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
credits: {
|
||||
balance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
transactionsCount: number;
|
||||
};
|
||||
projects: ProjectDataSummary[];
|
||||
totals: {
|
||||
totalEntities: number;
|
||||
projectsWithData: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeleteUserDataResponse {
|
||||
success: boolean;
|
||||
deletedFromProjects: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
deletedCount?: number;
|
||||
}[];
|
||||
deletedFromAuth: {
|
||||
sessions: number;
|
||||
accounts: number;
|
||||
credits: number;
|
||||
user: boolean;
|
||||
};
|
||||
totalDeleted: number;
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
users: UserListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { BetterAuthService } from '../../auth/services/better-auth.service';
|
||||
|
||||
/**
|
||||
* Guard for admin-only endpoints
|
||||
* Checks JWT token and verifies user has admin role or is in ADMIN_USER_IDS
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AdminGuard.name);
|
||||
private readonly adminUserIds: string[];
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly betterAuthService: BetterAuthService
|
||||
) {
|
||||
const adminIds = this.configService.get<string>('ADMIN_USER_IDS', '');
|
||||
this.adminUserIds = adminIds ? adminIds.split(',').map((id) => id.trim()) : [];
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
this.logger.warn('Missing or invalid Authorization header');
|
||||
throw new UnauthorizedException('Missing authorization token');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
// Validate JWT using Better Auth
|
||||
const result = await this.betterAuthService.validateToken(token);
|
||||
|
||||
if (!result.valid || !result.payload) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const userId = result.payload.sub;
|
||||
const userRole = result.payload.role;
|
||||
|
||||
// Check if user is admin (by role or by explicit ID)
|
||||
const isAdmin = userRole === 'admin' || this.adminUserIds.includes(userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
this.logger.warn(`User ${userId} attempted admin access without permission`);
|
||||
throw new ForbiddenException('Admin access required');
|
||||
}
|
||||
|
||||
// Attach user info to request
|
||||
(request as any).user = result.payload;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException || error instanceof ForbiddenException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Token validation error: ${error.message}`);
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Logger,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { UserDataService } from './user-data.service';
|
||||
import { AdminGuard } from './guards/admin.guard';
|
||||
import { UserDataSummary, DeleteUserDataResponse, UserListResponse } from './dto/user-data.dto';
|
||||
|
||||
/**
|
||||
* Admin controller for cross-project user data management
|
||||
* All endpoints require admin authentication (role=admin or in ADMIN_USER_IDS)
|
||||
*/
|
||||
@Controller('api/v1/admin')
|
||||
@UseGuards(AdminGuard)
|
||||
export class UserDataController {
|
||||
private readonly logger = new Logger(UserDataController.name);
|
||||
|
||||
constructor(private readonly userDataService: UserDataService) {}
|
||||
|
||||
/**
|
||||
* List all users with pagination and search
|
||||
* GET /api/v1/admin/users?page=1&limit=20&search=email
|
||||
*/
|
||||
@Get('users')
|
||||
async getUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string
|
||||
): Promise<UserListResponse> {
|
||||
const pageNum = parseInt(page || '1', 10);
|
||||
const limitNum = Math.min(parseInt(limit || '20', 10), 100);
|
||||
|
||||
this.logger.log(
|
||||
`Admin request: getUsers page=${pageNum} limit=${limitNum} search=${search || ''}`
|
||||
);
|
||||
return this.userDataService.getUsers(pageNum, limitNum, search);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated user data from all projects
|
||||
* GET /api/v1/admin/users/:userId/data
|
||||
*/
|
||||
@Get('users/:userId/data')
|
||||
async getUserData(@Param('userId') userId: string): Promise<UserDataSummary> {
|
||||
this.logger.log(`Admin request: getUserData for userId=${userId}`);
|
||||
return this.userDataService.getUserDataSummary(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all user data across all projects (GDPR right to be forgotten)
|
||||
* DELETE /api/v1/admin/users/:userId/data
|
||||
*/
|
||||
@Delete('users/:userId/data')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteUserData(@Param('userId') userId: string): Promise<DeleteUserDataResponse> {
|
||||
this.logger.log(`Admin request: deleteUserData for userId=${userId}`);
|
||||
return this.userDataService.deleteUserData(userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { eq, sql, desc, ilike, or } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import * as schema from '../db/schema';
|
||||
import {
|
||||
UserDataSummary,
|
||||
ProjectDataSummary,
|
||||
DeleteUserDataResponse,
|
||||
UserListItem,
|
||||
UserListResponse,
|
||||
} from './dto/user-data.dto';
|
||||
|
||||
interface ProjectConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserDataService {
|
||||
private readonly logger = new Logger(UserDataService.name);
|
||||
private readonly serviceKey: string;
|
||||
private readonly projectConfigs: ProjectConfig[];
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly httpService: HttpService
|
||||
) {
|
||||
this.serviceKey = this.configService.get<string>('ADMIN_SERVICE_KEY', 'dev-admin-key');
|
||||
|
||||
// Configure backend URLs from environment or use defaults
|
||||
this.projectConfigs = this.initProjectConfigs();
|
||||
}
|
||||
|
||||
private getDatabase() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
private initProjectConfigs(): ProjectConfig[] {
|
||||
return [
|
||||
{
|
||||
id: 'chat',
|
||||
name: 'Chat',
|
||||
icon: '💬',
|
||||
url: this.configService.get('CHAT_BACKEND_URL', 'http://localhost:3002'),
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
name: 'Todo',
|
||||
icon: '✅',
|
||||
url: this.configService.get('TODO_BACKEND_URL', 'http://localhost:3018'),
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
name: 'Contacts',
|
||||
icon: '👥',
|
||||
url: this.configService.get('CONTACTS_BACKEND_URL', 'http://localhost:3015'),
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
name: 'Calendar',
|
||||
icon: '📅',
|
||||
url: this.configService.get('CALENDAR_BACKEND_URL', 'http://localhost:3014'),
|
||||
},
|
||||
{
|
||||
id: 'picture',
|
||||
name: 'Picture',
|
||||
icon: '🎨',
|
||||
url: this.configService.get('PICTURE_BACKEND_URL', 'http://localhost:3006'),
|
||||
},
|
||||
{
|
||||
id: 'zitare',
|
||||
name: 'Zitare',
|
||||
icon: '💡',
|
||||
url: this.configService.get('ZITARE_BACKEND_URL', 'http://localhost:3007'),
|
||||
},
|
||||
{
|
||||
id: 'presi',
|
||||
name: 'Presi',
|
||||
icon: '📊',
|
||||
url: this.configService.get('PRESI_BACKEND_URL', 'http://localhost:3008'),
|
||||
},
|
||||
{
|
||||
id: 'photos',
|
||||
name: 'Photos',
|
||||
icon: '📷',
|
||||
url: this.configService.get('PHOTOS_BACKEND_URL', 'http://localhost:3019'),
|
||||
},
|
||||
{
|
||||
id: 'clock',
|
||||
name: 'Clock',
|
||||
icon: '⏰',
|
||||
url: this.configService.get('CLOCK_BACKEND_URL', 'http://localhost:3017'),
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
name: 'Storage',
|
||||
icon: '💾',
|
||||
url: this.configService.get('STORAGE_BACKEND_URL', 'http://localhost:3016'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all users with pagination
|
||||
*/
|
||||
async getUsers(page: number = 1, limit: number = 20, search?: string): Promise<UserListResponse> {
|
||||
const db = this.getDatabase();
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build base query
|
||||
const baseConditions = search
|
||||
? or(ilike(schema.users.email, `%${search}%`), ilike(schema.users.name, `%${search}%`))
|
||||
: undefined;
|
||||
|
||||
const [users, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(baseConditions)
|
||||
.orderBy(desc(schema.users.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.users)
|
||||
.where(baseConditions),
|
||||
]);
|
||||
|
||||
// Get last session for each user
|
||||
const userIds = users.map((user: typeof schema.users.$inferSelect) => user.id);
|
||||
const lastSessions =
|
||||
userIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
odriUserId: schema.sessions.userId,
|
||||
lastActivityAt: sql<Date>`MAX(${schema.sessions.lastActivityAt})`,
|
||||
})
|
||||
.from(schema.sessions)
|
||||
.where(sql`${schema.sessions.userId} IN (${sql.join(userIds, sql`, `)})`)
|
||||
.groupBy(schema.sessions.userId)
|
||||
: [];
|
||||
|
||||
const sessionMap = new Map(
|
||||
lastSessions.map((session) => [session.odriUserId, session.lastActivityAt])
|
||||
);
|
||||
|
||||
const userList: UserListItem[] = users.map((user: typeof schema.users.$inferSelect) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
lastActiveAt: sessionMap.get(user.id)?.toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
users: userList,
|
||||
total: countResult[0]?.count ?? 0,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated user data from all projects
|
||||
*/
|
||||
async getUserDataSummary(userId: string): Promise<UserDataSummary> {
|
||||
const db = this.getDatabase();
|
||||
this.logger.log(`Getting user data summary for userId: ${userId}`);
|
||||
|
||||
// Get user data from local DB
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
|
||||
|
||||
if (!user.length) {
|
||||
throw new NotFoundException(`User ${userId} not found`);
|
||||
}
|
||||
|
||||
// Get auth data
|
||||
const [sessionsCount, accountsCount, has2FA, lastSession] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.userId, userId)),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.accounts)
|
||||
.where(eq(schema.accounts.userId, userId)),
|
||||
db
|
||||
.select()
|
||||
.from(schema.twoFactorAuth)
|
||||
.where(eq(schema.twoFactorAuth.userId, userId))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ lastActivityAt: schema.sessions.lastActivityAt })
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.userId, userId))
|
||||
.orderBy(desc(schema.sessions.lastActivityAt))
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
// Get credits data
|
||||
const creditsResult = await db
|
||||
.select()
|
||||
.from(schema.balances)
|
||||
.where(eq(schema.balances.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const transactionsCount = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.transactions)
|
||||
.where(eq(schema.transactions.userId, userId));
|
||||
|
||||
const credits = creditsResult[0];
|
||||
|
||||
// Query all backends in parallel
|
||||
const projectResults = await Promise.all(
|
||||
this.projectConfigs.map((config) => this.queryBackend(config, userId))
|
||||
);
|
||||
|
||||
// Calculate totals
|
||||
const totalEntities = projectResults.reduce(
|
||||
(sum, p) => sum + (p.available ? p.totalCount : 0),
|
||||
0
|
||||
);
|
||||
const projectsWithData = projectResults.filter((p) => p.available && p.totalCount > 0).length;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user[0].id,
|
||||
email: user[0].email,
|
||||
name: user[0].name,
|
||||
role: user[0].role,
|
||||
createdAt: user[0].createdAt.toISOString(),
|
||||
emailVerified: user[0].emailVerified,
|
||||
},
|
||||
auth: {
|
||||
sessionsCount: sessionsCount[0]?.count ?? 0,
|
||||
accountsCount: accountsCount[0]?.count ?? 0,
|
||||
has2FA: has2FA.length > 0,
|
||||
lastLoginAt: lastSession[0]?.lastActivityAt?.toISOString() ?? null,
|
||||
},
|
||||
credits: {
|
||||
balance: credits?.balance ?? 0,
|
||||
totalEarned: credits?.totalEarned ?? 0,
|
||||
totalSpent: credits?.totalSpent ?? 0,
|
||||
transactionsCount: transactionsCount[0]?.count ?? 0,
|
||||
},
|
||||
projects: projectResults,
|
||||
totals: {
|
||||
totalEntities,
|
||||
projectsWithData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all user data across all projects (GDPR)
|
||||
*/
|
||||
async deleteUserData(userId: string): Promise<DeleteUserDataResponse> {
|
||||
const db = this.getDatabase();
|
||||
this.logger.log(`Deleting all user data for userId: ${userId}`);
|
||||
|
||||
// Verify user exists
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
|
||||
|
||||
if (!user.length) {
|
||||
throw new NotFoundException(`User ${userId} not found`);
|
||||
}
|
||||
|
||||
// Delete from all backends in parallel
|
||||
const projectResults = await Promise.all(
|
||||
this.projectConfigs.map(async (config) => {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.delete(`${config.url}/api/v1/admin/user-data/${userId}`, {
|
||||
headers: { 'X-Service-Key': this.serviceKey },
|
||||
timeout: 10000,
|
||||
})
|
||||
);
|
||||
return {
|
||||
projectId: config.id,
|
||||
projectName: config.name,
|
||||
success: true,
|
||||
deletedCount: response.data?.totalDeleted ?? 0,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Failed to delete data from ${config.name}: ${error.message}`);
|
||||
return {
|
||||
projectId: config.id,
|
||||
projectName: config.name,
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Delete from local auth tables
|
||||
const [deletedSessions, deletedAccounts, deletedTransactions] = await Promise.all([
|
||||
db.delete(schema.sessions).where(eq(schema.sessions.userId, userId)).returning(),
|
||||
db.delete(schema.accounts).where(eq(schema.accounts.userId, userId)).returning(),
|
||||
db.delete(schema.transactions).where(eq(schema.transactions.userId, userId)).returning(),
|
||||
]);
|
||||
|
||||
// Delete credits balance
|
||||
await db.delete(schema.balances).where(eq(schema.balances.userId, userId));
|
||||
|
||||
// Delete 2FA
|
||||
await db.delete(schema.twoFactorAuth).where(eq(schema.twoFactorAuth.userId, userId));
|
||||
|
||||
// Soft delete user (or hard delete if preferred)
|
||||
await db.update(schema.users).set({ deletedAt: new Date() }).where(eq(schema.users.id, userId));
|
||||
|
||||
const totalFromProjects = projectResults
|
||||
.filter((p) => p.success)
|
||||
.reduce((sum, p) => sum + (p.deletedCount ?? 0), 0);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedFromProjects: projectResults,
|
||||
deletedFromAuth: {
|
||||
sessions: deletedSessions.length,
|
||||
accounts: deletedAccounts.length,
|
||||
credits: deletedTransactions.length,
|
||||
user: true,
|
||||
},
|
||||
totalDeleted:
|
||||
totalFromProjects +
|
||||
deletedSessions.length +
|
||||
deletedAccounts.length +
|
||||
deletedTransactions.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full export data including sessions, security events, and transactions
|
||||
*/
|
||||
async getFullExportData(userId: string) {
|
||||
const summary = await this.getUserDataSummary(userId);
|
||||
|
||||
// Get additional details for export
|
||||
const [sessions, securityEvents, transactions] = await Promise.all([
|
||||
this.getSessionHistory(userId),
|
||||
this.getSecurityEvents(userId),
|
||||
this.getTransactionHistory(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
...summary,
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportVersion: '2.0',
|
||||
sessions: {
|
||||
active: sessions.filter((s) => !s.revokedAt && new Date(s.expiresAt) > new Date()),
|
||||
history: sessions,
|
||||
},
|
||||
securityEvents,
|
||||
creditTransactions: transactions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session history for a user
|
||||
*/
|
||||
private async getSessionHistory(userId: string) {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: schema.sessions.id,
|
||||
createdAt: schema.sessions.createdAt,
|
||||
expiresAt: schema.sessions.expiresAt,
|
||||
lastActivityAt: schema.sessions.lastActivityAt,
|
||||
ipAddress: schema.sessions.ipAddress,
|
||||
userAgent: schema.sessions.userAgent,
|
||||
deviceName: schema.sessions.deviceName,
|
||||
revokedAt: schema.sessions.revokedAt,
|
||||
})
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.userId, userId))
|
||||
.orderBy(desc(schema.sessions.createdAt))
|
||||
.limit(100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events for a user
|
||||
*/
|
||||
private async getSecurityEvents(userId: string) {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: schema.securityEvents.id,
|
||||
eventType: schema.securityEvents.eventType,
|
||||
ipAddress: schema.securityEvents.ipAddress,
|
||||
userAgent: schema.securityEvents.userAgent,
|
||||
metadata: schema.securityEvents.metadata,
|
||||
createdAt: schema.securityEvents.createdAt,
|
||||
})
|
||||
.from(schema.securityEvents)
|
||||
.where(eq(schema.securityEvents.userId, userId))
|
||||
.orderBy(desc(schema.securityEvents.createdAt))
|
||||
.limit(100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transaction history for a user
|
||||
*/
|
||||
private async getTransactionHistory(userId: string) {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: schema.transactions.id,
|
||||
type: schema.transactions.type,
|
||||
status: schema.transactions.status,
|
||||
amount: schema.transactions.amount,
|
||||
balanceBefore: schema.transactions.balanceBefore,
|
||||
balanceAfter: schema.transactions.balanceAfter,
|
||||
appId: schema.transactions.appId,
|
||||
description: schema.transactions.description,
|
||||
createdAt: schema.transactions.createdAt,
|
||||
completedAt: schema.transactions.completedAt,
|
||||
})
|
||||
.from(schema.transactions)
|
||||
.where(eq(schema.transactions.userId, userId))
|
||||
.orderBy(desc(schema.transactions.createdAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user data for email (before deletion)
|
||||
*/
|
||||
async getUserForEmail(userId: string) {
|
||||
const db = this.getDatabase();
|
||||
|
||||
const user = await db
|
||||
.select({
|
||||
email: schema.users.email,
|
||||
name: schema.users.name,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
return user[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query a single backend for user data
|
||||
*/
|
||||
private async queryBackend(config: ProjectConfig, userId: string): Promise<ProjectDataSummary> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${config.url}/api/v1/admin/user-data/${userId}`, {
|
||||
headers: { 'X-Service-Key': this.serviceKey },
|
||||
timeout: 5000,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
projectId: config.id,
|
||||
projectName: config.name,
|
||||
icon: config.icon,
|
||||
available: true,
|
||||
entities: response.data.entities || [],
|
||||
totalCount: response.data.totalCount || 0,
|
||||
lastActivityAt: response.data.lastActivityAt,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Backend ${config.name} unavailable: ${error.message}`);
|
||||
return {
|
||||
projectId: config.id,
|
||||
projectName: config.name,
|
||||
icon: config.icon,
|
||||
available: false,
|
||||
error: error.code === 'ECONNREFUSED' ? 'Backend offline' : error.message,
|
||||
entities: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { LlmClientService } from '@manacore/shared-llm';
|
||||
|
||||
export interface FeedbackAnalysis {
|
||||
title: string;
|
||||
category: 'bug' | 'feature' | 'improvement' | 'question' | 'other';
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES = ['bug', 'feature', 'improvement', 'question', 'other'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
|
||||
constructor(private readonly llm: LlmClientService) {}
|
||||
|
||||
async analyzeFeedback(feedbackText: string): Promise<FeedbackAnalysis> {
|
||||
try {
|
||||
const prompt = `Analysiere dieses User-Feedback und generiere:
|
||||
1. Einen kurzen, prägnanten deutschen Titel (max 60 Zeichen) der den Kern des Feedbacks zusammenfasst
|
||||
2. Eine passende Kategorie aus: bug, feature, improvement, question, other
|
||||
|
||||
Feedback: "${feedbackText}"
|
||||
|
||||
Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein anderer Text):
|
||||
{"title": "...", "category": "..."}`;
|
||||
|
||||
const { data } = await this.llm.json<FeedbackAnalysis>(prompt, {
|
||||
temperature: 0.3,
|
||||
timeout: 30_000,
|
||||
validate: (raw) => {
|
||||
const obj = raw as FeedbackAnalysis;
|
||||
if (!obj.title || !obj.category) throw new Error('missing fields');
|
||||
if (!VALID_CATEGORIES.includes(obj.category as any)) {
|
||||
obj.category = 'other';
|
||||
}
|
||||
if (obj.title.length > 60) {
|
||||
obj.title = obj.title.substring(0, 57) + '...';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(`AI analyzed feedback: ${JSON.stringify(data)}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error(`AI analysis failed: ${error}`);
|
||||
return this.fallbackAnalysis(feedbackText);
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackAnalysis(feedbackText: string): FeedbackAnalysis {
|
||||
// Simple fallback: use first 60 chars as title, default category
|
||||
const title = feedbackText.length > 60 ? feedbackText.substring(0, 57) + '...' : feedbackText;
|
||||
|
||||
// Simple keyword-based category detection
|
||||
const lowerText = feedbackText.toLowerCase();
|
||||
let category: FeedbackAnalysis['category'] = 'feature';
|
||||
|
||||
if (
|
||||
lowerText.includes('bug') ||
|
||||
lowerText.includes('fehler') ||
|
||||
lowerText.includes('kaputt') ||
|
||||
lowerText.includes('funktioniert nicht')
|
||||
) {
|
||||
category = 'bug';
|
||||
} else if (
|
||||
lowerText.includes('?') ||
|
||||
lowerText.includes('frage') ||
|
||||
lowerText.includes('wie')
|
||||
) {
|
||||
category = 'question';
|
||||
} else if (
|
||||
lowerText.includes('besser') ||
|
||||
lowerText.includes('verbessern') ||
|
||||
lowerText.includes('optimieren')
|
||||
) {
|
||||
category = 'improvement';
|
||||
}
|
||||
|
||||
return { title, category };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './ai.module';
|
||||
export * from './ai.service';
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { Controller, Get, Post, Query, Res, HttpStatus } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
@Get('health')
|
||||
async getHealth() {
|
||||
return this.analyticsService.getHealth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest metrics snapshot
|
||||
*/
|
||||
@Get('latest')
|
||||
async getLatest() {
|
||||
const metrics = await this.analyticsService.getLatestMetrics();
|
||||
if (!metrics) {
|
||||
return { message: 'No metrics recorded yet' };
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user growth data
|
||||
* @param days Number of days to look back (default: 90)
|
||||
*/
|
||||
@Get('growth')
|
||||
async getGrowth(@Query('days') days?: string) {
|
||||
const numDays = days ? parseInt(days, 10) : 90;
|
||||
return this.analyticsService.getUserGrowth(numDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly aggregated metrics
|
||||
* @param months Number of months to look back (default: 12)
|
||||
*/
|
||||
@Get('monthly')
|
||||
async getMonthly(@Query('months') months?: string) {
|
||||
const numMonths = months ? parseInt(months, 10) : 12;
|
||||
return this.analyticsService.getMonthlyMetrics(numMonths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics for a date range
|
||||
* @param start Start date (YYYY-MM-DD)
|
||||
* @param end End date (YYYY-MM-DD)
|
||||
*/
|
||||
@Get('range')
|
||||
async getRange(@Query('start') start: string, @Query('end') end: string) {
|
||||
if (!start || !end) {
|
||||
return { error: 'Both start and end dates are required (YYYY-MM-DD format)' };
|
||||
}
|
||||
return this.analyticsService.getMetricsRange(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual snapshot (for testing/recovery)
|
||||
*/
|
||||
@Post('snapshot')
|
||||
async triggerSnapshot() {
|
||||
await this.analyticsService.recordDailySnapshot();
|
||||
return { message: 'Snapshot recorded successfully' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Grafana JSON API compatible endpoint - query
|
||||
* Used by Grafana Infinity datasource
|
||||
*/
|
||||
@Post('grafana/query')
|
||||
async grafanaQuery(@Res() res: Response) {
|
||||
// Return available targets
|
||||
const latest = await this.analyticsService.getLatestMetrics();
|
||||
const growth = await this.analyticsService.getUserGrowth(30);
|
||||
|
||||
res.status(HttpStatus.OK).json([
|
||||
{
|
||||
target: 'total_users',
|
||||
datapoints: growth.map((g) => [g.total_users, new Date(g.date).getTime()]),
|
||||
},
|
||||
{
|
||||
target: 'daily_growth',
|
||||
datapoints: growth.map((g) => [g.growth ?? 0, new Date(g.date).getTime()]),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grafana JSON API compatible endpoint - search
|
||||
* Returns available metrics
|
||||
*/
|
||||
@Post('grafana/search')
|
||||
async grafanaSearch() {
|
||||
return [
|
||||
'total_users',
|
||||
'verified_users',
|
||||
'new_users_today',
|
||||
'new_users_week',
|
||||
'new_users_month',
|
||||
'daily_growth',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary endpoint for dashboards
|
||||
*/
|
||||
@Get('summary')
|
||||
async getSummary() {
|
||||
const latest = await this.analyticsService.getLatestMetrics();
|
||||
const monthly = await this.analyticsService.getMonthlyMetrics(2);
|
||||
const health = await this.analyticsService.getHealth();
|
||||
|
||||
const currentMonth = monthly[monthly.length - 1];
|
||||
const previousMonth = monthly[monthly.length - 2];
|
||||
|
||||
return {
|
||||
current: latest,
|
||||
trends: {
|
||||
month_over_month_growth:
|
||||
currentMonth && previousMonth
|
||||
? ((currentMonth.total_users_eom - previousMonth.total_users_eom) /
|
||||
previousMonth.total_users_eom) *
|
||||
100
|
||||
: null,
|
||||
new_users_this_month: currentMonth?.new_users ?? 0,
|
||||
},
|
||||
health,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
exports: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Database } from 'duckdb-async';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface DailyMetrics {
|
||||
date: string;
|
||||
total_users: number;
|
||||
verified_users: number;
|
||||
new_users_today: number;
|
||||
new_users_week: number;
|
||||
new_users_month: number;
|
||||
total_db_size_bytes: number | null;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
export interface GrowthData {
|
||||
date: string;
|
||||
total_users: number;
|
||||
growth: number | null;
|
||||
growth_percent: number | null;
|
||||
}
|
||||
|
||||
export interface MonthlyMetrics {
|
||||
month: string;
|
||||
total_users_eom: number;
|
||||
new_users: number;
|
||||
growth_percent: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
private duckdb: Database | null = null;
|
||||
private readonly dbPath: string;
|
||||
private readonly databaseUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.dbPath = this.configService.get<string>('DUCKDB_PATH', './data/metrics.duckdb');
|
||||
this.databaseUrl = this.configService.get<string>('DATABASE_URL', '');
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
const dbDir = path.dirname(this.dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
this.logger.log(`Created DuckDB directory: ${dbDir}`);
|
||||
}
|
||||
|
||||
this.duckdb = await Database.create(this.dbPath);
|
||||
await this.initializeSchema();
|
||||
this.logger.log(`DuckDB initialized at ${this.dbPath}`);
|
||||
|
||||
// Record initial snapshot if database is empty
|
||||
const count = await this.getRecordCount();
|
||||
if (count === 0) {
|
||||
this.logger.log('No existing records found, recording initial snapshot...');
|
||||
await this.recordDailySnapshot();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize DuckDB', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.duckdb) {
|
||||
await this.duckdb.close();
|
||||
this.logger.log('DuckDB connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeSchema(): Promise<void> {
|
||||
if (!this.duckdb) return;
|
||||
|
||||
await this.duckdb.run(`
|
||||
CREATE TABLE IF NOT EXISTS daily_metrics (
|
||||
date DATE PRIMARY KEY,
|
||||
total_users INTEGER NOT NULL,
|
||||
verified_users INTEGER NOT NULL,
|
||||
new_users_today INTEGER NOT NULL,
|
||||
new_users_week INTEGER NOT NULL,
|
||||
new_users_month INTEGER NOT NULL,
|
||||
total_db_size_bytes BIGINT,
|
||||
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
this.logger.log('DuckDB schema initialized');
|
||||
}
|
||||
|
||||
private async getRecordCount(): Promise<number> {
|
||||
if (!this.duckdb) return 0;
|
||||
const result = await this.duckdb.all('SELECT COUNT(*) as count FROM daily_metrics');
|
||||
return Number(result[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record daily snapshot - runs at midnight UTC
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async recordDailySnapshot(): Promise<void> {
|
||||
if (!this.duckdb) {
|
||||
this.logger.warn('DuckDB not initialized, skipping snapshot');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Get user counts from PostgreSQL
|
||||
const [totalUsers, verifiedUsers, newToday, newWeek, newMonth, dbSize] = await Promise.all([
|
||||
this.countTotalUsers(),
|
||||
this.countVerifiedUsers(),
|
||||
this.countUsersCreatedSince(1),
|
||||
this.countUsersCreatedSince(7),
|
||||
this.countUsersCreatedSince(30),
|
||||
this.getDatabaseSize(),
|
||||
]);
|
||||
|
||||
// Insert or replace in DuckDB
|
||||
await this.duckdb.run(
|
||||
`
|
||||
INSERT OR REPLACE INTO daily_metrics
|
||||
(date, total_users, verified_users, new_users_today, new_users_week, new_users_month, total_db_size_bytes, recorded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`,
|
||||
today,
|
||||
totalUsers,
|
||||
verifiedUsers,
|
||||
newToday,
|
||||
newWeek,
|
||||
newMonth,
|
||||
dbSize
|
||||
);
|
||||
|
||||
this.logger.log(`Daily snapshot recorded for ${today}: ${totalUsers} total users`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to record daily snapshot', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user growth over time
|
||||
*/
|
||||
async getUserGrowth(days = 90): Promise<GrowthData[]> {
|
||||
if (!this.duckdb) return [];
|
||||
|
||||
const result = await this.duckdb.all(
|
||||
`
|
||||
SELECT
|
||||
date::VARCHAR as date,
|
||||
total_users,
|
||||
total_users - LAG(total_users) OVER (ORDER BY date) as growth,
|
||||
ROUND(((total_users::FLOAT - LAG(total_users) OVER (ORDER BY date)) /
|
||||
NULLIF(LAG(total_users) OVER (ORDER BY date), 0)) * 100, 2) as growth_percent
|
||||
FROM daily_metrics
|
||||
WHERE date > CURRENT_DATE - INTERVAL '${days} days'
|
||||
ORDER BY date
|
||||
`
|
||||
);
|
||||
|
||||
return result as GrowthData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly aggregated metrics
|
||||
*/
|
||||
async getMonthlyMetrics(months = 12): Promise<MonthlyMetrics[]> {
|
||||
if (!this.duckdb) return [];
|
||||
|
||||
const result = await this.duckdb.all(
|
||||
`
|
||||
SELECT
|
||||
strftime(date_trunc('month', date), '%Y-%m') as month,
|
||||
MAX(total_users)::INTEGER as total_users_eom,
|
||||
SUM(new_users_today)::INTEGER as new_users,
|
||||
ROUND(((MAX(total_users)::FLOAT - MIN(total_users)) /
|
||||
NULLIF(MIN(total_users), 0)) * 100, 2) as growth_percent
|
||||
FROM daily_metrics
|
||||
WHERE date > CURRENT_DATE - INTERVAL '${months} months'
|
||||
GROUP BY date_trunc('month', date)
|
||||
ORDER BY month
|
||||
`
|
||||
);
|
||||
|
||||
return result as MonthlyMetrics[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest metrics
|
||||
*/
|
||||
async getLatestMetrics(): Promise<DailyMetrics | null> {
|
||||
if (!this.duckdb) return null;
|
||||
|
||||
const result = await this.duckdb.all(`
|
||||
SELECT
|
||||
date::VARCHAR as date,
|
||||
total_users,
|
||||
verified_users,
|
||||
new_users_today,
|
||||
new_users_week,
|
||||
new_users_month,
|
||||
total_db_size_bytes::INTEGER as total_db_size_bytes,
|
||||
recorded_at::VARCHAR as recorded_at
|
||||
FROM daily_metrics
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
return (result[0] as DailyMetrics) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics for a date range
|
||||
*/
|
||||
async getMetricsRange(startDate: string, endDate: string): Promise<DailyMetrics[]> {
|
||||
if (!this.duckdb) return [];
|
||||
|
||||
const result = await this.duckdb.all(
|
||||
`
|
||||
SELECT
|
||||
date::VARCHAR as date,
|
||||
total_users,
|
||||
verified_users,
|
||||
new_users_today,
|
||||
new_users_week,
|
||||
new_users_month,
|
||||
total_db_size_bytes::INTEGER as total_db_size_bytes,
|
||||
recorded_at::VARCHAR as recorded_at
|
||||
FROM daily_metrics
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date
|
||||
`,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
return result as DailyMetrics[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for the analytics service
|
||||
*/
|
||||
async getHealth(): Promise<{
|
||||
status: string;
|
||||
database_path: string;
|
||||
database_size_bytes: number | null;
|
||||
total_records: number;
|
||||
latest_snapshot: string | null;
|
||||
}> {
|
||||
const recordCount = await this.getRecordCount();
|
||||
const latest = await this.getLatestMetrics();
|
||||
|
||||
return {
|
||||
status: this.duckdb ? 'healthy' : 'unhealthy',
|
||||
database_path: this.dbPath,
|
||||
database_size_bytes: null, // DuckDB doesn't expose this easily
|
||||
total_records: recordCount,
|
||||
latest_snapshot: latest?.date ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export metrics to Parquet format (for archival)
|
||||
*/
|
||||
async exportToParquet(outputPath: string): Promise<void> {
|
||||
if (!this.duckdb) {
|
||||
throw new Error('DuckDB not initialized');
|
||||
}
|
||||
|
||||
await this.duckdb.run(`COPY daily_metrics TO '${outputPath}' (FORMAT PARQUET)`);
|
||||
this.logger.log(`Metrics exported to ${outputPath}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PostgreSQL Query Helpers
|
||||
// ============================================
|
||||
|
||||
private getPostgresDb() {
|
||||
if (!this.databaseUrl) {
|
||||
throw new Error('DATABASE_URL not configured');
|
||||
}
|
||||
return getDb(this.databaseUrl);
|
||||
}
|
||||
|
||||
private async countTotalUsers(): Promise<number> {
|
||||
const db = this.getPostgresDb();
|
||||
const result = await db.execute(sql`SELECT COUNT(*) as count FROM auth.users`);
|
||||
const row = result[0] as { count: string | number } | undefined;
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
private async countVerifiedUsers(): Promise<number> {
|
||||
const db = this.getPostgresDb();
|
||||
const result = await db.execute(
|
||||
sql`SELECT COUNT(*) as count FROM auth.users WHERE email_verified = true`
|
||||
);
|
||||
const row = result[0] as { count: string | number } | undefined;
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
private async countUsersCreatedSince(days: number): Promise<number> {
|
||||
const db = this.getPostgresDb();
|
||||
const result = await db.execute(
|
||||
sql`SELECT COUNT(*) as count FROM auth.users WHERE created_at > NOW() - INTERVAL '${sql.raw(days.toString())} days'`
|
||||
);
|
||||
const row = result[0] as { count: string | number } | undefined;
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
private async getDatabaseSize(): Promise<number | null> {
|
||||
try {
|
||||
const db = this.getPostgresDb();
|
||||
const result = await db.execute(sql`SELECT pg_database_size(current_database()) as size`);
|
||||
const row = result[0] as { size: string | number } | undefined;
|
||||
return Number(row?.size ?? 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './analytics.module';
|
||||
export * from './analytics.service';
|
||||
export * from './analytics.controller';
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { CreateApiKeyDto, ValidateApiKeyDto } from './dto';
|
||||
import { SecurityEventsService, SecurityEventType } from '../security';
|
||||
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
constructor(
|
||||
private readonly apiKeysService: ApiKeysService,
|
||||
private readonly securityEvents: SecurityEventsService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List all API keys for the authenticated user
|
||||
*/
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async listKeys(@CurrentUser() user: CurrentUserData) {
|
||||
return this.apiKeysService.listUserApiKeys(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key
|
||||
* Returns the full key only once - it cannot be retrieved later
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createKey(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
@Req() req: Request
|
||||
) {
|
||||
const result = await this.apiKeysService.createApiKey(user.userId, dto);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: user.userId,
|
||||
eventType: SecurityEventType.API_KEY_CREATED,
|
||||
metadata: { keyId: result.id, name: dto.name, scopes: dto.scopes },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key
|
||||
*/
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async revokeKey(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request
|
||||
) {
|
||||
await this.apiKeysService.revokeApiKey(user.userId, id);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: user.userId,
|
||||
eventType: SecurityEventType.API_KEY_REVOKED,
|
||||
metadata: { keyId: id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key (for internal services like STT/TTS)
|
||||
*
|
||||
* This endpoint does NOT require JWT authentication since it's called
|
||||
* by services that only have an API key, not a JWT.
|
||||
*
|
||||
* Rate limited to 10 requests/minute per IP to prevent brute force.
|
||||
*/
|
||||
@Post('validate')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async validateKey(@Body() dto: ValidateApiKeyDto, @Req() req: Request) {
|
||||
const result = await this.apiKeysService.validateApiKey(dto.apiKey, dto.scope);
|
||||
|
||||
const eventType = result.valid
|
||||
? SecurityEventType.API_KEY_VALIDATED
|
||||
: SecurityEventType.API_KEY_VALIDATION_FAILED;
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: result.valid ? result.userId : undefined,
|
||||
eventType,
|
||||
metadata: {
|
||||
scope: dto.scope,
|
||||
keyPrefix: dto.apiKey?.substring(0, 16) + '...',
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { SecurityModule } from '../security';
|
||||
|
||||
@Module({
|
||||
imports: [SecurityModule],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getDb } from '../db/connection';
|
||||
import { apiKeys } from '../db/schema';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import type { ValidateApiKeyResponseDto } from './dto/validate-api-key.dto';
|
||||
|
||||
const DEFAULT_SCOPES = ['stt', 'tts'];
|
||||
const KEY_PREFIX = 'sk_live_';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
* Format: sk_live_<32 random hex chars>
|
||||
*/
|
||||
private generateKey(): string {
|
||||
const randomPart = randomBytes(16).toString('hex');
|
||||
return `${KEY_PREFIX}${randomPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an API key using SHA-256
|
||||
*/
|
||||
private hashKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prefix for display (first 12 characters after sk_live_)
|
||||
*/
|
||||
private getKeyPrefix(key: string): string {
|
||||
return key.substring(0, KEY_PREFIX.length + 8) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API keys for a user (without exposing the full key)
|
||||
*/
|
||||
async listUserApiKeys(userId: string) {
|
||||
const db = this.getDb();
|
||||
const keys = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
scopes: apiKeys.scopes,
|
||||
rateLimitRequests: apiKeys.rateLimitRequests,
|
||||
rateLimitWindow: apiKeys.rateLimitWindow,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId));
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key
|
||||
* Returns the full key only once - it cannot be retrieved later
|
||||
*/
|
||||
async createApiKey(userId: string, dto: CreateApiKeyDto) {
|
||||
const db = this.getDb();
|
||||
|
||||
const key = this.generateKey();
|
||||
const keyHash = this.hashKey(key);
|
||||
const keyPrefix = this.getKeyPrefix(key);
|
||||
|
||||
const [apiKey] = await db
|
||||
.insert(apiKeys)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
name: dto.name,
|
||||
keyPrefix,
|
||||
keyHash,
|
||||
scopes: dto.scopes || DEFAULT_SCOPES,
|
||||
rateLimitRequests: dto.rateLimitRequests || 60,
|
||||
rateLimitWindow: dto.rateLimitWindow || 60,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Return the full key only on creation
|
||||
return {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key, // Full key - shown only once
|
||||
keyPrefix: apiKey.keyPrefix,
|
||||
scopes: apiKey.scopes,
|
||||
rateLimitRequests: apiKey.rateLimitRequests,
|
||||
rateLimitWindow: apiKey.rateLimitWindow,
|
||||
createdAt: apiKey.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key (soft delete)
|
||||
*/
|
||||
async revokeApiKey(userId: string, keyId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Verify key exists and belongs to user
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('API key not found');
|
||||
}
|
||||
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key (for STT/TTS services to call)
|
||||
* This endpoint does NOT require authentication
|
||||
*/
|
||||
async validateApiKey(apiKey: string, scope?: string): Promise<ValidateApiKeyResponseDto> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Hash the incoming key to compare
|
||||
const keyHash = this.hashKey(apiKey);
|
||||
|
||||
// Find the key
|
||||
const [key] = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(and(eq(apiKeys.keyHash, keyHash), isNull(apiKeys.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!key) {
|
||||
return { valid: false, error: 'Invalid or revoked API key' };
|
||||
}
|
||||
|
||||
// Check scope if provided
|
||||
if (scope && key.scopes && !key.scopes.includes(scope)) {
|
||||
return { valid: false, error: `API key does not have scope: ${scope}` };
|
||||
}
|
||||
|
||||
// Update last used timestamp (fire-and-forget)
|
||||
db.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(apiKeys.id, key.id))
|
||||
.then(() => {})
|
||||
.catch(() => {});
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
userId: key.userId,
|
||||
scopes: key.scopes || [],
|
||||
rateLimit: {
|
||||
requests: key.rateLimitRequests,
|
||||
window: key.rateLimitWindow,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { IsString, IsOptional, MaxLength, IsArray, IsInt, Min, Max } from 'class-validator';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
scopes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
rateLimitRequests?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3600)
|
||||
rateLimitWindow?: number;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './create-api-key.dto';
|
||||
export * from './validate-api-key.dto';
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class ValidateApiKeyDto {
|
||||
@IsString()
|
||||
apiKey: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export class ValidateApiKeyResponseDto {
|
||||
valid: boolean;
|
||||
userId?: string;
|
||||
scopes?: string[];
|
||||
rateLimit?: {
|
||||
requests: number;
|
||||
window: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './api-keys.module';
|
||||
export * from './api-keys.service';
|
||||
export * from './api-keys.controller';
|
||||
export * from './dto';
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { LlmModule } from '@manacore/shared-llm';
|
||||
import configuration from './config/configuration';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { GuildsModule } from './guilds/guilds.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { MeModule } from './me/me.module';
|
||||
import { MetricsModule } from './metrics';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { LoggerModule } from './common/logger';
|
||||
import { SecurityModule } from './security';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000, // 60 seconds
|
||||
limit: 100, // 100 requests per minute
|
||||
},
|
||||
]),
|
||||
LlmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
manaLlmUrl: config.get('MANA_LLM_URL'),
|
||||
debug: config.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
LoggerModule,
|
||||
SecurityModule,
|
||||
MetricsModule,
|
||||
AdminModule,
|
||||
ApiKeysModule,
|
||||
AuthModule,
|
||||
GuildsModule,
|
||||
HealthModule,
|
||||
MeModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionFilter,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,738 +0,0 @@
|
|||
/**
|
||||
* AuthController Unit Tests
|
||||
*
|
||||
* Tests all authentication controller endpoints using BetterAuthService:
|
||||
*
|
||||
* B2C Endpoints:
|
||||
* - POST /auth/register - User registration
|
||||
* - POST /auth/login - User login
|
||||
* - POST /auth/logout - User logout
|
||||
* - POST /auth/refresh - Token refresh
|
||||
* - GET /auth/session - Get current session
|
||||
* - POST /auth/validate - Token validation
|
||||
*
|
||||
* B2B Endpoints:
|
||||
* - POST /auth/register/b2b - Organization registration
|
||||
* - GET /auth/organizations - List organizations
|
||||
* - GET /auth/organizations/:id - Get organization
|
||||
* - GET /auth/organizations/:id/members - Get organization members
|
||||
* - POST /auth/organizations/:id/invite - Invite employee
|
||||
* - POST /auth/organizations/accept-invitation - Accept invitation
|
||||
* - DELETE /auth/organizations/:id/members/:memberId - Remove member
|
||||
* - POST /auth/organizations/set-active - Set active organization
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { SecurityEventsService, AccountLockoutService } from '../security';
|
||||
import { mockDtoFactory } from '../__tests__/utils/mock-factories';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
let betterAuthService: jest.Mocked<BetterAuthService>;
|
||||
|
||||
// Common test data
|
||||
const mockAuthHeader = 'Bearer valid-jwt-token';
|
||||
const mockToken = 'valid-jwt-token';
|
||||
const mockReq = { headers: { 'user-agent': 'test' }, ip: '127.0.0.1' } as any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock BetterAuthService with all methods
|
||||
const mockBetterAuthService = {
|
||||
registerB2C: jest.fn(),
|
||||
registerB2B: jest.fn(),
|
||||
signIn: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
listOrganizations: jest.fn(),
|
||||
getOrganization: jest.fn(),
|
||||
getOrganizationMembers: jest.fn(),
|
||||
inviteEmployee: jest.fn(),
|
||||
acceptInvitation: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
setActiveOrganization: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
validateToken: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSecurityEventsService = {
|
||||
logEvent: jest.fn().mockResolvedValue(undefined),
|
||||
logEventWithRequest: jest.fn().mockResolvedValue(undefined),
|
||||
extractRequestInfo: jest.fn().mockReturnValue({ ipAddress: '127.0.0.1', userAgent: 'test' }),
|
||||
};
|
||||
|
||||
const mockAccountLockoutService = {
|
||||
checkLockout: jest.fn().mockResolvedValue({ locked: false }),
|
||||
recordAttempt: jest.fn().mockResolvedValue(undefined),
|
||||
clearAttempts: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: BetterAuthService,
|
||||
useValue: mockBetterAuthService,
|
||||
},
|
||||
{
|
||||
provide: SecurityEventsService,
|
||||
useValue: mockSecurityEventsService,
|
||||
},
|
||||
{
|
||||
provide: AccountLockoutService,
|
||||
useValue: mockAccountLockoutService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.overrideGuard(ThrottlerGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
betterAuthService = module.get(BetterAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/register (B2C)
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/register', () => {
|
||||
it('should successfully register a new B2C user', async () => {
|
||||
const registerDto = mockDtoFactory.register({
|
||||
email: 'newuser@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'New User',
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: registerDto.email,
|
||||
name: registerDto.name,
|
||||
},
|
||||
token: 'jwt-token',
|
||||
};
|
||||
|
||||
betterAuthService.registerB2C.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.register(registerDto, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.registerB2C).toHaveBeenCalledWith({
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: registerDto.name,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle registration without name', async () => {
|
||||
const registerDto = {
|
||||
email: 'noname@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
user: { id: 'user-456', email: registerDto.email, name: '' },
|
||||
token: 'jwt-token',
|
||||
};
|
||||
|
||||
betterAuthService.registerB2C.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.register(registerDto as any, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.registerB2C).toHaveBeenCalledWith({
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: '', // Controller passes empty string as fallback when name is not provided
|
||||
sourceAppUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate ConflictException when user exists', async () => {
|
||||
const registerDto = mockDtoFactory.register({ email: 'existing@example.com' });
|
||||
|
||||
betterAuthService.registerB2C.mockRejectedValue(
|
||||
new ConflictException('User with this email already exists')
|
||||
);
|
||||
|
||||
await expect(controller.register(registerDto, mockReq)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/login
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it('should successfully login a user', async () => {
|
||||
const loginDto = mockDtoFactory.login({
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: loginDto.email,
|
||||
name: 'Test User',
|
||||
role: 'user',
|
||||
},
|
||||
accessToken: 'jwt-access-token',
|
||||
refreshToken: 'session-refresh-token',
|
||||
expiresIn: 900,
|
||||
};
|
||||
|
||||
betterAuthService.signIn.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.login(loginDto, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.signIn).toHaveBeenCalledWith({
|
||||
email: loginDto.email,
|
||||
password: loginDto.password,
|
||||
deviceId: undefined,
|
||||
deviceName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass device info when provided', async () => {
|
||||
const loginDto = {
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
deviceId: 'device-abc-123',
|
||||
deviceName: 'iPhone 15 Pro',
|
||||
};
|
||||
|
||||
betterAuthService.signIn.mockResolvedValue({
|
||||
user: { id: '123', email: 'user@example.com', name: 'Test', role: 'user' },
|
||||
accessToken: 'jwt-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
await controller.login(loginDto, mockReq);
|
||||
|
||||
expect(betterAuthService.signIn).toHaveBeenCalledWith({
|
||||
email: loginDto.email,
|
||||
password: loginDto.password,
|
||||
deviceId: 'device-abc-123',
|
||||
deviceName: 'iPhone 15 Pro',
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate UnauthorizedException for invalid credentials', async () => {
|
||||
const loginDto = mockDtoFactory.login({ password: 'WrongPassword' });
|
||||
|
||||
betterAuthService.signIn.mockRejectedValue(
|
||||
new UnauthorizedException('Invalid email or password')
|
||||
);
|
||||
|
||||
await expect(controller.login(loginDto, mockReq)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/logout
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('should successfully logout a user', async () => {
|
||||
const expectedResult = { success: true, message: 'Signed out successfully' };
|
||||
|
||||
betterAuthService.signOut.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.logout(mockAuthHeader, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith(mockToken);
|
||||
});
|
||||
|
||||
it('should extract token from Bearer header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' });
|
||||
|
||||
await controller.logout('Bearer my-secret-token', mockReq);
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('my-secret-token');
|
||||
});
|
||||
|
||||
it('should handle raw token without Bearer prefix', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' });
|
||||
|
||||
await controller.logout('raw-token', mockReq);
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('raw-token');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/refresh
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/refresh', () => {
|
||||
it('should successfully refresh tokens', async () => {
|
||||
const refreshTokenDto = { refreshToken: 'valid-refresh-token' };
|
||||
|
||||
const expectedResult = {
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresIn: 900,
|
||||
tokenType: 'Bearer',
|
||||
user: { id: 'user-123', email: 'user@example.com', name: 'Test', role: 'user' as const },
|
||||
};
|
||||
|
||||
betterAuthService.refreshToken.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.refresh(refreshTokenDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.refreshToken).toHaveBeenCalledWith('valid-refresh-token');
|
||||
});
|
||||
|
||||
it('should propagate UnauthorizedException for invalid refresh token', async () => {
|
||||
const refreshTokenDto = { refreshToken: 'invalid-token' };
|
||||
|
||||
betterAuthService.refreshToken.mockRejectedValue(
|
||||
new UnauthorizedException('Invalid refresh token')
|
||||
);
|
||||
|
||||
await expect(controller.refresh(refreshTokenDto)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/session
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/session', () => {
|
||||
it('should return current session', async () => {
|
||||
const expectedResult = {
|
||||
user: { id: 'user-123', email: 'user@example.com', name: 'Test' },
|
||||
session: { id: 'session-123', activeOrganizationId: null },
|
||||
};
|
||||
|
||||
betterAuthService.getSession.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.getSession(mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.getSession).toHaveBeenCalledWith(mockToken);
|
||||
});
|
||||
|
||||
it('should propagate UnauthorizedException for invalid session', async () => {
|
||||
betterAuthService.getSession.mockRejectedValue(
|
||||
new UnauthorizedException('Invalid or expired session')
|
||||
);
|
||||
|
||||
await expect(controller.getSession(mockAuthHeader)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/validate
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/validate', () => {
|
||||
it('should return valid for a valid token', async () => {
|
||||
const body = { token: 'valid-jwt-token' };
|
||||
|
||||
const expectedResult = {
|
||||
valid: true,
|
||||
payload: { sub: 'user-123', email: 'user@example.com', role: 'user' },
|
||||
};
|
||||
|
||||
betterAuthService.validateToken.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.validate(body);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.validateToken).toHaveBeenCalledWith(body.token);
|
||||
});
|
||||
|
||||
it('should return invalid for expired token', async () => {
|
||||
const body = { token: 'expired-token' };
|
||||
|
||||
betterAuthService.validateToken.mockResolvedValue({
|
||||
valid: false,
|
||||
error: 'Token expired',
|
||||
} as any);
|
||||
|
||||
const result = await controller.validate(body);
|
||||
|
||||
expect((result as any).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/register/b2b
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/register/b2b', () => {
|
||||
it('should successfully register a B2B organization', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@acme.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
user: { id: 'user-123', email: registerDto.ownerEmail, name: registerDto.ownerName },
|
||||
organization: { id: 'org-456', name: 'Acme Corporation', slug: 'acme-corporation' },
|
||||
token: 'jwt-token',
|
||||
};
|
||||
|
||||
betterAuthService.registerB2B.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.registerB2B(registerDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.registerB2B).toHaveBeenCalledWith(registerDto);
|
||||
});
|
||||
|
||||
it('should propagate ConflictException when owner email exists', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'existing@acme.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John',
|
||||
organizationName: 'Acme',
|
||||
};
|
||||
|
||||
betterAuthService.registerB2B.mockRejectedValue(
|
||||
new ConflictException('Owner email already exists')
|
||||
);
|
||||
|
||||
await expect(controller.registerB2B(registerDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/organizations
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/organizations', () => {
|
||||
it('should list user organizations', async () => {
|
||||
const expectedResult = {
|
||||
organizations: [
|
||||
{ id: 'org-1', name: 'Org One', slug: 'org-one' },
|
||||
{ id: 'org-2', name: 'Org Two', slug: 'org-two' },
|
||||
],
|
||||
};
|
||||
|
||||
betterAuthService.listOrganizations.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.listOrganizations(mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.listOrganizations).toHaveBeenCalledWith(mockToken);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no organizations', async () => {
|
||||
betterAuthService.listOrganizations.mockResolvedValue({ organizations: [] });
|
||||
|
||||
const result = await controller.listOrganizations(mockAuthHeader);
|
||||
|
||||
expect(result.organizations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/organizations/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/organizations/:id', () => {
|
||||
it('should get organization details', async () => {
|
||||
const orgId = 'org-123';
|
||||
const expectedResult = {
|
||||
id: orgId,
|
||||
name: 'Acme Corp',
|
||||
slug: 'acme-corp',
|
||||
members: [{ id: 'member-1', userId: 'user-1', role: 'owner' }],
|
||||
};
|
||||
|
||||
betterAuthService.getOrganization.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.getOrganization(orgId, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.getOrganization).toHaveBeenCalledWith(orgId, mockToken);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when organization not found', async () => {
|
||||
betterAuthService.getOrganization.mockRejectedValue(
|
||||
new NotFoundException('Organization not found')
|
||||
);
|
||||
|
||||
await expect(controller.getOrganization('invalid-id', mockAuthHeader)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/organizations/:id/members
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/organizations/:id/members', () => {
|
||||
it('should get organization members', async () => {
|
||||
const orgId = 'org-123';
|
||||
const expectedMembers = [
|
||||
{ id: 'member-1', userId: 'user-1', organizationId: orgId, role: 'owner' },
|
||||
{ id: 'member-2', userId: 'user-2', organizationId: orgId, role: 'member' },
|
||||
];
|
||||
|
||||
betterAuthService.getOrganizationMembers.mockResolvedValue(expectedMembers as any);
|
||||
|
||||
const result = await controller.getOrganizationMembers(orgId);
|
||||
|
||||
expect(result).toEqual(expectedMembers);
|
||||
expect(betterAuthService.getOrganizationMembers).toHaveBeenCalledWith(orgId);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/organizations/:id/invite
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/organizations/:id/invite', () => {
|
||||
it('should invite an employee to organization', async () => {
|
||||
const orgId = 'org-123';
|
||||
const inviteDto = {
|
||||
organizationId: orgId,
|
||||
employeeEmail: 'employee@acme.com',
|
||||
role: 'member' as const,
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
id: 'invitation-123',
|
||||
email: 'employee@acme.com',
|
||||
organizationId: orgId,
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
betterAuthService.inviteEmployee.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.inviteEmployee(orgId, inviteDto, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.inviteEmployee).toHaveBeenCalledWith({
|
||||
organizationId: orgId,
|
||||
employeeEmail: 'employee@acme.com',
|
||||
role: 'member',
|
||||
inviterToken: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when inviter lacks permission', async () => {
|
||||
const orgId = 'org-123';
|
||||
const inviteDto = {
|
||||
organizationId: orgId,
|
||||
employeeEmail: 'employee@acme.com',
|
||||
role: 'member' as const,
|
||||
};
|
||||
|
||||
betterAuthService.inviteEmployee.mockRejectedValue(
|
||||
new ForbiddenException('You do not have permission to invite members')
|
||||
);
|
||||
|
||||
await expect(controller.inviteEmployee(orgId, inviteDto, mockAuthHeader)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/organizations/accept-invitation
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/organizations/accept-invitation', () => {
|
||||
it('should accept an invitation', async () => {
|
||||
const acceptDto = { invitationId: 'invitation-123' };
|
||||
|
||||
const expectedResult = {
|
||||
member: { id: 'member-123', userId: 'user-456', organizationId: 'org-123', role: 'member' },
|
||||
organization: { id: 'org-123', name: 'Acme Corp' },
|
||||
};
|
||||
|
||||
betterAuthService.acceptInvitation.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.acceptInvitation(acceptDto, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.acceptInvitation).toHaveBeenCalledWith({
|
||||
invitationId: 'invitation-123',
|
||||
userToken: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when invitation not found', async () => {
|
||||
const acceptDto = { invitationId: 'invalid-invitation' };
|
||||
|
||||
betterAuthService.acceptInvitation.mockRejectedValue(
|
||||
new NotFoundException('Invitation not found or expired')
|
||||
);
|
||||
|
||||
await expect(controller.acceptInvitation(acceptDto, mockAuthHeader)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /auth/organizations/:id/members/:memberId
|
||||
// ============================================================================
|
||||
|
||||
describe('DELETE /auth/organizations/:id/members/:memberId', () => {
|
||||
it('should remove a member from organization', async () => {
|
||||
const orgId = 'org-123';
|
||||
const memberId = 'member-456';
|
||||
|
||||
const expectedResult = { success: true, message: 'Member removed successfully' };
|
||||
|
||||
betterAuthService.removeMember.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.removeMember(orgId, memberId, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.removeMember).toHaveBeenCalledWith({
|
||||
organizationId: orgId,
|
||||
memberId,
|
||||
removerToken: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when remover lacks permission', async () => {
|
||||
betterAuthService.removeMember.mockRejectedValue(
|
||||
new ForbiddenException('You do not have permission to remove members')
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.removeMember('org-123', 'member-456', mockAuthHeader)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/organizations/set-active
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/organizations/set-active', () => {
|
||||
it('should set active organization', async () => {
|
||||
const setActiveDto = { organizationId: 'org-123' };
|
||||
|
||||
const expectedResult = {
|
||||
userId: 'user-123',
|
||||
activeOrganizationId: 'org-123',
|
||||
};
|
||||
|
||||
betterAuthService.setActiveOrganization.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.setActiveOrganization(setActiveDto, mockAuthHeader);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.setActiveOrganization).toHaveBeenCalledWith({
|
||||
organizationId: 'org-123',
|
||||
userToken: mockToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when not a member', async () => {
|
||||
const setActiveDto = { organizationId: 'org-999' };
|
||||
|
||||
betterAuthService.setActiveOrganization.mockRejectedValue(
|
||||
new NotFoundException('Organization not found or you are not a member')
|
||||
);
|
||||
|
||||
await expect(controller.setActiveOrganization(setActiveDto, mockAuthHeader)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Guard Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Guards', () => {
|
||||
it('should have JwtAuthGuard on protected endpoints', () => {
|
||||
const protectedEndpoints: (keyof AuthController)[] = [
|
||||
'logout',
|
||||
'getSession',
|
||||
'listOrganizations',
|
||||
'getOrganization',
|
||||
'getOrganizationMembers',
|
||||
'inviteEmployee',
|
||||
'acceptInvitation',
|
||||
'removeMember',
|
||||
'setActiveOrganization',
|
||||
];
|
||||
|
||||
protectedEndpoints.forEach((endpoint) => {
|
||||
const guards = Reflect.getMetadata(
|
||||
'__guards__',
|
||||
AuthController.prototype[endpoint as keyof AuthController]
|
||||
);
|
||||
expect(guards).toBeDefined();
|
||||
expect(guards).toContain(JwtAuthGuard);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT have JwtAuthGuard on public endpoints', () => {
|
||||
const publicEndpoints: (keyof AuthController)[] = [
|
||||
'register',
|
||||
'login',
|
||||
'refresh',
|
||||
'validate',
|
||||
'registerB2B',
|
||||
];
|
||||
|
||||
publicEndpoints.forEach((endpoint) => {
|
||||
const guards = Reflect.getMetadata(
|
||||
'__guards__',
|
||||
AuthController.prototype[endpoint as keyof AuthController]
|
||||
);
|
||||
expect(guards).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Token Extraction Helper
|
||||
// ============================================================================
|
||||
|
||||
describe('Token Extraction', () => {
|
||||
it('should extract token from Bearer authorization header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' });
|
||||
|
||||
await controller.logout('Bearer my-token-123', mockReq);
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('my-token-123');
|
||||
});
|
||||
|
||||
it('should handle missing authorization header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' });
|
||||
|
||||
await controller.logout('', mockReq);
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,25 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
|
||||
import { OidcController } from './oidc.controller';
|
||||
import { OidcLoginController } from './oidc-login.controller';
|
||||
import { MatrixSessionController } from './matrix-session.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { MatrixSessionService } from './services/matrix-session.service';
|
||||
import { PasskeyService } from './services/passkey.service';
|
||||
import { SecurityModule } from '../security';
|
||||
|
||||
@Module({
|
||||
imports: [SecurityModule, ConfigModule],
|
||||
controllers: [
|
||||
AuthController,
|
||||
BetterAuthPassthroughController,
|
||||
OidcController,
|
||||
OidcLoginController,
|
||||
MatrixSessionController,
|
||||
],
|
||||
providers: [BetterAuthService, MatrixSessionService, PasskeyService],
|
||||
exports: [BetterAuthService, MatrixSessionService, PasskeyService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
/**
|
||||
* Better Auth Passthrough Controller
|
||||
*
|
||||
* This controller handles Better Auth's native routes that are generated
|
||||
* with the `/api/auth/*` prefix (without the NestJS `/api/v1` prefix).
|
||||
*
|
||||
* Routes handled:
|
||||
* - GET /api/auth/get-session - SSO session check (cookie-based)
|
||||
* - GET /api/auth/verify-email - Email verification from verification emails
|
||||
* - GET /api/auth/reset-password/:token - Password reset from reset emails
|
||||
*
|
||||
* This is necessary because Better Auth generates URLs with `/api/auth/*`
|
||||
* but our NestJS API uses `/api/v1/*` as the global prefix.
|
||||
*/
|
||||
|
||||
import { Controller, Get, Post, All, Param, Query, Req, Res, HttpStatus } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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,
|
||||
private readonly configService: ConfigService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('BetterAuthPassthrough');
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward requests to Better Auth's handler
|
||||
*
|
||||
* Converts Express request to Fetch Request and passes it to Better Auth.
|
||||
* Copies response status, headers (including Set-Cookie), and body back.
|
||||
*/
|
||||
private async forwardToBetterAuth(req: Request, res: Response) {
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const url = new URL(req.originalUrl, baseUrl);
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value && typeof value === 'string') {
|
||||
headers.set(key, value);
|
||||
} else if (Array.isArray(value)) {
|
||||
headers.set(key, value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRequest = new Request(url.toString(), {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
||||
});
|
||||
|
||||
const handler = this.betterAuthService.getHandler();
|
||||
const response = await handler(fetchRequest);
|
||||
|
||||
res.status(response.status);
|
||||
|
||||
response.headers.forEach((value: string, key: string) => {
|
||||
if (key.toLowerCase() === 'set-cookie') {
|
||||
res.append(key, value);
|
||||
} else {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
try {
|
||||
return res.json(JSON.parse(body));
|
||||
} catch {
|
||||
return res.send(body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-Factor Authentication passthrough
|
||||
*
|
||||
* Forwards all /api/auth/two-factor/* requests to Better Auth's handler.
|
||||
* The twoFactor plugin registers these routes:
|
||||
* - POST /two-factor/enable
|
||||
* - POST /two-factor/disable
|
||||
* - POST /two-factor/verify-totp
|
||||
* - POST /two-factor/verify-backup-code
|
||||
* - POST /two-factor/get-totp-uri
|
||||
* - POST /two-factor/generate-backup-codes
|
||||
*/
|
||||
@All('two-factor/*')
|
||||
async twoFactorPassthrough(@Req() req: Request, @Res() res: Response) {
|
||||
try {
|
||||
return await this.forwardToBetterAuth(req, res);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Two-factor passthrough failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.status(500).json({ error: 'Two-factor request failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic Link passthrough
|
||||
*
|
||||
* Forwards all /api/auth/magic-link/* requests to Better Auth's handler.
|
||||
* The magicLink plugin registers these routes:
|
||||
* - POST /magic-link/send-magic-link
|
||||
* - GET /magic-link/verify (callback from email)
|
||||
*/
|
||||
@All('magic-link/*')
|
||||
async handleMagicLink(@Req() req: Request, @Res() res: Response) {
|
||||
try {
|
||||
return await this.forwardToBetterAuth(req, res);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Magic link passthrough failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.status(500).json({ error: 'Magic link request failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SSO get-session request
|
||||
*
|
||||
* This endpoint is called by client apps to check if the user has a valid
|
||||
* session cookie (set by auth.mana.how). Used for cross-subdomain SSO.
|
||||
*
|
||||
* The request includes cookies, and we forward them to Better Auth's handler.
|
||||
*/
|
||||
@Get('get-session')
|
||||
async getSession(@Req() req: Request, @Res() res: Response) {
|
||||
try {
|
||||
// Build the cookie header from Express request
|
||||
const cookieHeader = req.headers.cookie || '';
|
||||
|
||||
// Get the base URL from config
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const url = new URL('/api/auth/get-session', baseUrl);
|
||||
|
||||
// Create a fetch Request with the cookies
|
||||
const headers = new Headers({
|
||||
Cookie: cookieHeader,
|
||||
});
|
||||
|
||||
const fetchRequest = new Request(url.toString(), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
// Forward to Better Auth's handler
|
||||
const handler = this.betterAuthService.getHandler();
|
||||
const response = await handler(fetchRequest);
|
||||
|
||||
// Copy status and body to Express response
|
||||
const body = await response.json();
|
||||
return res.status(response.status).json(body);
|
||||
} catch (error) {
|
||||
this.logger.error('SSO get-session failed', error instanceof Error ? error.stack : undefined);
|
||||
return res.status(401).json({ error: 'Session check failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate redirect URL for security
|
||||
*
|
||||
* Only allows redirects to:
|
||||
* - *.mana.how domains
|
||||
* - mana.how (main domain)
|
||||
* - localhost (for development)
|
||||
*
|
||||
* @param redirectTo - URL to validate
|
||||
* @returns Validated origin URL or null if invalid
|
||||
*/
|
||||
private validateRedirectUrl(redirectTo?: string): string | null {
|
||||
if (!redirectTo) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(redirectTo);
|
||||
|
||||
// Allow *.mana.how, mana.how, and localhost
|
||||
if (
|
||||
url.hostname.endsWith('.mana.how') ||
|
||||
url.hostname === 'mana.how' ||
|
||||
url.hostname === 'localhost'
|
||||
) {
|
||||
return url.origin;
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, return null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle email verification
|
||||
*
|
||||
* Better Auth sends verification emails with links to:
|
||||
* {baseURL}/api/auth/verify-email?token=...&redirectTo=...
|
||||
*
|
||||
* This endpoint:
|
||||
* 1. Calls Better Auth's verifyEmail API
|
||||
* 2. Gets the source app URL from the store (set during registration)
|
||||
* 3. Redirects the user to the app's login page with verified=true and email
|
||||
*/
|
||||
@Get('verify-email')
|
||||
async verifyEmail(
|
||||
@Query('token') token: string,
|
||||
@Query('redirectTo') redirectTo: string | undefined,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const fallbackUrl = process.env.FRONTEND_URL || this.defaultFrontendUrl;
|
||||
|
||||
try {
|
||||
if (!token) {
|
||||
return res.redirect(`${fallbackUrl}/verification-failed?error=missing_token`);
|
||||
}
|
||||
|
||||
// Call Better Auth's verifyEmail API
|
||||
const result = await this.betterAuthService.verifyEmail(token);
|
||||
|
||||
if (result.success) {
|
||||
const email = result.email || '';
|
||||
|
||||
// Determine redirect URL:
|
||||
// 1. First try the redirectTo query param (passed through URL)
|
||||
// 2. Then try the sourceAppStore (set during registration)
|
||||
// 3. Finally fall back to default frontend URL
|
||||
let baseUrl = this.validateRedirectUrl(redirectTo);
|
||||
|
||||
if (!baseUrl && email) {
|
||||
// Try to get source app URL from store (set during registration)
|
||||
const storedUrl = this.betterAuthService.getSourceAppUrl(email);
|
||||
baseUrl = this.validateRedirectUrl(storedUrl || undefined);
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
baseUrl = fallbackUrl;
|
||||
}
|
||||
|
||||
// Redirect to app's login page with verified=true and email
|
||||
const loginUrl = new URL('/login', baseUrl);
|
||||
loginUrl.searchParams.set('verified', 'true');
|
||||
if (email) {
|
||||
loginUrl.searchParams.set('email', email);
|
||||
}
|
||||
|
||||
return res.redirect(loginUrl.toString());
|
||||
} else {
|
||||
// Redirect to error page
|
||||
return res.redirect(`${fallbackUrl}/verification-failed?error=${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Email verification failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.redirect(`${fallbackUrl}/verification-failed?error=verification_failed`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle password reset link from email
|
||||
*
|
||||
* Better Auth sends password reset emails with links to:
|
||||
* {baseURL}/api/auth/reset-password/{token}?callbackURL=...
|
||||
*
|
||||
* This endpoint:
|
||||
* 1. Extracts the reset token from the URL
|
||||
* 2. Redirects the user to the frontend /reset-password page with the token
|
||||
* 3. The frontend then shows a form to enter the new password
|
||||
* 4. Frontend submits to POST /api/v1/auth/reset-password with token + newPassword
|
||||
*/
|
||||
@Get('reset-password/:token')
|
||||
async resetPassword(
|
||||
@Param('token') token: string,
|
||||
@Query('callbackURL') callbackURL: string | undefined,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const fallbackUrl = process.env.FRONTEND_URL || this.defaultFrontendUrl;
|
||||
|
||||
try {
|
||||
if (!token) {
|
||||
return res.redirect(`${fallbackUrl}/login?error=missing_reset_token`);
|
||||
}
|
||||
|
||||
// Determine redirect URL:
|
||||
// 1. First try the callbackURL query param (from the email link)
|
||||
// 2. Fall back to default frontend URL
|
||||
let baseUrl = this.validateRedirectUrl(callbackURL);
|
||||
|
||||
if (!baseUrl) {
|
||||
baseUrl = fallbackUrl;
|
||||
}
|
||||
|
||||
// Redirect to frontend's reset-password page with token
|
||||
const resetUrl = new URL('/reset-password', baseUrl);
|
||||
resetUrl.searchParams.set('token', token);
|
||||
|
||||
this.logger.debug('Password reset redirect', { destination: baseUrl });
|
||||
return res.redirect(resetUrl.toString());
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Password reset redirect failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.redirect(`${fallbackUrl}/login?error=reset_failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,451 +0,0 @@
|
|||
/**
|
||||
* Better Auth Configuration
|
||||
*
|
||||
* This file configures Better Auth with:
|
||||
* - Email/password authentication
|
||||
* - Organization plugin for B2B (multi-tenant)
|
||||
* - JWT plugin with minimal claims
|
||||
* - Drizzle adapter for PostgreSQL
|
||||
*
|
||||
* ARCHITECTURE DECISION (2024-12):
|
||||
* We use MINIMAL JWT claims. Organization and credit data should be fetched
|
||||
* via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md
|
||||
*
|
||||
* @see https://www.better-auth.com/docs
|
||||
*/
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { jwt } from 'better-auth/plugins/jwt';
|
||||
import { organization } from 'better-auth/plugins/organization';
|
||||
import { oidcProvider } from 'better-auth/plugins/oidc-provider';
|
||||
import { twoFactor } from 'better-auth/plugins/two-factor';
|
||||
import { magicLink } from 'better-auth/plugins/magic-link';
|
||||
import { getDb } from '../db/connection';
|
||||
import { organizations, members, invitations } from '../db/schema/organizations.schema';
|
||||
import {
|
||||
users,
|
||||
sessions,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
jwks,
|
||||
oauthApplications,
|
||||
oauthAccessTokens,
|
||||
oauthAuthorizationCodes,
|
||||
oauthConsents,
|
||||
twoFactorAuth,
|
||||
} from '../db/schema/auth.schema';
|
||||
import type { JWTPayloadContext } from './types/better-auth.types';
|
||||
import {
|
||||
sendPasswordResetEmail,
|
||||
sendInvitationEmail,
|
||||
sendVerificationEmail,
|
||||
sendMagicLinkEmail,
|
||||
} from '../email/email.service';
|
||||
import { sourceAppStore } from './stores/source-app.store';
|
||||
import { passwordResetRedirectStore } from './stores/password-reset-redirect.store';
|
||||
|
||||
/**
|
||||
* JWT Custom Payload Interface
|
||||
*
|
||||
* MINIMAL claims only. Organization context and credits are available via:
|
||||
* - GET /organization/get-active-member - org membership & role
|
||||
* - GET /api/v1/credits/balance - credit balance
|
||||
*
|
||||
* Why minimal claims?
|
||||
* 1. Credit balance changes frequently - JWT would be stale
|
||||
* 2. Organization context available via Better Auth org plugin APIs
|
||||
* 3. Smaller tokens = better performance
|
||||
* 4. Follows Better Auth's session-based design
|
||||
*/
|
||||
export interface JWTCustomPayload {
|
||||
/** User ID (standard JWT claim) */
|
||||
sub: string;
|
||||
|
||||
/** User email */
|
||||
email: string;
|
||||
|
||||
/** User role (user, admin, service) */
|
||||
role: string;
|
||||
|
||||
/** Session ID for reference */
|
||||
sid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Better Auth instance
|
||||
*
|
||||
* @param databaseUrl - PostgreSQL connection URL
|
||||
* @returns Better Auth instance
|
||||
*/
|
||||
export function createBetterAuth(databaseUrl: string) {
|
||||
const db = getDb(databaseUrl);
|
||||
|
||||
return betterAuth({
|
||||
// Database adapter (Drizzle with PostgreSQL)
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
// Auth tables (actual Drizzle table objects)
|
||||
user: users,
|
||||
session: sessions,
|
||||
account: accounts,
|
||||
verification: verificationTokens,
|
||||
|
||||
// Organization tables
|
||||
organization: organizations,
|
||||
member: members,
|
||||
invitation: invitations,
|
||||
|
||||
// JWT plugin table
|
||||
jwks: jwks,
|
||||
|
||||
// Two-Factor Authentication table
|
||||
twoFactor: twoFactorAuth,
|
||||
|
||||
// OIDC Provider tables
|
||||
oauthApplication: oauthApplications,
|
||||
oauthAccessToken: oauthAccessTokens,
|
||||
oauthAuthorizationCode: oauthAuthorizationCodes,
|
||||
oauthConsent: oauthConsents,
|
||||
},
|
||||
}),
|
||||
|
||||
// Email/password authentication with password reset
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 128,
|
||||
|
||||
/**
|
||||
* Password Reset Configuration
|
||||
*
|
||||
* Better Auth provides password reset via:
|
||||
* - auth.api.requestPasswordReset({ body: { email } }) - Sends reset email
|
||||
* - auth.api.resetPassword({ body: { newPassword, token } }) - Resets password
|
||||
*
|
||||
* The reset URL is modified to include callbackURL parameter
|
||||
* so users are redirected back to the app they requested reset from.
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
*/
|
||||
sendResetPassword: async ({
|
||||
user,
|
||||
url,
|
||||
}: {
|
||||
user: { email: string; name: string };
|
||||
url: string;
|
||||
}) => {
|
||||
// Check if we have a redirect URL stored for this user's password reset request
|
||||
const redirectUrl = passwordResetRedirectStore.get(user.email);
|
||||
|
||||
// Modify reset URL to include callbackURL parameter
|
||||
let resetUrl = url;
|
||||
if (redirectUrl) {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.set('callbackURL', redirectUrl);
|
||||
resetUrl = urlObj.toString();
|
||||
}
|
||||
|
||||
await sendPasswordResetEmail(user.email, resetUrl, user.name);
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Email Verification Configuration
|
||||
*
|
||||
* Sends verification email when user registers.
|
||||
* User must verify email before they can log in.
|
||||
*
|
||||
* The verification URL is modified to include redirectTo parameter
|
||||
* so users are redirected back to the app they registered from.
|
||||
*/
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendVerificationEmail: async ({
|
||||
user,
|
||||
url,
|
||||
}: {
|
||||
user: { email: string; name: string };
|
||||
url: string;
|
||||
}) => {
|
||||
// Check if we have a source app URL stored for this user
|
||||
// Note: We get the URL without deleting it here since it might be needed
|
||||
// during the verification process in the passthrough controller
|
||||
const sourceAppUrl = sourceAppStore.get(user.email);
|
||||
|
||||
// Modify verification URL to include redirectTo parameter
|
||||
let verificationUrl = url;
|
||||
if (sourceAppUrl) {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.set('redirectTo', sourceAppUrl);
|
||||
verificationUrl = urlObj.toString();
|
||||
}
|
||||
|
||||
await sendVerificationEmail(user.email, verificationUrl, user.name);
|
||||
},
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update session once per day
|
||||
},
|
||||
|
||||
// Base URL for callbacks and redirects
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3001',
|
||||
|
||||
/**
|
||||
* Advanced Cookie Configuration for Cross-Domain SSO
|
||||
*
|
||||
* By setting the cookie domain to '.mana.how', session cookies are shared
|
||||
* across all subdomains (calendar.mana.how, todo.mana.how, etc.).
|
||||
* This enables Single Sign-On: login once, authenticated everywhere.
|
||||
*
|
||||
* For local development (localhost), leave domain undefined to use default behavior.
|
||||
*/
|
||||
advanced: {
|
||||
// Cookie prefix for all auth cookies
|
||||
cookiePrefix: 'mana',
|
||||
|
||||
// Cross-subdomain cookie configuration
|
||||
crossSubDomainCookies: {
|
||||
// Enable cross-subdomain cookies in production
|
||||
enabled: !!process.env.COOKIE_DOMAIN,
|
||||
// Domain for cookies (e.g., '.mana.how' - note the leading dot)
|
||||
domain: process.env.COOKIE_DOMAIN || undefined,
|
||||
},
|
||||
|
||||
// Default cookie options for all auth cookies
|
||||
defaultCookieAttributes: {
|
||||
// Secure in production, allow http in development
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
// SameSite=None is required for cross-subdomain SSO via fetch()
|
||||
// Lax only sends cookies on top-level navigations, not programmatic fetch()
|
||||
// None requires Secure=true (ensured by production check above)
|
||||
sameSite: process.env.COOKIE_DOMAIN ? ('none' as const) : ('lax' as const),
|
||||
// Cookies accessible to all paths
|
||||
path: '/',
|
||||
// Prevent JavaScript access to cookies
|
||||
httpOnly: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Trusted origins for cross-origin requests (must match CORS_ORIGINS in production)
|
||||
// IMPORTANT: Every app that uses SSO must be listed here, otherwise
|
||||
// Better Auth will reject cross-origin requests with credentials.
|
||||
// When adding a new app, add its production domain here AND to
|
||||
// CORS_ORIGINS in docker-compose.macmini.yml.
|
||||
trustedOrigins: [
|
||||
// Production domains - auth service
|
||||
'https://auth.mana.how',
|
||||
'https://mana.how',
|
||||
// Production domains - all apps (keep alphabetical)
|
||||
'https://calendar.mana.how',
|
||||
'https://chat.mana.how',
|
||||
'https://clock.mana.how',
|
||||
'https://contacts.mana.how',
|
||||
'https://context.mana.how',
|
||||
'https://docs.mana.how',
|
||||
'https://element.mana.how',
|
||||
'https://inventar.mana.how',
|
||||
'https://link.mana.how',
|
||||
'https://manadeck.mana.how',
|
||||
'https://matrix.mana.how',
|
||||
'https://mchat.mana.how',
|
||||
'https://mukke.mana.how',
|
||||
'https://nutriphi.mana.how',
|
||||
'https://photos.mana.how',
|
||||
'https://picture.mana.how',
|
||||
'https://planta.mana.how',
|
||||
'https://playground.mana.how',
|
||||
'https://presi.mana.how',
|
||||
'https://questions.mana.how',
|
||||
'https://skilltree.mana.how',
|
||||
'https://storage.mana.how',
|
||||
'https://todo.mana.how',
|
||||
'https://traces.mana.how',
|
||||
'https://zitare.mana.how',
|
||||
// Local development
|
||||
'http://localhost:3001',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://localhost:5190',
|
||||
],
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
/**
|
||||
* Organization Plugin (B2B)
|
||||
*
|
||||
* Provides complete organization management:
|
||||
* - Create/update/delete organizations
|
||||
* - Invite/add/remove members
|
||||
* - Role-based access control
|
||||
* - Active organization tracking (session.activeOrganizationId)
|
||||
*
|
||||
* Client apps use these endpoints for org context:
|
||||
* - GET /organization/get-active-member
|
||||
* - GET /organization/get-active-member-role
|
||||
* - POST /organization/set-active
|
||||
*/
|
||||
organization({
|
||||
// Allow users to create their own organizations
|
||||
allowUserToCreateOrganization: true,
|
||||
|
||||
// Email invitation handler
|
||||
async sendInvitationEmail(data) {
|
||||
const { email, organization, inviter } = data;
|
||||
const baseUrl = process.env.BASE_URL || 'https://mana.how';
|
||||
const inviteUrl = `${baseUrl}/accept-invitation?id=${data.id}`;
|
||||
await sendInvitationEmail(
|
||||
email,
|
||||
organization.name,
|
||||
inviter?.user?.name || 'Ein Teammitglied',
|
||||
inviteUrl
|
||||
);
|
||||
},
|
||||
|
||||
// Custom roles and permissions
|
||||
organizationRole: {
|
||||
owner: {
|
||||
permissions: [
|
||||
'organization:update',
|
||||
'organization:delete',
|
||||
'members:invite',
|
||||
'members:remove',
|
||||
'members:update_role',
|
||||
'credits:allocate',
|
||||
'credits:view_all',
|
||||
],
|
||||
},
|
||||
admin: {
|
||||
permissions: [
|
||||
'organization:update',
|
||||
'members:invite',
|
||||
'members:remove',
|
||||
'credits:view_all',
|
||||
],
|
||||
},
|
||||
member: {
|
||||
permissions: ['credits:view_own'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
/**
|
||||
* JWT Plugin
|
||||
*
|
||||
* Generates JWT tokens with MINIMAL claims.
|
||||
*
|
||||
* DO NOT add complex claims like:
|
||||
* - credit_balance (stale after 15min, fetch via API instead)
|
||||
* - organization details (use Better Auth org plugin APIs)
|
||||
* - customer_type (derive from activeOrganizationId presence)
|
||||
*
|
||||
* Apps should call APIs for dynamic data:
|
||||
* - Credits: GET /api/v1/credits/balance
|
||||
* - Org info: GET /organization/get-active-member
|
||||
*/
|
||||
jwt({
|
||||
jwt: {
|
||||
// For OIDC compatibility, issuer MUST match the discovery document
|
||||
// Use BASE_URL to match /.well-known/openid-configuration issuer
|
||||
issuer: process.env.BASE_URL || process.env.JWT_ISSUER || 'http://localhost:3001',
|
||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||
expirationTime: '15m',
|
||||
|
||||
/**
|
||||
* Define minimal JWT payload
|
||||
*
|
||||
* Only includes static user info that doesn't change frequently.
|
||||
*/
|
||||
definePayload({ user, session }: JWTPayloadContext) {
|
||||
return {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as { role?: string }).role || 'user',
|
||||
sid: session.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
/**
|
||||
* OIDC Provider Plugin
|
||||
*
|
||||
* Enables Mana Core Auth to act as an OpenID Connect Provider.
|
||||
* This allows Matrix/Synapse and other services to use SSO.
|
||||
*
|
||||
* Endpoints provided:
|
||||
* - GET /.well-known/openid-configuration
|
||||
* - GET /api/oidc/authorize
|
||||
* - POST /api/oidc/token
|
||||
* - GET /api/oidc/userinfo
|
||||
* - GET /api/oidc/jwks
|
||||
*/
|
||||
oidcProvider({
|
||||
// Login page for OIDC authorization
|
||||
loginPage: '/login',
|
||||
// Consent page (skipped for trusted clients)
|
||||
consentPage: '/consent',
|
||||
// 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',
|
||||
},
|
||||
// Trusted clients that skip consent screen
|
||||
// These clients are considered first-party and don't need user consent
|
||||
trustedClients: [
|
||||
{
|
||||
clientId: 'matrix-synapse',
|
||||
clientSecret: process.env.SYNAPSE_OIDC_CLIENT_SECRET || '',
|
||||
name: 'Matrix Synapse',
|
||||
type: 'web',
|
||||
disabled: false,
|
||||
metadata: {},
|
||||
redirectUrls: ['https://matrix.mana.how/_synapse/client/oidc/callback'],
|
||||
skipConsent: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
/**
|
||||
* Two-Factor Authentication Plugin (TOTP)
|
||||
*
|
||||
* Provides TOTP-based 2FA with backup codes.
|
||||
* Endpoints provided automatically by Better Auth passthrough:
|
||||
* - POST /two-factor/enable (requires password)
|
||||
* - POST /two-factor/disable (requires password)
|
||||
* - POST /two-factor/verify-totp (during login)
|
||||
* - POST /two-factor/verify-backup-code (during login)
|
||||
* - POST /two-factor/get-totp-uri
|
||||
* - POST /two-factor/generate-backup-codes
|
||||
*/
|
||||
twoFactor({
|
||||
issuer: 'ManaCore',
|
||||
}),
|
||||
/**
|
||||
* Magic Link Plugin (Passwordless Email Login)
|
||||
*
|
||||
* Sends a one-time login link via email.
|
||||
* Endpoints via Better Auth passthrough:
|
||||
* - POST /magic-link/send-magic-link
|
||||
* - GET /magic-link/verify (callback from email)
|
||||
*/
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }: { email: string; url: string }) => {
|
||||
await sendMagicLinkEmail(email, url);
|
||||
},
|
||||
expiresIn: 600, // 10 minutes
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export type for Better Auth instance
|
||||
*/
|
||||
export type BetterAuthInstance = ReturnType<typeof createBetterAuth>;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for accepting an organization invitation
|
||||
*/
|
||||
export class AcceptInvitationDto {
|
||||
@IsString()
|
||||
invitationId: string;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: 'Current password', example: 'currentPassword123' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
currentPassword: string;
|
||||
|
||||
@ApiProperty({ description: 'New password (min 8 characters)', example: 'newSecurePassword456' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
newPassword: string;
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { IsString, IsOptional, MinLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class DeleteAccountDto {
|
||||
@ApiProperty({
|
||||
description: 'Current password to confirm account deletion',
|
||||
example: 'myPassword123',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Optional reason for leaving',
|
||||
example: 'I found a better service',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { IsEmail, IsOptional, IsString, IsUrl } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Forgot Password DTO
|
||||
*
|
||||
* Request body for initiating password reset.
|
||||
*/
|
||||
export class ForgotPasswordDto {
|
||||
/**
|
||||
* User's email address
|
||||
*/
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* Optional redirect URL after password reset
|
||||
* The reset token will be appended as a query parameter
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* Auth DTOs Index
|
||||
*
|
||||
* Re-exports all authentication-related DTOs
|
||||
*/
|
||||
|
||||
// Core auth DTOs
|
||||
export { RegisterDto } from './register.dto';
|
||||
export { LoginDto } from './login.dto';
|
||||
export { RefreshTokenDto } from './refresh-token.dto';
|
||||
|
||||
// B2B organization DTOs
|
||||
export { RegisterB2BDto } from './register-b2b.dto';
|
||||
export { InviteEmployeeDto } from './invite-employee.dto';
|
||||
export { AcceptInvitationDto } from './accept-invitation.dto';
|
||||
export { SetActiveOrganizationDto } from './set-active-organization.dto';
|
||||
export { UpdateOrganizationDto } from './update-organization.dto';
|
||||
export { UpdateMemberRoleDto } from './update-member-role.dto';
|
||||
|
||||
// Password management DTOs
|
||||
export { ForgotPasswordDto } from './forgot-password.dto';
|
||||
export { ResetPasswordDto } from './reset-password.dto';
|
||||
export { ResendVerificationDto } from './resend-verification.dto';
|
||||
|
||||
// Profile management DTOs
|
||||
export { UpdateProfileDto } from './update-profile.dto';
|
||||
export { ChangePasswordDto } from './change-password.dto';
|
||||
export { DeleteAccountDto } from './delete-account.dto';
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { IsEmail, IsString, IsIn } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for inviting an employee to an organization
|
||||
*
|
||||
* Only owners and admins can invite new members.
|
||||
*/
|
||||
export class InviteEmployeeDto {
|
||||
@IsString()
|
||||
organizationId: string;
|
||||
|
||||
@IsEmail()
|
||||
employeeEmail: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['admin', 'member'])
|
||||
role: 'admin' | 'member';
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { IsEmail, IsString, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description: 'User email address',
|
||||
example: 'user@example.com',
|
||||
})
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User password',
|
||||
example: 'SecurePassword123!',
|
||||
})
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Unique device identifier for session tracking',
|
||||
example: 'device-uuid-123',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
deviceId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Human-readable device name',
|
||||
example: 'iPhone 15 Pro',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
deviceName?: string;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for B2B organization registration
|
||||
*
|
||||
* Creates an organization with the registering user as owner.
|
||||
*/
|
||||
export class RegisterB2BDto {
|
||||
@IsEmail()
|
||||
ownerEmail: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
@MaxLength(128)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
ownerName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(255)
|
||||
organizationName: string;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsUrl } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({
|
||||
description: 'User email address',
|
||||
example: 'user@example.com',
|
||||
})
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User password (min 8 characters)',
|
||||
example: 'SecurePassword123!',
|
||||
minLength: 8,
|
||||
maxLength: 128,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'User display name',
|
||||
example: 'John Doe',
|
||||
maxLength: 255,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'URL of the source app for redirect after registration',
|
||||
example: 'https://app.example.com',
|
||||
maxLength: 255,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false }) // Allow localhost URLs for development
|
||||
@MaxLength(255)
|
||||
sourceAppUrl?: string;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { IsEmail, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ResendVerificationDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sourceAppUrl?: string;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Reset Password DTO
|
||||
*
|
||||
* Request body for resetting password with token.
|
||||
*/
|
||||
export class ResetPasswordDto {
|
||||
/**
|
||||
* Reset token from email link
|
||||
*/
|
||||
@IsString()
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* New password (must meet password requirements)
|
||||
*/
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'Password must be at least 8 characters long' })
|
||||
@MaxLength(128, { message: 'Password must be at most 128 characters long' })
|
||||
newPassword: string;
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for setting the active organization
|
||||
*
|
||||
* Used to switch between organizations for users with multiple memberships.
|
||||
*/
|
||||
export class SetActiveOrganizationDto {
|
||||
@IsString()
|
||||
organizationId: string;
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { IsString, IsIn } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* DTO for updating a member's role within an organization
|
||||
*
|
||||
* Note: 'owner' role cannot be assigned via this endpoint.
|
||||
* To transfer ownership, use the dedicated transfer ownership endpoint.
|
||||
*/
|
||||
export class UpdateMemberRoleDto {
|
||||
@ApiProperty({
|
||||
description: 'New role for the member',
|
||||
enum: ['admin', 'member'],
|
||||
example: 'admin',
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(['admin', 'member'])
|
||||
role: 'admin' | 'member';
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { IsString, IsOptional, MaxLength, MinLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* DTO for updating an organization
|
||||
*
|
||||
* All fields are optional - only provided fields will be updated.
|
||||
*/
|
||||
export class UpdateOrganizationDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'New name for the organization',
|
||||
minLength: 2,
|
||||
maxLength: 255,
|
||||
example: 'Acme Corporation',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MinLength(2)
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'URL to organization logo',
|
||||
maxLength: 500,
|
||||
example: 'https://example.com/logo.png',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
logo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Additional metadata for the organization',
|
||||
example: { industry: 'Technology', size: 'Enterprise' },
|
||||
})
|
||||
@IsOptional()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { IsString, IsOptional, IsEmail, MinLength, MaxLength, IsUrl } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@ApiPropertyOptional({ description: 'New display name', example: 'Max Mustermann' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Profile image URL',
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
image?: string;
|
||||
}
|
||||
|
|
@ -1,542 +0,0 @@
|
|||
/**
|
||||
* JWT Token Validation Tests (Minimal Claims)
|
||||
*
|
||||
* Tests for JWT token validation with minimal claims:
|
||||
* - sub (user ID)
|
||||
* - email
|
||||
* - role
|
||||
* - sid (session ID)
|
||||
*
|
||||
* ARCHITECTURE DECISION (2024-12):
|
||||
* We use MINIMAL JWT claims. Organization and credit data should be fetched
|
||||
* via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md
|
||||
*
|
||||
* Why minimal claims?
|
||||
* 1. Credit balance changes frequently - JWT would be stale
|
||||
* 2. Organization context available via Better Auth org plugin APIs
|
||||
* 3. Smaller tokens = better performance
|
||||
* 4. Follows Better Auth's session-based design
|
||||
*
|
||||
* NOTE: These tests use jose library (EdDSA/HS256) as per project guidelines.
|
||||
* Production uses EdDSA via Better Auth's JWKS.
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { type ConfigService } from '@nestjs/config';
|
||||
import { SignJWT, jwtVerify, errors } from 'jose';
|
||||
import { type JWTCustomPayload } from './better-auth.config';
|
||||
import { createMockConfigService } from '../__tests__/utils/test-helpers';
|
||||
import { mockUserFactory } from '../__tests__/utils/mock-factories';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('../db/connection');
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid-123'),
|
||||
}));
|
||||
|
||||
// Helper to create JWT using jose
|
||||
async function signJwt(
|
||||
payload: JWTCustomPayload,
|
||||
secret: Uint8Array,
|
||||
options: { expiresIn?: string; issuer?: string; audience?: string; notBefore?: number } = {}
|
||||
): Promise<string> {
|
||||
const jwt = new SignJWT(payload as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt();
|
||||
|
||||
if (options.expiresIn) {
|
||||
jwt.setExpirationTime(options.expiresIn);
|
||||
}
|
||||
if (options.issuer) {
|
||||
jwt.setIssuer(options.issuer);
|
||||
}
|
||||
if (options.audience) {
|
||||
jwt.setAudience(options.audience);
|
||||
}
|
||||
if (options.notBefore !== undefined) {
|
||||
jwt.setNotBefore(options.notBefore);
|
||||
}
|
||||
|
||||
return jwt.sign(secret);
|
||||
}
|
||||
|
||||
// Helper to verify JWT using jose
|
||||
async function verifyJwt(
|
||||
token: string,
|
||||
secret: Uint8Array,
|
||||
options: { issuer?: string; audience?: string } = {}
|
||||
): Promise<JWTCustomPayload> {
|
||||
const { payload } = await jwtVerify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: options.issuer,
|
||||
audience: options.audience,
|
||||
});
|
||||
return payload as unknown as JWTCustomPayload;
|
||||
}
|
||||
|
||||
describe('JWT Token Validation (Minimal Claims)', () => {
|
||||
let configService: ConfigService;
|
||||
let mockDb: any;
|
||||
let secret: Uint8Array;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use HS256 for testing (symmetric key) for simplicity
|
||||
// In production, mana-core uses EdDSA via Better Auth's JWKS
|
||||
secret = new TextEncoder().encode('test-secret-key-for-jwt-validation-must-be-32-chars');
|
||||
|
||||
// Create mock database
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock getDb
|
||||
const { getDb } = require('../db/connection');
|
||||
getDb.mockReturnValue(mockDb);
|
||||
|
||||
configService = createMockConfigService({
|
||||
'jwt.issuer': 'mana-core',
|
||||
'jwt.audience': 'manacore',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Minimal JWT Claims Structure', () => {
|
||||
it('should generate token with minimal claims only', async () => {
|
||||
const user = mockUserFactory.create({
|
||||
id: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
sid: 'session-abc-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = await verifyJwt(token, secret, {
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
expect(decoded).toMatchObject({
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-abc-123',
|
||||
});
|
||||
|
||||
// Verify NO complex claims are present
|
||||
expect((decoded as any).customer_type).toBeUndefined();
|
||||
expect((decoded as any).organization).toBeUndefined();
|
||||
expect((decoded as any).credit_balance).toBeUndefined();
|
||||
expect((decoded as any).app_id).toBeUndefined();
|
||||
expect((decoded as any).device_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include standard JWT claims (sub, iat, exp, iss, aud)', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = await verifyJwt(token, secret);
|
||||
|
||||
// Standard JWT claims
|
||||
expect(decoded.sub).toBe('user-123');
|
||||
expect((decoded as any).iat).toBeGreaterThanOrEqual(now);
|
||||
expect((decoded as any).exp).toBeGreaterThan((decoded as any).iat);
|
||||
expect((decoded as any).iss).toBe('mana-core');
|
||||
expect((decoded as any).aud).toBe('manacore');
|
||||
});
|
||||
|
||||
it('should support different user roles', async () => {
|
||||
const roles = ['user', 'admin', 'service'];
|
||||
|
||||
for (const role of roles) {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: `${role}-user-123`,
|
||||
email: `${role}@example.com`,
|
||||
role,
|
||||
sid: `session-${role}`,
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = await verifyJwt(token, secret);
|
||||
|
||||
expect(decoded.role).toBe(role);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Validation - Security', () => {
|
||||
it('should validate HS256 signature correctly', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Should successfully verify with correct secret
|
||||
await expect(verifyJwt(token, secret)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject expired tokens', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
// Create token that expires immediately
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '0s', // Expired immediately
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Wait a moment to ensure expiry
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWTExpired);
|
||||
});
|
||||
|
||||
it('should reject tokens with wrong issuer', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'wrong-issuer', // Wrong issuer
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyJwt(token, secret, {
|
||||
issuer: 'mana-core', // Expect correct issuer
|
||||
audience: 'manacore',
|
||||
})
|
||||
).rejects.toThrow(errors.JWTClaimValidationFailed);
|
||||
});
|
||||
|
||||
it('should reject tokens with wrong audience', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'wrong-audience', // Wrong audience
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyJwt(token, secret, {
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore', // Expect correct audience
|
||||
})
|
||||
).rejects.toThrow(errors.JWTClaimValidationFailed);
|
||||
});
|
||||
|
||||
it('should reject tampered tokens', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Tamper with the token - try to change role to admin
|
||||
const parts = token.split('.');
|
||||
const tamperedPayload = Buffer.from(JSON.stringify({ ...payload, role: 'admin' })).toString(
|
||||
'base64url'
|
||||
);
|
||||
const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
|
||||
|
||||
await expect(verifyJwt(tamperedToken, secret)).rejects.toThrow(
|
||||
errors.JWSSignatureVerificationFailed
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject tokens signed with wrong secret', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
// Sign with different secret
|
||||
const wrongSecret = new TextEncoder().encode('wrong-secret-key-for-testing-wrong');
|
||||
|
||||
const token = await signJwt(payload, wrongSecret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Try to verify with correct secret
|
||||
await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWSSignatureVerificationFailed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Expiration Times', () => {
|
||||
it('should use 15 minutes for access tokens', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded: any = await verifyJwt(token, secret);
|
||||
|
||||
const expiryTime = decoded.exp - decoded.iat;
|
||||
expect(expiryTime).toBe(15 * 60); // 15 minutes = 900 seconds
|
||||
});
|
||||
|
||||
it('should validate token is not yet valid (nbf claim)', async () => {
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future
|
||||
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
notBefore: futureTime, // Not valid until 1 hour from now
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWTClaimValidationFailed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle malformed JWT gracefully', async () => {
|
||||
const malformedToken = 'this.is.not.a.valid.jwt';
|
||||
|
||||
await expect(verifyJwt(malformedToken, secret)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle empty token', async () => {
|
||||
await expect(verifyJwt('', secret)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle token with missing required claims', async () => {
|
||||
// Token with only sub (missing email, role, sid)
|
||||
const minimalPayload = { sub: 'user-123' } as unknown as JWTCustomPayload;
|
||||
|
||||
const token = await signJwt(minimalPayload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Token is technically valid, but application should validate claims
|
||||
const decoded = await verifyJwt(token, secret);
|
||||
|
||||
expect(decoded.sub).toBe('user-123');
|
||||
expect(decoded.email).toBeUndefined();
|
||||
expect(decoded.role).toBeUndefined();
|
||||
expect(decoded.sid).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Refresh Behavior', () => {
|
||||
it('should issue new token with same user claims', async () => {
|
||||
const originalPayload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-original',
|
||||
};
|
||||
|
||||
const originalToken = await signJwt(originalPayload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Refresh creates new token with new session ID
|
||||
const refreshedPayload: JWTCustomPayload = {
|
||||
...originalPayload,
|
||||
sid: 'session-refreshed', // New session ID
|
||||
};
|
||||
|
||||
const refreshedToken = await signJwt(refreshedPayload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = await verifyJwt(refreshedToken, secret);
|
||||
|
||||
// User claims should be maintained
|
||||
expect(decoded.sub).toBe('user-123');
|
||||
expect(decoded.email).toBe('user@example.com');
|
||||
expect(decoded.role).toBe('user');
|
||||
// Session ID should be new
|
||||
expect(decoded.sid).toBe('session-refreshed');
|
||||
});
|
||||
|
||||
it('should maintain user role across refreshes', async () => {
|
||||
const adminPayload: JWTCustomPayload = {
|
||||
sub: 'admin-123',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(adminPayload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = await verifyJwt(token, secret);
|
||||
|
||||
// Admin role should be preserved
|
||||
expect(decoded.role).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Architecture Decision Documentation', () => {
|
||||
/**
|
||||
* This test documents what is NOT in the JWT by design.
|
||||
* See docs/AUTHENTICATION_ARCHITECTURE.md for full explanation.
|
||||
*/
|
||||
it('should NOT contain organization data (fetch via API instead)', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = await verifyJwt(token, secret);
|
||||
|
||||
// Organization data should be fetched via:
|
||||
// - session.activeOrganizationId (from Better Auth session)
|
||||
// - GET /organization/get-active-member (for details)
|
||||
expect((decoded as any).organization).toBeUndefined();
|
||||
expect((decoded as any).organizationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT contain credit balance (fetch via API instead)', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = await verifyJwt(token, secret);
|
||||
|
||||
// Credit balance should be fetched via:
|
||||
// - GET /api/v1/credits/balance
|
||||
// Credit balance changes too frequently to embed in JWT
|
||||
expect((decoded as any).credit_balance).toBeUndefined();
|
||||
expect((decoded as any).credits).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT contain customer_type (derive from session instead)', async () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret, {
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = await verifyJwt(token, secret);
|
||||
|
||||
// Customer type should be derived from:
|
||||
// - B2B = session.activeOrganizationId != null
|
||||
// - B2C = session.activeOrganizationId == null
|
||||
expect((decoded as any).customer_type).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
/**
|
||||
* Magic Link Passthrough Unit Tests
|
||||
*
|
||||
* Tests that the BetterAuthPassthroughController has the magic link
|
||||
* handler method and that it delegates to forwardToBetterAuth.
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { LoggerService } from '../common/logger';
|
||||
|
||||
describe('BetterAuthPassthroughController - Magic Link', () => {
|
||||
let controller: BetterAuthPassthroughController;
|
||||
let betterAuthService: jest.Mocked<BetterAuthService>;
|
||||
|
||||
const mockBetterAuthService = {
|
||||
getHandler: jest.fn(),
|
||||
verifyEmail: jest.fn(),
|
||||
getSourceAppUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config: Record<string, string> = {
|
||||
BASE_URL: 'http://localhost:3001',
|
||||
};
|
||||
return config[key] || '';
|
||||
}),
|
||||
};
|
||||
|
||||
const mockLoggerService = {
|
||||
setContext: jest.fn().mockReturnThis(),
|
||||
log: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [BetterAuthPassthroughController],
|
||||
providers: [
|
||||
{ provide: BetterAuthService, useValue: mockBetterAuthService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: LoggerService, useValue: mockLoggerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<BetterAuthPassthroughController>(BetterAuthPassthroughController);
|
||||
betterAuthService = module.get(BetterAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Magic Link Handler Existence
|
||||
// ============================================================================
|
||||
|
||||
describe('handleMagicLink', () => {
|
||||
it('should have handleMagicLink method defined', () => {
|
||||
expect(controller.handleMagicLink).toBeDefined();
|
||||
expect(typeof controller.handleMagicLink).toBe('function');
|
||||
});
|
||||
|
||||
it('should call forwardToBetterAuth and delegate to Better Auth handler', async () => {
|
||||
const mockResponse = new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
const mockHandler = jest.fn().mockResolvedValue(mockResponse);
|
||||
betterAuthService.getHandler.mockReturnValue(mockHandler);
|
||||
|
||||
const mockReq = {
|
||||
method: 'POST',
|
||||
originalUrl: '/api/auth/magic-link/send-magic-link',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { email: 'test@example.com' },
|
||||
} as any;
|
||||
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
append: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as any;
|
||||
|
||||
await controller.handleMagicLink(mockReq, mockRes);
|
||||
|
||||
expect(betterAuthService.getHandler).toHaveBeenCalled();
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 on internal error', async () => {
|
||||
betterAuthService.getHandler.mockImplementation(() => {
|
||||
throw new Error('Handler unavailable');
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
method: 'POST',
|
||||
originalUrl: '/api/auth/magic-link/send-magic-link',
|
||||
headers: {},
|
||||
body: {},
|
||||
} as any;
|
||||
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
append: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as any;
|
||||
|
||||
await controller.handleMagicLink(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Magic link request failed' });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Route Metadata
|
||||
// ============================================================================
|
||||
|
||||
describe('Route metadata', () => {
|
||||
it('should have @All decorator on handleMagicLink for magic-link/* routes', () => {
|
||||
const routePath = Reflect.getMetadata(
|
||||
'path',
|
||||
BetterAuthPassthroughController.prototype.handleMagicLink
|
||||
);
|
||||
expect(routePath).toBe('magic-link/*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { MatrixSessionService } from './services/matrix-session.service';
|
||||
|
||||
/**
|
||||
* DTO for linking a Matrix user to a Mana account
|
||||
*/
|
||||
class LinkMatrixUserDto {
|
||||
/** Matrix user ID (e.g., @user:matrix.mana.how) */
|
||||
matrixUserId!: string;
|
||||
/** User's email (optional, for convenience) */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrix Session Controller
|
||||
*
|
||||
* Provides endpoints for Matrix bot authentication via SSO.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/v1/auth/matrix-user-links - Link Matrix user to Mana account
|
||||
* - GET /api/v1/auth/matrix-session/:matrixUserId - Get JWT for linked Matrix user
|
||||
* - DELETE /api/v1/auth/matrix-user-links/:matrixUserId - Unlink Matrix user
|
||||
* - GET /api/v1/auth/matrix-user-links/check/:matrixUserId - Check if user is linked
|
||||
*
|
||||
* Authentication:
|
||||
* - POST /link requires Bearer token (user authenticating)
|
||||
* - GET /session requires X-Service-Key (internal bot service)
|
||||
* - DELETE requires Bearer token (user unlinking)
|
||||
* - GET /check requires X-Service-Key (internal bot service)
|
||||
*/
|
||||
@Controller('api/v1/auth')
|
||||
export class MatrixSessionController {
|
||||
constructor(private readonly matrixSessionService: MatrixSessionService) {}
|
||||
|
||||
/**
|
||||
* Link a Matrix user ID to a Mana account
|
||||
*
|
||||
* Called by bots after successful !login command.
|
||||
* Requires the user's JWT token from login.
|
||||
*
|
||||
* @example
|
||||
* POST /api/v1/auth/matrix-user-links
|
||||
* Authorization: Bearer <jwt-token>
|
||||
* Body: { "matrixUserId": "@user:matrix.mana.how", "email": "user@example.com" }
|
||||
*/
|
||||
@Post('matrix-user-links')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async linkMatrixUser(
|
||||
@Body() dto: LinkMatrixUserDto,
|
||||
@Headers('authorization') authHeader?: string,
|
||||
@Headers('x-service-key') serviceKey?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// Two auth methods: Bearer token (from user login) or Service key (from bot)
|
||||
let manaUserId: string;
|
||||
|
||||
if (serviceKey && this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||
// Service key auth - must provide userId in body
|
||||
const bodyWithUserId = dto as LinkMatrixUserDto & { userId?: string };
|
||||
if (!bodyWithUserId.userId) {
|
||||
throw new UnauthorizedException('userId required when using service key');
|
||||
}
|
||||
manaUserId = bodyWithUserId.userId;
|
||||
} else if (authHeader?.startsWith('Bearer ')) {
|
||||
// JWT auth - extract user ID from token
|
||||
const token = authHeader.substring(7);
|
||||
const payload = this.decodeToken(token);
|
||||
if (!payload?.sub) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
manaUserId = payload.sub;
|
||||
} else {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
if (!dto.matrixUserId) {
|
||||
throw new UnauthorizedException('matrixUserId is required');
|
||||
}
|
||||
|
||||
await this.matrixSessionService.linkMatrixUser(dto.matrixUserId, manaUserId, dto.email);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Matrix user ${dto.matrixUserId} linked successfully`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a JWT token for a linked Matrix user
|
||||
*
|
||||
* Called by bots to auto-authenticate users.
|
||||
* Requires service key (internal service authentication).
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/auth/matrix-session/@user:matrix.mana.how
|
||||
* X-Service-Key: <service-key>
|
||||
*/
|
||||
@Get('matrix-session/:matrixUserId')
|
||||
async getMatrixSession(
|
||||
@Param('matrixUserId') matrixUserId: string,
|
||||
@Headers('x-service-key') serviceKey?: string
|
||||
): Promise<{ token: string; email: string }> {
|
||||
// Require service key for this endpoint
|
||||
if (!serviceKey || !this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||
throw new UnauthorizedException('Valid service key required');
|
||||
}
|
||||
|
||||
const result = await this.matrixSessionService.getSessionForMatrixUser(
|
||||
decodeURIComponent(matrixUserId)
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException('No link found for this Matrix user');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a Matrix user from a Mana account
|
||||
*
|
||||
* Called when user wants to disconnect their Matrix account.
|
||||
* Requires the user's JWT token.
|
||||
*
|
||||
* @example
|
||||
* DELETE /api/v1/auth/matrix-user-links/@user:matrix.mana.how
|
||||
* Authorization: Bearer <jwt-token>
|
||||
*/
|
||||
@Delete('matrix-user-links/:matrixUserId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async unlinkMatrixUser(
|
||||
@Param('matrixUserId') matrixUserId: string,
|
||||
@Headers('authorization') authHeader?: string,
|
||||
@Headers('x-service-key') serviceKey?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// Allow both Bearer token and service key
|
||||
if (
|
||||
!authHeader?.startsWith('Bearer ') &&
|
||||
!this.matrixSessionService.validateServiceKey(serviceKey || '')
|
||||
) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
const deleted = await this.matrixSessionService.unlinkMatrixUser(
|
||||
decodeURIComponent(matrixUserId)
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
throw new NotFoundException('No link found for this Matrix user');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Matrix user ${matrixUserId} unlinked successfully`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Matrix user is linked
|
||||
*
|
||||
* Requires service key (internal service authentication).
|
||||
*
|
||||
* @example
|
||||
* GET /api/v1/auth/matrix-user-links/check/@user:matrix.mana.how
|
||||
* X-Service-Key: <service-key>
|
||||
*/
|
||||
@Get('matrix-user-links/check/:matrixUserId')
|
||||
async checkMatrixLink(
|
||||
@Param('matrixUserId') matrixUserId: string,
|
||||
@Headers('x-service-key') serviceKey?: string
|
||||
): Promise<{ linked: boolean }> {
|
||||
// Require service key for this endpoint
|
||||
if (!serviceKey || !this.matrixSessionService.validateServiceKey(serviceKey)) {
|
||||
throw new UnauthorizedException('Valid service key required');
|
||||
}
|
||||
|
||||
const linked = await this.matrixSessionService.isLinked(decodeURIComponent(matrixUserId));
|
||||
|
||||
return { linked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token to get payload (without verification)
|
||||
* Note: This is used only to extract user ID after the bot has verified the token
|
||||
*/
|
||||
private decodeToken(token: string): { sub?: string } | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8');
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
/**
|
||||
* OIDC Login Controller
|
||||
*
|
||||
* Provides a simple login page for OIDC authorization flows.
|
||||
* When users access the authorization endpoint without being logged in,
|
||||
* Better Auth redirects them here. After successful login, users are
|
||||
* redirected back to continue the authorization flow.
|
||||
*/
|
||||
|
||||
import { Controller, Get, Post, Req, Res, Body, Query } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
|
||||
@Controller()
|
||||
export class OidcLoginController {
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
|
||||
/**
|
||||
* GET /login - Display login page
|
||||
*
|
||||
* Shows a simple login form. OIDC parameters are preserved in the URL
|
||||
* so they can be passed back to the authorization endpoint after login.
|
||||
*/
|
||||
@Get('login')
|
||||
async getLoginPage(@Query() query: Record<string, string>, @Res() res: Response) {
|
||||
// Handle returnUrl parameter (when redirected from authorization endpoint)
|
||||
let returnUrl = query.returnUrl || '/';
|
||||
let clientId: string | undefined = query.client_id;
|
||||
|
||||
// If no direct client_id but we have returnUrl, extract client_id from it
|
||||
if (!clientId && query.returnUrl) {
|
||||
try {
|
||||
const returnUrlParams = new URLSearchParams(query.returnUrl.split('?')[1] || '');
|
||||
clientId = returnUrlParams.get('client_id') ?? undefined;
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
// If no returnUrl was provided, build one from query params (direct OIDC flow)
|
||||
if (!query.returnUrl && Object.keys(query).length > 0) {
|
||||
const queryString = new URLSearchParams(query).toString();
|
||||
returnUrl = `/api/auth/oauth2/authorize?${queryString}`;
|
||||
}
|
||||
|
||||
// Get client name for display
|
||||
const clientName = this.getClientDisplayName(clientId || 'Unknown');
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign In - Mana Core</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo h1 {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.logo p {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.client-info {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.client-info p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 13px;
|
||||
}
|
||||
.client-info strong {
|
||||
color: #818cf8;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
button:disabled {
|
||||
background: #4b5563;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
.error.show {
|
||||
display: block;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<h1>Mana Core</h1>
|
||||
<p>Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<div class="client-info">
|
||||
<p>Signing in to <strong>${clientName}</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
|
||||
<form id="loginForm">
|
||||
<input type="hidden" name="returnUrl" value="${returnUrl}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" placeholder="you@example.com" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter your password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div class="footer">
|
||||
<p>Secured by Mana Core Auth</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('loginForm');
|
||||
const errorDiv = document.getElementById('error');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const returnUrl = form.querySelector('[name="returnUrl"]').value;
|
||||
|
||||
errorDiv.classList.remove('show');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Signing in...';
|
||||
|
||||
try {
|
||||
// Use Better Auth's native sign-in endpoint which sets session cookies
|
||||
const response = await fetch('/api/auth/sign-in/email', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Login successful - session cookie is now set
|
||||
// Redirect to authorization endpoint to continue OIDC flow
|
||||
window.location.href = returnUrl;
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || 'Invalid email or password');
|
||||
}
|
||||
} catch (error) {
|
||||
errorDiv.textContent = error.message || 'An error occurred. Please try again.';
|
||||
errorDiv.classList.add('show');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign In';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
return res.send(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for OIDC client
|
||||
*/
|
||||
private getClientDisplayName(clientId: string): string {
|
||||
const clientNames: Record<string, string> = {
|
||||
'matrix-synapse': 'Matrix Chat',
|
||||
};
|
||||
return clientNames[clientId] || clientId;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
/**
|
||||
* OIDC Provider Controller
|
||||
*
|
||||
* Exposes Better Auth's OIDC Provider endpoints for external services
|
||||
* like Matrix/Synapse to use SSO authentication.
|
||||
*
|
||||
* Better Auth exposes OIDC endpoints at /api/auth/oauth2/* paths.
|
||||
* This controller provides routes at both:
|
||||
* - /api/auth/oauth2/* (native Better Auth paths from discovery document)
|
||||
* - /api/oidc/* (alternative paths for convenience)
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /.well-known/openid-configuration - OIDC Discovery
|
||||
* - GET /api/auth/oauth2/authorize - Authorization endpoint
|
||||
* - POST /api/auth/oauth2/token - Token endpoint
|
||||
* - GET /api/auth/oauth2/userinfo - UserInfo endpoint
|
||||
* - GET /api/auth/jwks - JWKS endpoint
|
||||
*/
|
||||
|
||||
import { Controller, Get, Post, All, Req, Res, HttpStatus } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { MatrixSessionService } from './services/matrix-session.service';
|
||||
import { LoggerService } from '../common/logger';
|
||||
|
||||
@Controller()
|
||||
export class OidcController {
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(
|
||||
private readonly betterAuthService: BetterAuthService,
|
||||
private readonly matrixSessionService: MatrixSessionService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('OidcController');
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Discovery Document
|
||||
*
|
||||
* Returns the OpenID Connect discovery document with all endpoints.
|
||||
*/
|
||||
@Get('.well-known/openid-configuration')
|
||||
async getOpenIdConfiguration(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Better Auth Native OAuth2 Endpoints
|
||||
// These match the paths in the discovery document
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Authorization Endpoint (Better Auth native path)
|
||||
*/
|
||||
@Get('api/auth/oauth2/authorize')
|
||||
async authorizeOauth2(@Req() req: Request, @Res() res: Response) {
|
||||
this.logger.debug('OIDC authorize request', { clientId: req.query.client_id });
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token Endpoint (Better Auth native path)
|
||||
*
|
||||
* Accepts both JSON and form-urlencoded body (OAuth2 spec requires form-urlencoded).
|
||||
* The body-parser middleware in main.ts parses form data into req.body object.
|
||||
*/
|
||||
@Post('api/auth/oauth2/token')
|
||||
async tokenOauth2(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* UserInfo Endpoint (Better Auth native path)
|
||||
*
|
||||
* When Matrix/Synapse calls this endpoint, we automatically create
|
||||
* the Matrix user link so bots can recognize the user without
|
||||
* requiring a separate !login command.
|
||||
*/
|
||||
@Get('api/auth/oauth2/userinfo')
|
||||
async userinfoOauth2(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequestWithMatrixLink(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWKS Endpoint (Better Auth native path)
|
||||
*/
|
||||
@Get('api/auth/jwks')
|
||||
async jwksAuth(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all for other Better Auth OAuth2 endpoints
|
||||
*/
|
||||
@All('api/auth/oauth2/*')
|
||||
async catchAllOauth2(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Better Auth Sign-in Endpoint
|
||||
*
|
||||
* This endpoint is needed for OIDC login flow.
|
||||
* When users log in via the /login page, it posts to this endpoint
|
||||
* which sets the session cookie needed for the OAuth2 flow.
|
||||
*/
|
||||
@Post('api/auth/sign-in/email')
|
||||
async signInEmail(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleBetterAuthRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Better Auth requests by forwarding to Better Auth's handler
|
||||
* This is a simpler handler that just passes through to Better Auth
|
||||
*/
|
||||
private async handleBetterAuthRequest(req: Request, res: Response) {
|
||||
try {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
|
||||
const url = new URL(req.originalUrl, baseUrl);
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value && typeof value === 'string') {
|
||||
headers.set(key, value);
|
||||
} else if (Array.isArray(value)) {
|
||||
headers.set(key, value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Fetch Request
|
||||
const fetchRequest = new Request(url.toString(), {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
||||
});
|
||||
|
||||
// Get Better Auth handler and call it directly
|
||||
const handler = this.betterAuthService.getHandler();
|
||||
const response = await handler(fetchRequest);
|
||||
|
||||
// Copy status
|
||||
res.status(response.status);
|
||||
|
||||
// Copy headers including Set-Cookie for session
|
||||
response.headers.forEach((value: string, key: string) => {
|
||||
// Handle multiple Set-Cookie headers
|
||||
if (key.toLowerCase() === 'set-cookie') {
|
||||
res.append(key, value);
|
||||
} else {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle redirects
|
||||
if (response.status === 302 || response.status === 301) {
|
||||
const location = response.headers.get('location');
|
||||
if (location) {
|
||||
return res.redirect(response.status, location);
|
||||
}
|
||||
}
|
||||
|
||||
// Return body
|
||||
const body = await response.text();
|
||||
if (body) {
|
||||
return res.send(body);
|
||||
}
|
||||
|
||||
return res.end();
|
||||
} catch (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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Alternative /api/oidc/* paths
|
||||
// For backwards compatibility and convenience
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Authorization Endpoint (alternative path)
|
||||
*/
|
||||
@Get('api/oidc/authorize')
|
||||
async authorize(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token Endpoint (alternative path)
|
||||
*/
|
||||
@Post('api/oidc/token')
|
||||
async token(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* UserInfo Endpoint (alternative path)
|
||||
*
|
||||
* When Matrix/Synapse calls this endpoint, we automatically create
|
||||
* the Matrix user link so bots can recognize the user.
|
||||
*/
|
||||
@Get('api/oidc/userinfo')
|
||||
async userinfo(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequestWithMatrixLink(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWKS Endpoint (alternative path)
|
||||
*/
|
||||
@Get('api/oidc/jwks')
|
||||
async jwks(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all for other OIDC endpoints (alternative path)
|
||||
*/
|
||||
@All('api/oidc/*')
|
||||
async catchAll(@Req() req: Request, @Res() res: Response) {
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OIDC request by forwarding to Better Auth
|
||||
*/
|
||||
private async handleOidcRequest(req: Request, res: Response) {
|
||||
try {
|
||||
const response = await this.betterAuthService.handleOidcRequest(req);
|
||||
|
||||
// Set status code
|
||||
res.status(response.status || HttpStatus.OK);
|
||||
|
||||
// Copy headers from Better Auth response
|
||||
if (response.headers) {
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (value) {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle redirects
|
||||
if (response.status === 302 || response.status === 301) {
|
||||
const location = response.headers?.location || response.headers?.Location;
|
||||
if (location) {
|
||||
return res.redirect(response.status, location as string);
|
||||
}
|
||||
}
|
||||
|
||||
// Return body
|
||||
if (response.body) {
|
||||
return res.send(response.body);
|
||||
}
|
||||
|
||||
return res.end();
|
||||
} catch (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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OIDC userinfo request with automatic Matrix user linking
|
||||
*
|
||||
* This method forwards the request to Better Auth, and if successful,
|
||||
* automatically creates a Matrix user link so bots can recognize
|
||||
* the user without requiring a separate !login command.
|
||||
*/
|
||||
private async handleOidcRequestWithMatrixLink(req: Request, res: Response) {
|
||||
try {
|
||||
const response = await this.betterAuthService.handleOidcRequest(req);
|
||||
|
||||
// Set status code
|
||||
res.status(response.status || HttpStatus.OK);
|
||||
|
||||
// Copy headers from Better Auth response
|
||||
if (response.headers) {
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (value) {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If userinfo was successful, create the Matrix user link
|
||||
if (response.status === 200 && response.body) {
|
||||
try {
|
||||
const userInfo = response.body as { sub?: string; email?: string };
|
||||
if (userInfo.sub && userInfo.email) {
|
||||
// Create Matrix user link asynchronously (don't block the response)
|
||||
this.matrixSessionService
|
||||
.autoLinkOnOidcLogin(userInfo.sub, userInfo.email)
|
||||
.catch((err) => {
|
||||
this.logger.warn('Failed to auto-link Matrix user on OIDC login', {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (linkError) {
|
||||
// Log but don't fail the request
|
||||
this.logger.warn('Error parsing userinfo for Matrix link', {
|
||||
error: linkError instanceof Error ? linkError.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return body
|
||||
if (response.body) {
|
||||
return res.send(response.body);
|
||||
}
|
||||
|
||||
return res.end();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'OIDC userinfo request failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,480 +0,0 @@
|
|||
/**
|
||||
* AuthController Passkey + 2FA Unit Tests
|
||||
*
|
||||
* Tests all passkey (WebAuthn) endpoints on the AuthController:
|
||||
*
|
||||
* - POST /auth/passkeys/register/options - Generate registration options
|
||||
* - POST /auth/passkeys/register/verify - Verify and store passkey
|
||||
* - POST /auth/passkeys/authenticate/options - Generate auth options (public)
|
||||
* - POST /auth/passkeys/authenticate/verify - Verify and return JWT tokens
|
||||
* - GET /auth/passkeys - List user's passkeys
|
||||
* - DELETE /auth/passkeys/:id - Delete a passkey
|
||||
* - PATCH /auth/passkeys/:id - Rename a passkey
|
||||
*
|
||||
* Also tests 2FA-related behavior in signIn.
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { PasskeyService } from './services/passkey.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security';
|
||||
|
||||
describe('AuthController - Passkey Endpoints', () => {
|
||||
let controller: AuthController;
|
||||
let passkeyService: jest.Mocked<PasskeyService>;
|
||||
let betterAuthService: jest.Mocked<BetterAuthService>;
|
||||
let securityEventsService: jest.Mocked<SecurityEventsService>;
|
||||
|
||||
const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' };
|
||||
const mockReq = {
|
||||
headers: { 'user-agent': 'test-agent' },
|
||||
ip: '127.0.0.1',
|
||||
} as any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockPasskeyService = {
|
||||
generateRegistrationOptions: jest.fn(),
|
||||
verifyRegistration: jest.fn(),
|
||||
generateAuthenticationOptions: jest.fn(),
|
||||
verifyAuthentication: jest.fn(),
|
||||
listPasskeys: jest.fn(),
|
||||
deletePasskey: jest.fn(),
|
||||
renamePasskey: jest.fn(),
|
||||
};
|
||||
|
||||
const mockBetterAuthService = {
|
||||
registerB2C: jest.fn(),
|
||||
registerB2B: jest.fn(),
|
||||
signIn: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
listOrganizations: jest.fn(),
|
||||
getOrganization: jest.fn(),
|
||||
getOrganizationMembers: jest.fn(),
|
||||
inviteEmployee: jest.fn(),
|
||||
acceptInvitation: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
setActiveOrganization: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
validateToken: jest.fn(),
|
||||
createSessionAndTokens: jest.fn(),
|
||||
requestPasswordReset: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
resendVerificationEmail: jest.fn(),
|
||||
getProfile: jest.fn(),
|
||||
updateProfile: jest.fn(),
|
||||
changePassword: jest.fn(),
|
||||
deleteAccount: jest.fn(),
|
||||
sessionToToken: jest.fn(),
|
||||
getJwks: jest.fn(),
|
||||
updateOrganization: jest.fn(),
|
||||
deleteOrganization: jest.fn(),
|
||||
updateMemberRole: jest.fn(),
|
||||
listOrganizationInvitations: jest.fn(),
|
||||
listUserInvitations: jest.fn(),
|
||||
cancelInvitation: jest.fn(),
|
||||
rejectInvitation: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSecurityEventsService = {
|
||||
logEvent: jest.fn().mockResolvedValue(undefined),
|
||||
logEventWithRequest: jest.fn().mockResolvedValue(undefined),
|
||||
extractRequestInfo: jest.fn().mockReturnValue({
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test-agent',
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAccountLockoutService = {
|
||||
checkLockout: jest.fn().mockResolvedValue({ locked: false }),
|
||||
recordAttempt: jest.fn().mockResolvedValue(undefined),
|
||||
clearAttempts: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: BetterAuthService, useValue: mockBetterAuthService },
|
||||
{ provide: PasskeyService, useValue: mockPasskeyService },
|
||||
{ provide: SecurityEventsService, useValue: mockSecurityEventsService },
|
||||
{ provide: AccountLockoutService, useValue: mockAccountLockoutService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.overrideGuard(ThrottlerGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
passkeyService = module.get(PasskeyService);
|
||||
betterAuthService = module.get(BetterAuthService);
|
||||
securityEventsService = module.get(SecurityEventsService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/passkeys/register/options
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/passkeys/register/options', () => {
|
||||
it('should call generateRegistrationOptions with user.userId', async () => {
|
||||
const expectedResult = {
|
||||
options: {
|
||||
challenge: 'test-challenge',
|
||||
rp: { name: 'ManaCore', id: 'localhost' },
|
||||
},
|
||||
challengeId: 'challenge-id-123',
|
||||
};
|
||||
|
||||
passkeyService.generateRegistrationOptions.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.passkeyRegisterOptions(mockUser as any);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(passkeyService.generateRegistrationOptions).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
|
||||
it('should return options and challengeId', async () => {
|
||||
const expectedResult = {
|
||||
options: { challenge: 'abc', rp: { name: 'ManaCore', id: 'localhost' } },
|
||||
challengeId: 'ch-456',
|
||||
};
|
||||
|
||||
passkeyService.generateRegistrationOptions.mockResolvedValue(expectedResult as any);
|
||||
|
||||
const result = await controller.passkeyRegisterOptions(mockUser as any);
|
||||
|
||||
expect(result.options).toBeDefined();
|
||||
expect(result.challengeId).toBe('ch-456');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/passkeys/register/verify
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/passkeys/register/verify', () => {
|
||||
it('should verify and return passkey info', async () => {
|
||||
const body = {
|
||||
challengeId: 'challenge-123',
|
||||
credential: { id: 'cred-1', response: {} },
|
||||
friendlyName: 'My Passkey',
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
id: 'pk-123',
|
||||
credentialId: 'cred-1',
|
||||
deviceType: 'multiPlatform',
|
||||
friendlyName: 'My Passkey',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
passkeyService.verifyRegistration.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.passkeyRegisterVerify(mockUser as any, body, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(passkeyService.verifyRegistration).toHaveBeenCalledWith(
|
||||
'challenge-123',
|
||||
body.credential,
|
||||
'My Passkey'
|
||||
);
|
||||
});
|
||||
|
||||
it('should log security event on successful registration', async () => {
|
||||
const body = {
|
||||
challengeId: 'challenge-123',
|
||||
credential: { id: 'cred-1', response: {} },
|
||||
};
|
||||
|
||||
passkeyService.verifyRegistration.mockResolvedValue({
|
||||
id: 'pk-123',
|
||||
credentialId: 'cred-1',
|
||||
deviceType: 'singleDevice',
|
||||
friendlyName: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await controller.passkeyRegisterVerify(mockUser as any, body, mockReq);
|
||||
|
||||
expect(securityEventsService.logEvent).toHaveBeenCalledWith({
|
||||
userId: 'user-123',
|
||||
eventType: SecurityEventType.PASSKEY_REGISTERED,
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test-agent',
|
||||
metadata: { passkeyId: 'pk-123' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/passkeys/authenticate/options
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/passkeys/authenticate/options', () => {
|
||||
it('should return options (no auth required)', async () => {
|
||||
const expectedResult = {
|
||||
options: { challenge: 'auth-challenge', rpId: 'localhost' },
|
||||
challengeId: 'auth-ch-123',
|
||||
};
|
||||
|
||||
passkeyService.generateAuthenticationOptions.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.passkeyAuthOptions();
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(passkeyService.generateAuthenticationOptions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// POST /auth/passkeys/authenticate/verify
|
||||
// ============================================================================
|
||||
|
||||
describe('POST /auth/passkeys/authenticate/verify', () => {
|
||||
it('should verify, create session+tokens, return tokens', async () => {
|
||||
const body = {
|
||||
challengeId: 'auth-ch-123',
|
||||
credential: { id: 'cred-1', response: {} },
|
||||
};
|
||||
|
||||
const mockAuthUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: 'user' as const,
|
||||
twoFactorEnabled: null,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
passkeyService.verifyAuthentication.mockResolvedValue({
|
||||
user: mockAuthUser as any,
|
||||
passkeyId: 'pk-123',
|
||||
});
|
||||
|
||||
const tokenResult = {
|
||||
user: { id: 'user-123', email: 'test@example.com', name: 'Test User', role: 'user' },
|
||||
accessToken: 'jwt-access-token',
|
||||
refreshToken: 'jwt-refresh-token',
|
||||
expiresIn: 900,
|
||||
};
|
||||
|
||||
betterAuthService.createSessionAndTokens.mockResolvedValue(tokenResult);
|
||||
|
||||
const result = await controller.passkeyAuthVerify(body, mockReq);
|
||||
|
||||
expect(result).toEqual(tokenResult);
|
||||
expect(passkeyService.verifyAuthentication).toHaveBeenCalledWith(
|
||||
'auth-ch-123',
|
||||
body.credential
|
||||
);
|
||||
expect(betterAuthService.createSessionAndTokens).toHaveBeenCalledWith(mockAuthUser, {
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test-agent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should log security event on success', async () => {
|
||||
const body = {
|
||||
challengeId: 'auth-ch-123',
|
||||
credential: { id: 'cred-1', response: {} },
|
||||
};
|
||||
|
||||
passkeyService.verifyAuthentication.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test',
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: 'user' as const,
|
||||
twoFactorEnabled: null,
|
||||
deletedAt: null,
|
||||
} as any,
|
||||
passkeyId: 'pk-456',
|
||||
});
|
||||
|
||||
betterAuthService.createSessionAndTokens.mockResolvedValue({
|
||||
user: { id: 'user-123', email: 'test@example.com', name: 'Test', role: 'user' },
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresIn: 900,
|
||||
});
|
||||
|
||||
await controller.passkeyAuthVerify(body, mockReq);
|
||||
|
||||
expect(securityEventsService.logEvent).toHaveBeenCalledWith({
|
||||
userId: 'user-123',
|
||||
eventType: SecurityEventType.PASSKEY_LOGIN_SUCCESS,
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test-agent',
|
||||
metadata: { passkeyId: 'pk-456' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/passkeys
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/passkeys', () => {
|
||||
it("should return user's passkeys", async () => {
|
||||
const mockPasskeys = [
|
||||
{
|
||||
id: 'pk-1',
|
||||
credentialId: 'cred-1',
|
||||
deviceType: 'multiPlatform',
|
||||
backedUp: true,
|
||||
friendlyName: 'MacBook',
|
||||
lastUsedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'pk-2',
|
||||
credentialId: 'cred-2',
|
||||
deviceType: 'singleDevice',
|
||||
backedUp: false,
|
||||
friendlyName: null,
|
||||
lastUsedAt: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
passkeyService.listPasskeys.mockResolvedValue(mockPasskeys);
|
||||
|
||||
const result = await controller.listPasskeys(mockUser as any);
|
||||
|
||||
expect(result).toEqual(mockPasskeys);
|
||||
expect(passkeyService.listPasskeys).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /auth/passkeys/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('DELETE /auth/passkeys/:id', () => {
|
||||
it('should delete and log security event', async () => {
|
||||
passkeyService.deletePasskey.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deletePasskey(mockUser as any, 'pk-123', mockReq);
|
||||
|
||||
expect(passkeyService.deletePasskey).toHaveBeenCalledWith('user-123', 'pk-123');
|
||||
expect(securityEventsService.logEvent).toHaveBeenCalledWith({
|
||||
userId: 'user-123',
|
||||
eventType: SecurityEventType.PASSKEY_DELETED,
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test-agent',
|
||||
metadata: { passkeyId: 'pk-123' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return void (204 status handled by decorator)', async () => {
|
||||
passkeyService.deletePasskey.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.deletePasskey(mockUser as any, 'pk-456', mockReq);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PATCH /auth/passkeys/:id
|
||||
// ============================================================================
|
||||
|
||||
describe('PATCH /auth/passkeys/:id', () => {
|
||||
it('should rename passkey', async () => {
|
||||
passkeyService.renamePasskey.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.renamePasskey(mockUser as any, 'pk-123', {
|
||||
friendlyName: 'Work Laptop',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(passkeyService.renamePasskey).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
'pk-123',
|
||||
'Work Laptop'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 2FA behavior in signIn
|
||||
// ============================================================================
|
||||
|
||||
describe('2FA in signIn', () => {
|
||||
it('should pass through twoFactorRedirect when returned by BetterAuthService', async () => {
|
||||
const loginDto = {
|
||||
email: 'user@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
deviceId: undefined,
|
||||
deviceName: undefined,
|
||||
};
|
||||
|
||||
const twoFactorResult = {
|
||||
twoFactorRedirect: true,
|
||||
message: 'Two-factor authentication required',
|
||||
};
|
||||
|
||||
betterAuthService.signIn.mockResolvedValue(twoFactorResult as any);
|
||||
|
||||
const result = await controller.login(loginDto, mockReq);
|
||||
|
||||
expect(result).toEqual(twoFactorResult);
|
||||
expect((result as any).twoFactorRedirect).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Guard Tests for Passkey Endpoints
|
||||
// ============================================================================
|
||||
|
||||
describe('Passkey Guard Configuration', () => {
|
||||
it('should have JwtAuthGuard on protected passkey endpoints', () => {
|
||||
const protectedEndpoints: (keyof AuthController)[] = [
|
||||
'passkeyRegisterOptions',
|
||||
'passkeyRegisterVerify',
|
||||
'listPasskeys',
|
||||
'deletePasskey',
|
||||
'renamePasskey',
|
||||
];
|
||||
|
||||
protectedEndpoints.forEach((endpoint) => {
|
||||
const guards = Reflect.getMetadata(
|
||||
'__guards__',
|
||||
AuthController.prototype[endpoint as keyof AuthController]
|
||||
);
|
||||
expect(guards).toBeDefined();
|
||||
expect(guards).toContain(JwtAuthGuard);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT have JwtAuthGuard on public passkey endpoints', () => {
|
||||
const publicEndpoints: (keyof AuthController)[] = ['passkeyAuthOptions', 'passkeyAuthVerify'];
|
||||
|
||||
publicEndpoints.forEach((endpoint) => {
|
||||
const guards = Reflect.getMetadata(
|
||||
'__guards__',
|
||||
AuthController.prototype[endpoint as keyof AuthController]
|
||||
);
|
||||
expect(guards).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
/**
|
||||
* AuthController Security Events Unit Tests
|
||||
*
|
||||
* Tests the security events / audit log endpoint on the AuthController:
|
||||
*
|
||||
* - GET /auth/security-events - List user's security events
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { PasskeyService } from './services/passkey.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security';
|
||||
|
||||
describe('AuthController - Security Events', () => {
|
||||
let controller: AuthController;
|
||||
let betterAuthService: jest.Mocked<BetterAuthService>;
|
||||
|
||||
const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' };
|
||||
const mockReq = {
|
||||
headers: { 'user-agent': 'test-agent' },
|
||||
ip: '127.0.0.1',
|
||||
} as any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockPasskeyService = {
|
||||
generateRegistrationOptions: jest.fn(),
|
||||
verifyRegistration: jest.fn(),
|
||||
generateAuthenticationOptions: jest.fn(),
|
||||
verifyAuthentication: jest.fn(),
|
||||
listPasskeys: jest.fn(),
|
||||
deletePasskey: jest.fn(),
|
||||
renamePasskey: jest.fn(),
|
||||
};
|
||||
|
||||
const mockBetterAuthService = {
|
||||
registerB2C: jest.fn(),
|
||||
registerB2B: jest.fn(),
|
||||
signIn: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
listOrganizations: jest.fn(),
|
||||
getOrganization: jest.fn(),
|
||||
getOrganizationMembers: jest.fn(),
|
||||
inviteEmployee: jest.fn(),
|
||||
acceptInvitation: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
setActiveOrganization: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
validateToken: jest.fn(),
|
||||
createSessionAndTokens: jest.fn(),
|
||||
requestPasswordReset: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
resendVerificationEmail: jest.fn(),
|
||||
getProfile: jest.fn(),
|
||||
updateProfile: jest.fn(),
|
||||
changePassword: jest.fn(),
|
||||
deleteAccount: jest.fn(),
|
||||
sessionToToken: jest.fn(),
|
||||
getJwks: jest.fn(),
|
||||
updateOrganization: jest.fn(),
|
||||
deleteOrganization: jest.fn(),
|
||||
updateMemberRole: jest.fn(),
|
||||
listOrganizationInvitations: jest.fn(),
|
||||
listUserInvitations: jest.fn(),
|
||||
cancelInvitation: jest.fn(),
|
||||
rejectInvitation: jest.fn(),
|
||||
getSecurityEvents: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSecurityEventsService = {
|
||||
logEvent: jest.fn().mockResolvedValue(undefined),
|
||||
logEventWithRequest: jest.fn().mockResolvedValue(undefined),
|
||||
extractRequestInfo: jest.fn().mockReturnValue({
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test-agent',
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAccountLockoutService = {
|
||||
checkLockout: jest.fn().mockResolvedValue({ locked: false }),
|
||||
recordAttempt: jest.fn().mockResolvedValue(undefined),
|
||||
clearAttempts: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: BetterAuthService, useValue: mockBetterAuthService },
|
||||
{ provide: PasskeyService, useValue: mockPasskeyService },
|
||||
{ provide: SecurityEventsService, useValue: mockSecurityEventsService },
|
||||
{ provide: AccountLockoutService, useValue: mockAccountLockoutService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.overrideGuard(ThrottlerGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
betterAuthService = module.get(BetterAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /auth/security-events
|
||||
// ============================================================================
|
||||
|
||||
describe('GET /auth/security-events', () => {
|
||||
it("should return user's events from BetterAuthService", async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
eventType: 'login_success',
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
metadata: { email: 'test@example.com' },
|
||||
createdAt: new Date('2026-03-27T10:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
eventType: 'password_changed',
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
metadata: {},
|
||||
createdAt: new Date('2026-03-26T09:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
betterAuthService.getSecurityEvents.mockResolvedValue(mockEvents);
|
||||
|
||||
const result = await controller.getSecurityEvents(mockUser as any, mockReq);
|
||||
|
||||
expect(result).toEqual(mockEvents);
|
||||
expect(betterAuthService.getSecurityEvents).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
|
||||
it('should return empty array when no events exist', async () => {
|
||||
betterAuthService.getSecurityEvents.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getSecurityEvents(mockUser as any, mockReq);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(betterAuthService.getSecurityEvents).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
|
||||
it('should return events in descending order by createdAt', async () => {
|
||||
const newerEvent = {
|
||||
id: 'evt-1',
|
||||
eventType: 'login_success',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test',
|
||||
metadata: {},
|
||||
createdAt: new Date('2026-03-27T12:00:00Z'),
|
||||
};
|
||||
const olderEvent = {
|
||||
id: 'evt-2',
|
||||
eventType: 'logout',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test',
|
||||
metadata: {},
|
||||
createdAt: new Date('2026-03-26T08:00:00Z'),
|
||||
};
|
||||
|
||||
// BetterAuthService already orders them desc by createdAt
|
||||
betterAuthService.getSecurityEvents.mockResolvedValue([newerEvent, olderEvent]);
|
||||
|
||||
const result = await controller.getSecurityEvents(mockUser as any, mockReq);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(new Date(result[0].createdAt).getTime()).toBeGreaterThan(
|
||||
new Date(result[1].createdAt).getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Guard Configuration
|
||||
// ============================================================================
|
||||
|
||||
describe('Security Events Guard Configuration', () => {
|
||||
it('should have JwtAuthGuard on getSecurityEvents', () => {
|
||||
const guards = Reflect.getMetadata('__guards__', AuthController.prototype.getSecurityEvents);
|
||||
expect(guards).toBeDefined();
|
||||
expect(guards).toContain(JwtAuthGuard);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
/**
|
||||
* BetterAuthService.getSecurityEvents Unit Tests
|
||||
*
|
||||
* Tests the audit log / security events query method.
|
||||
* Uses the thenable DB mock pattern from passkey.service.spec.ts.
|
||||
*
|
||||
* Since BetterAuthService has complex constructor dependencies (Better Auth,
|
||||
* OIDC provider), we mock the better-auth.config module and the DB connection.
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { LoggerService } from '../../common/logger';
|
||||
|
||||
// Mock better-auth config to avoid oidcProvider instantiation
|
||||
jest.mock('../better-auth.config', () => ({
|
||||
createBetterAuth: jest.fn(() => ({
|
||||
api: {},
|
||||
handler: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../db/connection', () => ({
|
||||
getDb: jest.fn(),
|
||||
}));
|
||||
|
||||
const createMockDb = () => {
|
||||
let results: any[] = [];
|
||||
let resultIndex = 0;
|
||||
|
||||
const db: any = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
then: jest.fn((resolve) => resolve(results[resultIndex++] || [])),
|
||||
setResults: (...r: any[]) => {
|
||||
results = r;
|
||||
resultIndex = 0;
|
||||
},
|
||||
};
|
||||
return db;
|
||||
};
|
||||
|
||||
// Import after mocks are set up
|
||||
import { BetterAuthService } from './better-auth.service';
|
||||
|
||||
describe('BetterAuthService - getSecurityEvents', () => {
|
||||
let service: BetterAuthService;
|
||||
let mockDb: ReturnType<typeof createMockDb>;
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string, defaultValue?: string) => {
|
||||
const config: Record<string, string> = {
|
||||
'database.url': 'postgresql://test:test@localhost:5432/test',
|
||||
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
|
||||
JWT_ISSUER: 'manacore',
|
||||
JWT_AUDIENCE: 'manacore',
|
||||
BASE_URL: 'http://localhost:3001',
|
||||
};
|
||||
return config[key] || defaultValue || '';
|
||||
}),
|
||||
};
|
||||
|
||||
const mockLoggerService = {
|
||||
setContext: jest.fn().mockReturnThis(),
|
||||
log: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = createMockDb();
|
||||
(getDb as jest.Mock).mockReturnValue(mockDb);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BetterAuthService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: LoggerService, useValue: mockLoggerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BetterAuthService>(BetterAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return events for a given userId ordered by createdAt desc', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
eventType: 'login_success',
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
metadata: { email: 'test@example.com' },
|
||||
createdAt: new Date('2026-03-27T10:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
eventType: 'logout',
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
metadata: {},
|
||||
createdAt: new Date('2026-03-26T09:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
mockDb.setResults(mockEvents);
|
||||
|
||||
const result = await service.getSecurityEvents('user-123');
|
||||
|
||||
expect(result).toEqual(mockEvents);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.from).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
expect(mockDb.orderBy).toHaveBeenCalled();
|
||||
expect(mockDb.limit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should limit results to default of 50', async () => {
|
||||
mockDb.setResults([]);
|
||||
|
||||
await service.getSecurityEvents('user-123');
|
||||
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('should respect custom limit parameter', async () => {
|
||||
mockDb.setResults([]);
|
||||
|
||||
await service.getSecurityEvents('user-123', 10);
|
||||
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('should return empty array when no events exist', async () => {
|
||||
mockDb.setResults([]);
|
||||
|
||||
const result = await service.getSecurityEvents('user-123');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,980 +0,0 @@
|
|||
/**
|
||||
* BetterAuthService Unit Tests
|
||||
*
|
||||
* Tests all Better Auth integration flows:
|
||||
* - B2C user registration
|
||||
* - B2B organization registration
|
||||
* - Organization member management
|
||||
* - Employee invitations
|
||||
* - Credit balance initialization
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ConflictException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { BetterAuthService } from './better-auth.service';
|
||||
import { LoggerService } from '../../common/logger';
|
||||
import { createMockConfigService } from '../../__tests__/utils/test-helpers';
|
||||
import { silentError } from '../../__tests__/utils/silent-error.decorator';
|
||||
|
||||
// Mock services that are not yet implemented
|
||||
const SecurityEventsService = jest.fn();
|
||||
|
||||
// Mock nanoid before importing factories
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid-123'),
|
||||
}));
|
||||
|
||||
// Mock database connection
|
||||
jest.mock('../../db/connection');
|
||||
|
||||
// Import after mocks
|
||||
import { mockUserFactory } from '../../__tests__/utils/mock-factories';
|
||||
|
||||
// Mock Better Auth configuration
|
||||
const mockAuthApi = {
|
||||
signUpEmail: jest.fn(),
|
||||
createOrganization: jest.fn(),
|
||||
inviteMember: jest.fn(),
|
||||
acceptInvitation: jest.fn(),
|
||||
getFullOrganization: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
setActiveOrganization: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../better-auth.config', () => ({
|
||||
createBetterAuth: jest.fn(() => ({
|
||||
api: mockAuthApi,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock services
|
||||
const mockSecurityEventsService = {
|
||||
logEvent: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockLoggerService = {
|
||||
setContext: jest.fn().mockReturnThis(),
|
||||
log: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
};
|
||||
|
||||
describe('BetterAuthService', () => {
|
||||
let service: BetterAuthService;
|
||||
let configService: ConfigService;
|
||||
let mockDb: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock database
|
||||
mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock getDb
|
||||
const { getDb } = require('../../db/connection');
|
||||
getDb.mockReturnValue(mockDb);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BetterAuthService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: createMockConfigService({
|
||||
'database.url': 'postgresql://test:test@localhost:5432/test',
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: SecurityEventsService,
|
||||
useValue: mockSecurityEventsService,
|
||||
},
|
||||
{
|
||||
provide: LoggerService,
|
||||
useValue: mockLoggerService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BetterAuthService>(BetterAuthService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('registerB2C', () => {
|
||||
it('should register a new B2C user successfully', async () => {
|
||||
const registerDto = {
|
||||
email: 'newuser@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'New User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({
|
||||
id: 'user-123',
|
||||
email: registerDto.email,
|
||||
name: registerDto.name,
|
||||
});
|
||||
|
||||
// Mock Better Auth signup response
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-session-token',
|
||||
});
|
||||
|
||||
// Mock credit balance creation (success)
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
const result = await service.registerB2C(registerDto);
|
||||
|
||||
// Verify Better Auth API was called correctly
|
||||
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: registerDto.name,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify personal credit balance was created (no free credits)
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockDb.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-123',
|
||||
balance: 0,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
})
|
||||
);
|
||||
|
||||
// Verify response structure
|
||||
expect(result).toEqual({
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'newuser@example.com',
|
||||
name: 'New User',
|
||||
},
|
||||
token: 'mock-session-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConflictException if user already exists', async () => {
|
||||
const registerDto = {
|
||||
email: 'existing@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Existing User',
|
||||
};
|
||||
|
||||
// Mock Better Auth error for existing user
|
||||
mockAuthApi.signUpEmail.mockRejectedValue(new Error('User with this email already exists'));
|
||||
|
||||
await expect(service.registerB2C(registerDto)).rejects.toThrow(ConflictException);
|
||||
await expect(service.registerB2C(registerDto)).rejects.toThrow(
|
||||
'User with this email already exists'
|
||||
);
|
||||
|
||||
// Verify no credit balance was created
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should normalize email to lowercase', async () => {
|
||||
const registerDto = {
|
||||
email: 'NewUser@EXAMPLE.COM',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'New User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({
|
||||
email: 'NewUser@EXAMPLE.COM', // Better Auth should handle normalization
|
||||
});
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
});
|
||||
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2C(registerDto);
|
||||
|
||||
// Verify email was passed as-is (Better Auth normalizes internally)
|
||||
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
email: 'NewUser@EXAMPLE.COM',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create personal credit balance with signup bonus', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'user-123' });
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
});
|
||||
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2C(registerDto);
|
||||
|
||||
// Verify credit balance initialization (no free credits)
|
||||
expect(mockDb.values).toHaveBeenCalledWith({
|
||||
userId: 'user-123',
|
||||
balance: 0,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should continue registration even if credit balance creation fails', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'user-123' });
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-token',
|
||||
});
|
||||
|
||||
// Mock database error for credit balance creation
|
||||
mockDb.returning.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
// Should not throw - registration should complete
|
||||
const result = await service.registerB2C(registerDto);
|
||||
|
||||
expect(result.user.id).toBe('user-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerB2B', () => {
|
||||
it('should register organization owner successfully', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({
|
||||
id: 'owner-123',
|
||||
email: registerDto.ownerEmail,
|
||||
name: registerDto.ownerName,
|
||||
});
|
||||
|
||||
const mockOrg = {
|
||||
id: 'org-123',
|
||||
name: 'Acme Corporation',
|
||||
slug: 'acme-corporation',
|
||||
};
|
||||
|
||||
// Mock user creation
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'mock-session-token',
|
||||
});
|
||||
|
||||
// Mock organization creation
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
|
||||
// Mock credit balance creation
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
const result = await service.registerB2B(registerDto);
|
||||
|
||||
// Verify user creation
|
||||
expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: registerDto.ownerEmail,
|
||||
password: registerDto.password,
|
||||
name: registerDto.ownerName,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'Acme Corporation',
|
||||
slug: 'acme-corporation',
|
||||
},
|
||||
headers: {
|
||||
authorization: 'Bearer mock-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify personal credit balance was created (org balance removed)
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify response structure
|
||||
expect(result).toEqual({
|
||||
user: mockUser,
|
||||
organization: mockOrg,
|
||||
token: 'mock-session-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create personal credit balance for org owner', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
});
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
// Verify personal credit balance was created (no org balance - B2B simplified)
|
||||
expect(mockDb.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'owner-123',
|
||||
balance: 0,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle organization creation failure', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
});
|
||||
|
||||
// Mock organization creation failure
|
||||
mockAuthApi.createOrganization.mockRejectedValue(new Error('Failed to create organization'));
|
||||
|
||||
await expect(service.registerB2B(registerDto)).rejects.toThrow(
|
||||
'Failed to create organization'
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate valid slug from organization name', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'My Awesome Company!!!',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
const mockOrg = { id: 'org-123', name: 'My Awesome Company' };
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
});
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
// Verify slug was sanitized
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'my-awesome-company',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConflictException if owner email already exists', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'existing@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
mockAuthApi.signUpEmail.mockRejectedValue(new Error('User with this email already exists'));
|
||||
|
||||
await expect(service.registerB2B(registerDto)).rejects.toThrow(ConflictException);
|
||||
await expect(service.registerB2B(registerDto)).rejects.toThrow('Owner email already exists');
|
||||
|
||||
// Verify organization was never created
|
||||
expect(mockAuthApi.createOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create both organization and personal credit balances', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: 'token',
|
||||
});
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
// Verify personal credit balance was created (no org balance)
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Personal balance for the owner
|
||||
expect(mockDb.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'owner-123',
|
||||
balance: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inviteEmployee', () => {
|
||||
it('should send invitation successfully', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'employee@example.com',
|
||||
role: 'member' as const,
|
||||
inviterToken: 'inviter-session-token',
|
||||
};
|
||||
|
||||
const mockInvitation = {
|
||||
id: 'invitation-123',
|
||||
email: 'employee@example.com',
|
||||
organizationId: 'org-123',
|
||||
role: 'member',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockResolvedValue(mockInvitation);
|
||||
|
||||
const result = await service.inviteEmployee(inviteDto);
|
||||
|
||||
// Verify Better Auth API was called
|
||||
expect(mockAuthApi.inviteMember).toHaveBeenCalledWith({
|
||||
body: {
|
||||
organizationId: 'org-123',
|
||||
email: 'employee@example.com',
|
||||
role: 'member',
|
||||
},
|
||||
headers: {
|
||||
authorization: 'Bearer inviter-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockInvitation);
|
||||
});
|
||||
|
||||
it('should pass correct role to Better Auth API', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'admin@example.com',
|
||||
role: 'admin' as const,
|
||||
inviterToken: 'inviter-token',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockResolvedValue({});
|
||||
|
||||
await service.inviteEmployee(inviteDto);
|
||||
|
||||
expect(mockAuthApi.inviteMember).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
role: 'admin',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invitation to existing member', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'existing@example.com',
|
||||
role: 'member' as const,
|
||||
inviterToken: 'inviter-token',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockRejectedValue(new Error('User is already a member'));
|
||||
|
||||
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow('User is already a member');
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException if inviter lacks permission', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'employee@example.com',
|
||||
role: 'member' as const,
|
||||
inviterToken: 'invalid-token',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockRejectedValue(
|
||||
new Error('You do not have permission to invite members')
|
||||
);
|
||||
|
||||
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(ForbiddenException);
|
||||
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(
|
||||
'You do not have permission to invite members'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptInvitation', () => {
|
||||
it('should accept invitation and add user to org', async () => {
|
||||
const acceptDto = {
|
||||
invitationId: 'invitation-123',
|
||||
userToken: 'user-session-token',
|
||||
};
|
||||
|
||||
const mockMembership = {
|
||||
userId: 'user-123',
|
||||
organizationId: 'org-123',
|
||||
role: 'member',
|
||||
};
|
||||
|
||||
mockAuthApi.acceptInvitation.mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await service.acceptInvitation(acceptDto);
|
||||
|
||||
// Verify Better Auth API was called
|
||||
expect(mockAuthApi.acceptInvitation).toHaveBeenCalledWith({
|
||||
body: { invitationId: 'invitation-123' },
|
||||
headers: {
|
||||
authorization: 'Bearer user-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockMembership);
|
||||
});
|
||||
|
||||
it('should handle expired invitation', async () => {
|
||||
const acceptDto = {
|
||||
invitationId: 'expired-invitation',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
mockAuthApi.acceptInvitation.mockRejectedValue(new Error('Invitation expired'));
|
||||
|
||||
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(
|
||||
'Invitation not found or expired'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle already accepted invitation', async () => {
|
||||
const acceptDto = {
|
||||
invitationId: 'used-invitation',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
mockAuthApi.acceptInvitation.mockRejectedValue(new Error('Invitation not found'));
|
||||
|
||||
await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrganizationMembers', () => {
|
||||
it('should return list of members', async () => {
|
||||
const mockMembers = [
|
||||
{
|
||||
userId: 'user-1',
|
||||
organizationId: 'org-123',
|
||||
role: 'owner',
|
||||
name: 'John Owner',
|
||||
email: 'owner@example.com',
|
||||
},
|
||||
{
|
||||
userId: 'user-2',
|
||||
organizationId: 'org-123',
|
||||
role: 'member',
|
||||
name: 'Jane Member',
|
||||
email: 'member@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
mockAuthApi.getFullOrganization.mockResolvedValue({ members: mockMembers });
|
||||
|
||||
const result = await service.getOrganizationMembers('org-123');
|
||||
|
||||
expect(mockAuthApi.getFullOrganization).toHaveBeenCalledWith({
|
||||
query: { organizationId: 'org-123' },
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockMembers);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle empty organization', async () => {
|
||||
mockAuthApi.getFullOrganization.mockResolvedValue({ members: [] });
|
||||
|
||||
const result = await service.getOrganizationMembers('org-123');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockAuthApi.getFullOrganization.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await silentError(async () => {
|
||||
return await service.getOrganizationMembers('org-123');
|
||||
});
|
||||
|
||||
// Should not throw, but return empty array
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
it('should remove member successfully', async () => {
|
||||
const removeDto = {
|
||||
organizationId: 'org-123',
|
||||
memberId: 'user-456',
|
||||
removerToken: 'admin-token',
|
||||
};
|
||||
|
||||
mockAuthApi.removeMember.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await service.removeMember(removeDto);
|
||||
|
||||
expect(mockAuthApi.removeMember).toHaveBeenCalledWith({
|
||||
body: {
|
||||
memberIdOrEmail: 'user-456',
|
||||
organizationId: 'org-123',
|
||||
},
|
||||
headers: {
|
||||
authorization: 'Bearer admin-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: 'Member removed successfully',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle removing non-existent member', async () => {
|
||||
const removeDto = {
|
||||
organizationId: 'org-123',
|
||||
memberId: 'non-existent',
|
||||
removerToken: 'admin-token',
|
||||
};
|
||||
|
||||
mockAuthApi.removeMember.mockRejectedValue(new Error('Member not found'));
|
||||
|
||||
await expect(service.removeMember(removeDto)).rejects.toThrow('Member not found');
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException if remover lacks permission', async () => {
|
||||
const removeDto = {
|
||||
organizationId: 'org-123',
|
||||
memberId: 'user-456',
|
||||
removerToken: 'member-token', // Regular member cannot remove
|
||||
};
|
||||
|
||||
mockAuthApi.removeMember.mockRejectedValue(
|
||||
new Error('You do not have permission to remove members')
|
||||
);
|
||||
|
||||
await expect(service.removeMember(removeDto)).rejects.toThrow(ForbiddenException);
|
||||
await expect(service.removeMember(removeDto)).rejects.toThrow(
|
||||
'You do not have permission to remove members'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveOrganization', () => {
|
||||
it('should switch organization successfully', async () => {
|
||||
const setActiveDto = {
|
||||
organizationId: 'org-456',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
userId: 'user-123',
|
||||
activeOrganizationId: 'org-456',
|
||||
};
|
||||
|
||||
mockAuthApi.setActiveOrganization.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await service.setActiveOrganization(setActiveDto);
|
||||
|
||||
expect(mockAuthApi.setActiveOrganization).toHaveBeenCalledWith({
|
||||
body: { organizationId: 'org-456' },
|
||||
headers: {
|
||||
authorization: 'Bearer user-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
});
|
||||
|
||||
it('should update session context', async () => {
|
||||
const setActiveDto = {
|
||||
organizationId: 'org-789',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
userId: 'user-123',
|
||||
activeOrganizationId: 'org-789',
|
||||
metadata: {
|
||||
previousOrg: 'org-456',
|
||||
},
|
||||
};
|
||||
|
||||
mockAuthApi.setActiveOrganization.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await service.setActiveOrganization(setActiveDto);
|
||||
|
||||
expect(result.activeOrganizationId).toBe('org-789');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for invalid organization', async () => {
|
||||
const setActiveDto = {
|
||||
organizationId: 'non-existent-org',
|
||||
userToken: 'user-token',
|
||||
};
|
||||
|
||||
mockAuthApi.setActiveOrganization.mockRejectedValue(
|
||||
new Error('Organization not found or you are not a member')
|
||||
);
|
||||
|
||||
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(
|
||||
'Organization not found or you are not a member'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify (private method)', () => {
|
||||
it('should convert organization name to lowercase slug', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'My Company',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'my-company',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove special characters from slug', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Company #1 (Best!)',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'company-1-best',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace spaces with hyphens', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Multi Word Company Name',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'multi-word-company-name',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive spaces', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Company With Spaces',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
slug: 'company-with-spaces',
|
||||
}),
|
||||
headers: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Credit Balance Initialization', () => {
|
||||
it('should initialize B2C user with signup bonus credits', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'user-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2C(registerDto);
|
||||
|
||||
// Verify credit balance was initialized with correct values (simplified - no free credits)
|
||||
expect(mockDb.values).toHaveBeenCalledWith({
|
||||
userId: 'user-123',
|
||||
balance: 0,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize personal credit balance for B2B owner', async () => {
|
||||
const registerDto = {
|
||||
ownerEmail: 'owner@company.com',
|
||||
password: 'SecurePassword123!',
|
||||
ownerName: 'John Owner',
|
||||
organizationName: 'Acme Corporation',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'owner-123' });
|
||||
const mockOrg = { id: 'org-123', name: 'Acme Corporation' };
|
||||
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
mockAuthApi.createOrganization.mockResolvedValue(mockOrg);
|
||||
mockDb.returning.mockResolvedValue([]);
|
||||
|
||||
await service.registerB2B(registerDto);
|
||||
|
||||
// Verify personal balance was initialized (no org balance - simplified system)
|
||||
expect(mockDb.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'owner-123',
|
||||
balance: 0,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not fail registration if credit balance creation errors', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockUser = mockUserFactory.create({ id: 'user-123' });
|
||||
mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' });
|
||||
|
||||
// Mock database error
|
||||
mockDb.insert.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
// Should not throw - registration should complete despite credit error
|
||||
const result = await silentError(async () => {
|
||||
return await service.registerB2C(registerDto);
|
||||
});
|
||||
|
||||
expect(result.user.id).toBe('user-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle generic errors from Better Auth', async () => {
|
||||
const registerDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
mockAuthApi.signUpEmail.mockRejectedValue(new Error('Unexpected server error'));
|
||||
|
||||
await expect(service.registerB2C(registerDto)).rejects.toThrow('Unexpected server error');
|
||||
});
|
||||
|
||||
it('should propagate network errors', async () => {
|
||||
const inviteDto = {
|
||||
organizationId: 'org-123',
|
||||
employeeEmail: 'employee@example.com',
|
||||
role: 'member' as const,
|
||||
inviterToken: 'token',
|
||||
};
|
||||
|
||||
mockAuthApi.inviteMember.mockRejectedValue(new Error('Network timeout'));
|
||||
|
||||
await expect(service.inviteEmployee(inviteDto)).rejects.toThrow('Network timeout');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,260 +0,0 @@
|
|||
import { Injectable, Logger, UnauthorizedException, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { matrixUserLinks, users } from '../../db/schema/auth.schema';
|
||||
import { BetterAuthService } from './better-auth.service';
|
||||
|
||||
/**
|
||||
* Matrix Session Service
|
||||
*
|
||||
* Manages the link between Matrix user IDs and Mana Core Auth accounts.
|
||||
* Enables automatic bot authentication for users who have linked their accounts.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User logs into a Matrix bot via !login email password
|
||||
* 2. Bot calls POST /api/v1/auth/matrix-user-links to store the link
|
||||
* 3. Later, bot can call GET /api/v1/auth/matrix-session/:matrixUserId
|
||||
* 4. If a link exists, a fresh JWT token is returned
|
||||
*/
|
||||
@Injectable()
|
||||
export class MatrixSessionService {
|
||||
private readonly logger = new Logger(MatrixSessionService.name);
|
||||
private readonly db;
|
||||
private readonly serviceKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly betterAuthService: BetterAuthService
|
||||
) {
|
||||
const databaseUrl = this.configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required');
|
||||
}
|
||||
this.db = getDb(databaseUrl);
|
||||
this.serviceKey = this.configService.get<string>('MANA_CORE_SERVICE_KEY', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate service key from X-Service-Key header
|
||||
*/
|
||||
validateServiceKey(providedKey: string): boolean {
|
||||
if (!this.serviceKey) {
|
||||
this.logger.warn('MANA_CORE_SERVICE_KEY not configured - service key validation disabled');
|
||||
return false;
|
||||
}
|
||||
return providedKey === this.serviceKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a link between a Matrix user ID and a Mana user
|
||||
*
|
||||
* @param matrixUserId - Matrix user ID (e.g., @user:matrix.mana.how)
|
||||
* @param manaUserId - Mana Core Auth user ID
|
||||
* @param email - User's email (optional, for convenience)
|
||||
*/
|
||||
async linkMatrixUser(matrixUserId: string, manaUserId: string, email?: string): Promise<void> {
|
||||
// Check if link already exists
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing link
|
||||
await this.db
|
||||
.update(matrixUserLinks)
|
||||
.set({
|
||||
userId: manaUserId,
|
||||
email,
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
|
||||
|
||||
this.logger.log(`Updated Matrix link: ${matrixUserId} -> ${manaUserId}`);
|
||||
} else {
|
||||
// Create new link
|
||||
await this.db.insert(matrixUserLinks).values({
|
||||
id: nanoid(),
|
||||
matrixUserId,
|
||||
userId: manaUserId,
|
||||
email,
|
||||
linkedAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.log(`Created Matrix link: ${matrixUserId} -> ${manaUserId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link for a Matrix user ID
|
||||
*/
|
||||
async unlinkMatrixUser(matrixUserId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.returning();
|
||||
|
||||
if (result.length > 0) {
|
||||
this.logger.log(`Removed Matrix link: ${matrixUserId}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a fresh JWT token for a linked Matrix user
|
||||
*
|
||||
* @param matrixUserId - Matrix user ID
|
||||
* @returns JWT token or null if no link exists
|
||||
*/
|
||||
async getSessionForMatrixUser(
|
||||
matrixUserId: string
|
||||
): Promise<{ token: string; email: string } | null> {
|
||||
// Find the link
|
||||
const links = await this.db
|
||||
.select({
|
||||
userId: matrixUserLinks.userId,
|
||||
email: matrixUserLinks.email,
|
||||
})
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.limit(1);
|
||||
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const link = links[0];
|
||||
|
||||
// Update last used timestamp
|
||||
await this.db
|
||||
.update(matrixUserLinks)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
|
||||
|
||||
// Get user details if email not stored
|
||||
let email = link.email;
|
||||
if (!email) {
|
||||
const userRecords = await this.db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.id, link.userId))
|
||||
.limit(1);
|
||||
|
||||
if (userRecords.length > 0) {
|
||||
email = userRecords[0].email;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a fresh JWT token for this user
|
||||
const token = await this.betterAuthService.generateTokenForUser(link.userId);
|
||||
|
||||
if (!token) {
|
||||
this.logger.error(`Failed to generate token for user ${link.userId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug(`Generated token for Matrix user ${matrixUserId}`);
|
||||
return { token, email: email || '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Matrix links for a Mana user
|
||||
*/
|
||||
async getLinksForUser(manaUserId: string): Promise<{ matrixUserId: string; linkedAt: Date }[]> {
|
||||
const links = await this.db
|
||||
.select({
|
||||
matrixUserId: matrixUserLinks.matrixUserId,
|
||||
linkedAt: matrixUserLinks.linkedAt,
|
||||
})
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.userId, manaUserId));
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Matrix user is linked
|
||||
*/
|
||||
async isLinked(matrixUserId: string): Promise<boolean> {
|
||||
const links = await this.db
|
||||
.select({ id: matrixUserLinks.id })
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.limit(1);
|
||||
|
||||
return links.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-link Matrix user during OIDC login
|
||||
*
|
||||
* Called when a user logs into Matrix via OIDC (Sign in with Mana Core).
|
||||
* Creates the Matrix user link automatically so bots can recognize them.
|
||||
*
|
||||
* @param manaUserId - Mana Core Auth user ID
|
||||
* @param email - User's email address
|
||||
* @param matrixDomain - Matrix homeserver domain (default: matrix.mana.how)
|
||||
*/
|
||||
async autoLinkOnOidcLogin(
|
||||
manaUserId: string,
|
||||
email: string,
|
||||
matrixDomain = 'matrix.mana.how'
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Calculate Matrix user ID from email using Synapse's template:
|
||||
// localpart_template: "{{ user.email.split('@')[0] }}"
|
||||
const localpart = email.split('@')[0].toLowerCase();
|
||||
const matrixUserId = `@${localpart}:${matrixDomain}`;
|
||||
|
||||
// Check if link already exists
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(matrixUserLinks)
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing link (in case user ID changed)
|
||||
if (existing[0].userId !== manaUserId) {
|
||||
await this.db
|
||||
.update(matrixUserLinks)
|
||||
.set({
|
||||
userId: manaUserId,
|
||||
email,
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
|
||||
this.logger.log(`Updated Matrix auto-link: ${matrixUserId} -> ${manaUserId}`);
|
||||
} else {
|
||||
// Just update lastUsedAt
|
||||
await this.db
|
||||
.update(matrixUserLinks)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new link
|
||||
await this.db.insert(matrixUserLinks).values({
|
||||
id: nanoid(),
|
||||
matrixUserId,
|
||||
userId: manaUserId,
|
||||
email,
|
||||
linkedAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.log(`Created Matrix auto-link on OIDC login: ${matrixUserId} -> ${manaUserId}`);
|
||||
} catch (error) {
|
||||
// Log but don't throw - this is a best-effort operation
|
||||
this.logger.error(
|
||||
'Failed to auto-link Matrix user on OIDC login',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,612 +0,0 @@
|
|||
/**
|
||||
* PasskeyService Unit Tests
|
||||
*
|
||||
* Tests WebAuthn passkey registration, authentication, and management.
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import { PasskeyService } from './passkey.service';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { LoggerService } from '../../common/logger';
|
||||
|
||||
jest.mock('@simplewebauthn/server', () => ({
|
||||
generateRegistrationOptions: jest.fn(),
|
||||
verifyRegistrationResponse: jest.fn(),
|
||||
generateAuthenticationOptions: jest.fn(),
|
||||
verifyAuthenticationResponse: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../db/connection', () => ({
|
||||
getDb: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid-id'),
|
||||
}));
|
||||
|
||||
const createMockDb = () => {
|
||||
let results: any[] = [];
|
||||
let resultIndex = 0;
|
||||
|
||||
const db: any = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
then: jest.fn((resolve) => resolve(results[resultIndex++] || [])),
|
||||
setResults: (...r: any[]) => {
|
||||
results = r;
|
||||
resultIndex = 0;
|
||||
},
|
||||
};
|
||||
return db;
|
||||
};
|
||||
|
||||
describe('PasskeyService', () => {
|
||||
let service: PasskeyService;
|
||||
let mockDb: ReturnType<typeof createMockDb>;
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string, defaultValue?: string) => {
|
||||
const config: Record<string, string> = {
|
||||
'database.url': 'postgresql://test:test@localhost:5432/test',
|
||||
WEBAUTHN_RP_ID: 'localhost',
|
||||
WEBAUTHN_ORIGINS: 'http://localhost:5173',
|
||||
};
|
||||
return config[key] || defaultValue || '';
|
||||
}),
|
||||
};
|
||||
|
||||
const mockLoggerService = {
|
||||
setContext: jest.fn().mockReturnThis(),
|
||||
log: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
|
||||
mockDb = createMockDb();
|
||||
(getDb as jest.Mock).mockReturnValue(mockDb);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PasskeyService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: LoggerService, useValue: mockLoggerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PasskeyService>(PasskeyService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// generateRegistrationOptions
|
||||
// ============================================================================
|
||||
|
||||
describe('generateRegistrationOptions', () => {
|
||||
it('should return options and challengeId for a valid user', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockOptions = {
|
||||
challenge: 'mock-challenge-string',
|
||||
rp: { name: 'ManaCore', id: 'localhost' },
|
||||
user: { id: 'user-123', name: 'test@example.com', displayName: 'Test User' },
|
||||
};
|
||||
|
||||
// First query: get user; Second query: get existing passkeys
|
||||
mockDb.setResults([mockUser], []);
|
||||
(generateRegistrationOptions as jest.Mock).mockResolvedValue(mockOptions);
|
||||
|
||||
const result = await service.generateRegistrationOptions('user-123');
|
||||
|
||||
expect(result.options).toEqual(mockOptions);
|
||||
expect(result.challengeId).toBe('mock-nanoid-id');
|
||||
expect(generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rpName: 'ManaCore',
|
||||
rpID: 'localhost',
|
||||
userName: 'test@example.com',
|
||||
userDisplayName: 'Test User',
|
||||
attestationType: 'none',
|
||||
excludeCredentials: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude existing passkeys', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const existingPasskeys = [
|
||||
{ credentialId: 'cred-1', transports: ['usb', 'ble'] },
|
||||
{ credentialId: 'cred-2', transports: ['internal'] },
|
||||
];
|
||||
|
||||
mockDb.setResults([mockUser], existingPasskeys);
|
||||
(generateRegistrationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'mock-challenge',
|
||||
});
|
||||
|
||||
await service.generateRegistrationOptions('user-123');
|
||||
|
||||
expect(generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
excludeCredentials: [
|
||||
{ id: 'cred-1', transports: ['usb', 'ble'] },
|
||||
{ id: 'cred-2', transports: ['internal'] },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent user', async () => {
|
||||
mockDb.setResults([]);
|
||||
|
||||
await expect(service.generateRegistrationOptions('nonexistent')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// verifyRegistration
|
||||
// ============================================================================
|
||||
|
||||
describe('verifyRegistration', () => {
|
||||
const mockCredential = {
|
||||
id: 'cred-id-123',
|
||||
rawId: 'raw-id',
|
||||
response: { attestationObject: 'obj', clientDataJSON: 'json' },
|
||||
type: 'public-key',
|
||||
};
|
||||
|
||||
it('should store passkey on successful verification', async () => {
|
||||
// First, generate a registration to store a challenge
|
||||
const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' };
|
||||
mockDb.setResults([mockUser], []);
|
||||
(generateRegistrationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'test-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateRegistrationOptions('user-123');
|
||||
|
||||
// Reset DB mock for verification calls
|
||||
const publicKeyBytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const mockVerification = {
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: 'new-cred-id',
|
||||
publicKey: publicKeyBytes,
|
||||
counter: 0,
|
||||
transports: ['internal'],
|
||||
},
|
||||
credentialDeviceType: 'multiPlatform',
|
||||
credentialBackedUp: true,
|
||||
},
|
||||
};
|
||||
|
||||
(verifyRegistrationResponse as jest.Mock).mockResolvedValue(mockVerification);
|
||||
|
||||
const newPasskey = {
|
||||
id: 'mock-nanoid-id',
|
||||
credentialId: 'new-cred-id',
|
||||
deviceType: 'multiPlatform',
|
||||
friendlyName: 'My Key',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// duplicate check (empty), insert returning
|
||||
mockDb.setResults([], [newPasskey]);
|
||||
|
||||
const result = await service.verifyRegistration(challengeId, mockCredential as any, 'My Key');
|
||||
|
||||
expect(result.id).toBe('mock-nanoid-id');
|
||||
expect(result.credentialId).toBe('new-cred-id');
|
||||
expect(result.deviceType).toBe('multiPlatform');
|
||||
expect(result.friendlyName).toBe('My Key');
|
||||
expect(verifyRegistrationResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedChallenge: 'test-challenge',
|
||||
expectedOrigin: ['http://localhost:5173'],
|
||||
expectedRPID: 'localhost',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for expired/invalid challenge', async () => {
|
||||
await expect(
|
||||
service.verifyRegistration('nonexistent-challenge', mockCredential as any)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when verification fails', async () => {
|
||||
// Store a challenge first
|
||||
const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' };
|
||||
mockDb.setResults([mockUser], []);
|
||||
(generateRegistrationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'test-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateRegistrationOptions('user-123');
|
||||
|
||||
(verifyRegistrationResponse as jest.Mock).mockResolvedValue({
|
||||
verified: false,
|
||||
registrationInfo: null,
|
||||
});
|
||||
|
||||
await expect(service.verifyRegistration(challengeId, mockCredential as any)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ConflictException for duplicate credentialId', async () => {
|
||||
// Store a challenge first
|
||||
const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' };
|
||||
mockDb.setResults([mockUser], []);
|
||||
(generateRegistrationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'test-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateRegistrationOptions('user-123');
|
||||
|
||||
const publicKeyBytes = new Uint8Array([1, 2, 3, 4]);
|
||||
(verifyRegistrationResponse as jest.Mock).mockResolvedValue({
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: 'existing-cred',
|
||||
publicKey: publicKeyBytes,
|
||||
counter: 0,
|
||||
transports: [],
|
||||
},
|
||||
credentialDeviceType: 'singleDevice',
|
||||
credentialBackedUp: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Duplicate check returns existing passkey
|
||||
mockDb.setResults([{ id: 'existing-pk', credentialId: 'existing-cred' }]);
|
||||
|
||||
await expect(service.verifyRegistration(challengeId, mockCredential as any)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// generateAuthenticationOptions
|
||||
// ============================================================================
|
||||
|
||||
describe('generateAuthenticationOptions', () => {
|
||||
it('should return options and challengeId (discoverable credentials)', async () => {
|
||||
const mockOptions = {
|
||||
challenge: 'auth-challenge',
|
||||
rpId: 'localhost',
|
||||
};
|
||||
|
||||
(generateAuthenticationOptions as jest.Mock).mockResolvedValue(mockOptions);
|
||||
|
||||
const result = await service.generateAuthenticationOptions();
|
||||
|
||||
expect(result.options).toEqual(mockOptions);
|
||||
expect(result.challengeId).toBe('mock-nanoid-id');
|
||||
expect(generateAuthenticationOptions).toHaveBeenCalledWith({
|
||||
rpID: 'localhost',
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// verifyAuthentication
|
||||
// ============================================================================
|
||||
|
||||
describe('verifyAuthentication', () => {
|
||||
const mockAuthCredential = {
|
||||
id: 'cred-id-123',
|
||||
rawId: 'raw-id',
|
||||
response: { authenticatorData: 'data', clientDataJSON: 'json', signature: 'sig' },
|
||||
type: 'public-key',
|
||||
};
|
||||
|
||||
it('should return user on successful authentication', async () => {
|
||||
// Store challenge
|
||||
(generateAuthenticationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'auth-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateAuthenticationOptions();
|
||||
|
||||
const mockPasskey = {
|
||||
id: 'pk-123',
|
||||
userId: 'user-123',
|
||||
credentialId: 'cred-id-123',
|
||||
publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'),
|
||||
counter: 5,
|
||||
transports: ['internal'],
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
(verifyAuthenticationResponse as jest.Mock).mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: { newCounter: 6 },
|
||||
});
|
||||
|
||||
// find passkey, update counter, get user
|
||||
mockDb.setResults([mockPasskey], [], [mockUser]);
|
||||
|
||||
const result = await service.verifyAuthentication(challengeId, mockAuthCredential as any);
|
||||
|
||||
expect(result.user).toEqual(mockUser);
|
||||
expect(result.passkeyId).toBe('pk-123');
|
||||
expect(verifyAuthenticationResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedChallenge: 'auth-challenge',
|
||||
expectedOrigin: ['http://localhost:5173'],
|
||||
expectedRPID: 'localhost',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should update counter and lastUsedAt', async () => {
|
||||
(generateAuthenticationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'auth-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateAuthenticationOptions();
|
||||
|
||||
const mockPasskey = {
|
||||
id: 'pk-123',
|
||||
userId: 'user-123',
|
||||
credentialId: 'cred-id-123',
|
||||
publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'),
|
||||
counter: 5,
|
||||
transports: [],
|
||||
};
|
||||
|
||||
const mockUser = { id: 'user-123', email: 'test@example.com', deletedAt: null };
|
||||
|
||||
(verifyAuthenticationResponse as jest.Mock).mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: { newCounter: 10 },
|
||||
});
|
||||
|
||||
mockDb.setResults([mockPasskey], [], [mockUser]);
|
||||
|
||||
await service.verifyAuthentication(challengeId, mockAuthCredential as any);
|
||||
|
||||
// Verify update was called (set is chained)
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
counter: 10,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for unknown credential', async () => {
|
||||
(generateAuthenticationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'auth-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateAuthenticationOptions();
|
||||
|
||||
// No passkey found
|
||||
mockDb.setResults([]);
|
||||
|
||||
await expect(
|
||||
service.verifyAuthentication(challengeId, mockAuthCredential as any)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for expired challenge', async () => {
|
||||
await expect(
|
||||
service.verifyAuthentication('invalid-challenge', mockAuthCredential as any)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for deleted user', async () => {
|
||||
(generateAuthenticationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'auth-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateAuthenticationOptions();
|
||||
|
||||
const mockPasskey = {
|
||||
id: 'pk-123',
|
||||
userId: 'user-123',
|
||||
credentialId: 'cred-id-123',
|
||||
publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'),
|
||||
counter: 5,
|
||||
transports: [],
|
||||
};
|
||||
|
||||
const deletedUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
deletedAt: new Date(),
|
||||
};
|
||||
|
||||
(verifyAuthenticationResponse as jest.Mock).mockResolvedValue({
|
||||
verified: true,
|
||||
authenticationInfo: { newCounter: 6 },
|
||||
});
|
||||
|
||||
mockDb.setResults([mockPasskey], [], [deletedUser]);
|
||||
|
||||
await expect(
|
||||
service.verifyAuthentication(challengeId, mockAuthCredential as any)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// listPasskeys
|
||||
// ============================================================================
|
||||
|
||||
describe('listPasskeys', () => {
|
||||
it('should return all passkeys for a user', async () => {
|
||||
const mockPasskeys = [
|
||||
{
|
||||
id: 'pk-1',
|
||||
credentialId: 'cred-1',
|
||||
deviceType: 'multiPlatform',
|
||||
backedUp: true,
|
||||
friendlyName: 'My Key',
|
||||
lastUsedAt: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'pk-2',
|
||||
credentialId: 'cred-2',
|
||||
deviceType: 'singleDevice',
|
||||
backedUp: false,
|
||||
friendlyName: null,
|
||||
lastUsedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockDb.setResults(mockPasskeys);
|
||||
|
||||
const result = await service.listPasskeys('user-123');
|
||||
|
||||
expect(result).toEqual(mockPasskeys);
|
||||
expect(mockDb.select).toHaveBeenCalled();
|
||||
expect(mockDb.from).toHaveBeenCalled();
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array for user with no passkeys', async () => {
|
||||
mockDb.setResults([]);
|
||||
|
||||
const result = await service.listPasskeys('user-no-passkeys');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// deletePasskey
|
||||
// ============================================================================
|
||||
|
||||
describe('deletePasskey', () => {
|
||||
it('should delete passkey owned by user', async () => {
|
||||
const mockPasskey = { id: 'pk-123', userId: 'user-123', credentialId: 'cred-1' };
|
||||
|
||||
// First call: find passkey, second call: delete
|
||||
mockDb.setResults([mockPasskey], []);
|
||||
|
||||
await service.deletePasskey('user-123', 'pk-123');
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent passkey', async () => {
|
||||
mockDb.setResults([]);
|
||||
|
||||
await expect(service.deletePasskey('user-123', 'nonexistent')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// renamePasskey
|
||||
// ============================================================================
|
||||
|
||||
describe('renamePasskey', () => {
|
||||
it('should update friendly name', async () => {
|
||||
const mockPasskey = { id: 'pk-123', userId: 'user-123', friendlyName: 'Old Name' };
|
||||
|
||||
mockDb.setResults([mockPasskey], []);
|
||||
|
||||
await service.renamePasskey('user-123', 'pk-123', 'New Name');
|
||||
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalledWith({ friendlyName: 'New Name' });
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent passkey', async () => {
|
||||
mockDb.setResults([]);
|
||||
|
||||
await expect(service.renamePasskey('user-123', 'nonexistent', 'Name')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Challenge management
|
||||
// ============================================================================
|
||||
|
||||
describe('Challenge management', () => {
|
||||
it('should clean up expired challenges (5-minute TTL)', async () => {
|
||||
// Generate a challenge
|
||||
(generateAuthenticationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'temp-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateAuthenticationOptions();
|
||||
|
||||
// Advance time past the 5-minute TTL
|
||||
jest.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
|
||||
// The challenge should now be expired
|
||||
mockDb.setResults([]);
|
||||
|
||||
await expect(
|
||||
service.verifyAuthentication(challengeId, { id: 'cred' } as any)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should consume challenge on use (one-time use)', async () => {
|
||||
(generateAuthenticationOptions as jest.Mock).mockResolvedValue({
|
||||
challenge: 'one-time-challenge',
|
||||
});
|
||||
const { challengeId } = await service.generateAuthenticationOptions();
|
||||
|
||||
// First use: passkey not found (throws different error), but challenge is consumed
|
||||
mockDb.setResults([]);
|
||||
|
||||
await expect(
|
||||
service.verifyAuthentication(challengeId, { id: 'cred' } as any)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
|
||||
// Second use: challenge already consumed
|
||||
await expect(
|
||||
service.verifyAuthentication(challengeId, { id: 'cred' } as any)
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type {
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
} from '@simplewebauthn/server';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { passkeys, users } from '../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { LoggerService } from '../../common/logger';
|
||||
|
||||
interface ChallengeEntry {
|
||||
challenge: string;
|
||||
userId?: string; // Only set for registration
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PasskeyService {
|
||||
private readonly logger: LoggerService;
|
||||
private readonly challenges = new Map<string, ChallengeEntry>();
|
||||
private readonly rpID: string;
|
||||
private readonly rpName = 'ManaCore';
|
||||
private readonly expectedOrigins: string[];
|
||||
private readonly databaseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('PasskeyService');
|
||||
this.databaseUrl = this.configService.get<string>('database.url', '');
|
||||
this.rpID = this.configService.get<string>('WEBAUTHN_RP_ID', 'localhost');
|
||||
|
||||
const originsStr = this.configService.get<string>('WEBAUTHN_ORIGINS', '');
|
||||
this.expectedOrigins = originsStr
|
||||
? originsStr.split(',').map((o) => o.trim())
|
||||
: ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3001'];
|
||||
|
||||
// Clean up expired challenges every 5 minutes
|
||||
setInterval(() => this.cleanupChallenges(), 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
private getDb() {
|
||||
return getDb(this.databaseUrl);
|
||||
}
|
||||
|
||||
private cleanupChallenges() {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.challenges) {
|
||||
if (entry.expiresAt < now) {
|
||||
this.challenges.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private storeChallenge(challengeId: string, challenge: string, userId?: string) {
|
||||
this.challenges.set(challengeId, {
|
||||
challenge,
|
||||
userId,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
private getAndDeleteChallenge(challengeId: string): ChallengeEntry | null {
|
||||
const entry = this.challenges.get(challengeId);
|
||||
if (!entry) return null;
|
||||
this.challenges.delete(challengeId);
|
||||
if (entry.expiresAt < Date.now()) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate registration options for a logged-in user
|
||||
*/
|
||||
async generateRegistrationOptions(userId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
// Get existing passkeys to exclude
|
||||
const existingPasskeys = await db.select().from(passkeys).where(eq(passkeys.userId, userId));
|
||||
|
||||
const excludeCredentials = existingPasskeys.map((pk) => ({
|
||||
id: pk.credentialId,
|
||||
transports: (pk.transports as AuthenticatorTransportFuture[]) || [],
|
||||
}));
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: this.rpName,
|
||||
rpID: this.rpID,
|
||||
userName: user.email,
|
||||
userDisplayName: user.name || user.email,
|
||||
attestationType: 'none',
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
|
||||
// Store challenge
|
||||
const challengeId = nanoid();
|
||||
this.storeChallenge(challengeId, options.challenge, userId);
|
||||
|
||||
return { options, challengeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify registration response and store the new passkey
|
||||
*/
|
||||
async verifyRegistration(
|
||||
challengeId: string,
|
||||
credential: RegistrationResponseJSON,
|
||||
friendlyName?: string
|
||||
) {
|
||||
const entry = this.getAndDeleteChallenge(challengeId);
|
||||
if (!entry || !entry.userId) {
|
||||
throw new BadRequestException('Invalid or expired challenge');
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: credential,
|
||||
expectedChallenge: entry.challenge,
|
||||
expectedOrigin: this.expectedOrigins,
|
||||
expectedRPID: this.rpID,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new BadRequestException('Passkey verification failed');
|
||||
}
|
||||
|
||||
const {
|
||||
credential: cred,
|
||||
credentialDeviceType,
|
||||
credentialBackedUp,
|
||||
} = verification.registrationInfo;
|
||||
|
||||
const db = this.getDb();
|
||||
|
||||
// Check for duplicate
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.credentialId, cred.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('This passkey is already registered');
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
const [newPasskey] = await db
|
||||
.insert(passkeys)
|
||||
.values({
|
||||
id,
|
||||
userId: entry.userId,
|
||||
credentialId: cred.id,
|
||||
publicKey: Buffer.from(cred.publicKey).toString('base64url'),
|
||||
counter: cred.counter,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
transports: cred.transports || [],
|
||||
friendlyName: friendlyName || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Passkey registered for user ${entry.userId}: ${id}`);
|
||||
|
||||
return {
|
||||
id: newPasskey.id,
|
||||
credentialId: newPasskey.credentialId,
|
||||
deviceType: newPasskey.deviceType,
|
||||
friendlyName: newPasskey.friendlyName,
|
||||
createdAt: newPasskey.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authentication options (public - no auth required)
|
||||
*/
|
||||
async generateAuthenticationOptions() {
|
||||
// Use discoverable credentials (resident keys) - no allowCredentials needed
|
||||
// The browser will show all available passkeys for this rpID
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: this.rpID,
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
const challengeId = nanoid();
|
||||
this.storeChallenge(challengeId, options.challenge);
|
||||
|
||||
return { options, challengeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify authentication response and return the user
|
||||
*/
|
||||
async verifyAuthentication(challengeId: string, credential: AuthenticationResponseJSON) {
|
||||
const entry = this.getAndDeleteChallenge(challengeId);
|
||||
if (!entry) {
|
||||
throw new BadRequestException('Invalid or expired challenge');
|
||||
}
|
||||
|
||||
const db = this.getDb();
|
||||
|
||||
// Find the passkey by credential ID
|
||||
const [passkey] = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.credentialId, credential.id))
|
||||
.limit(1);
|
||||
|
||||
if (!passkey) {
|
||||
throw new BadRequestException('Passkey not found');
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credential,
|
||||
expectedChallenge: entry.challenge,
|
||||
expectedOrigin: this.expectedOrigins,
|
||||
expectedRPID: this.rpID,
|
||||
credential: {
|
||||
id: passkey.credentialId,
|
||||
publicKey: Buffer.from(passkey.publicKey, 'base64url'),
|
||||
counter: passkey.counter,
|
||||
transports: (passkey.transports as AuthenticatorTransportFuture[]) || [],
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException('Passkey authentication failed');
|
||||
}
|
||||
|
||||
// Update counter and lastUsedAt
|
||||
await db
|
||||
.update(passkeys)
|
||||
.set({
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where(eq(passkeys.id, passkey.id));
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, passkey.userId)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
if (user.deletedAt) {
|
||||
throw new BadRequestException('Account has been deleted');
|
||||
}
|
||||
|
||||
return { user, passkeyId: passkey.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all passkeys for a user
|
||||
*/
|
||||
async listPasskeys(userId: string) {
|
||||
const db = this.getDb();
|
||||
const userPasskeys = await db
|
||||
.select({
|
||||
id: passkeys.id,
|
||||
credentialId: passkeys.credentialId,
|
||||
deviceType: passkeys.deviceType,
|
||||
backedUp: passkeys.backedUp,
|
||||
friendlyName: passkeys.friendlyName,
|
||||
lastUsedAt: passkeys.lastUsedAt,
|
||||
createdAt: passkeys.createdAt,
|
||||
})
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, userId));
|
||||
|
||||
return userPasskeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a passkey
|
||||
*/
|
||||
async deletePasskey(userId: string, passkeyId: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
const [passkey] = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!passkey) {
|
||||
throw new NotFoundException('Passkey not found');
|
||||
}
|
||||
|
||||
await db.delete(passkeys).where(eq(passkeys.id, passkeyId));
|
||||
|
||||
this.logger.log(`Passkey deleted: ${passkeyId} for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a passkey
|
||||
*/
|
||||
async renamePasskey(userId: string, passkeyId: string, friendlyName: string) {
|
||||
const db = this.getDb();
|
||||
|
||||
const [passkey] = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!passkey) {
|
||||
throw new NotFoundException('Passkey not found');
|
||||
}
|
||||
|
||||
await db.update(passkeys).set({ friendlyName }).where(eq(passkeys.id, passkeyId));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
/**
|
||||
* SSO Configuration Tests
|
||||
*
|
||||
* Validates that the Better Auth configuration correctly supports
|
||||
* cross-subdomain SSO for all apps in the monorepo.
|
||||
*
|
||||
* These tests ensure that:
|
||||
* 1. All active apps are listed in trustedOrigins
|
||||
* 2. Cookie domain configuration is correct for SSO
|
||||
* 3. CORS_ORIGINS in docker-compose matches trustedOrigins
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('SSO Configuration', () => {
|
||||
const configPath = path.resolve(__dirname, 'better-auth.config.ts');
|
||||
let configContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
configContent = fs.readFileSync(configPath, 'utf8');
|
||||
});
|
||||
|
||||
describe('trustedOrigins', () => {
|
||||
/**
|
||||
* All apps that have a web frontend with SSO support.
|
||||
* When adding a new app, add it here AND to trustedOrigins in better-auth.config.ts.
|
||||
*/
|
||||
const APPS_WITH_SSO = [
|
||||
'calendar',
|
||||
'chat',
|
||||
'clock',
|
||||
'contacts',
|
||||
'context',
|
||||
'manadeck',
|
||||
'matrix',
|
||||
'mukke',
|
||||
'nutriphi',
|
||||
'photos',
|
||||
'picture',
|
||||
'planta',
|
||||
'presi',
|
||||
'questions',
|
||||
'skilltree',
|
||||
'storage',
|
||||
'todo',
|
||||
'traces',
|
||||
'zitare',
|
||||
];
|
||||
|
||||
it.each(APPS_WITH_SSO)('should include %s.mana.how in trustedOrigins', (appName) => {
|
||||
expect(configContent).toContain(`https://${appName}.mana.how`);
|
||||
});
|
||||
|
||||
it('should include the auth service itself', () => {
|
||||
expect(configContent).toContain('https://auth.mana.how');
|
||||
});
|
||||
|
||||
it('should include the main domain', () => {
|
||||
expect(configContent).toContain('https://mana.how');
|
||||
});
|
||||
|
||||
it('should include localhost for development', () => {
|
||||
expect(configContent).toContain('http://localhost:5173');
|
||||
expect(configContent).toContain('http://localhost:3001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookie configuration', () => {
|
||||
it('should use "mana" cookie prefix', () => {
|
||||
expect(configContent).toContain("cookiePrefix: 'mana'");
|
||||
});
|
||||
|
||||
it('should enable crossSubDomainCookies based on COOKIE_DOMAIN env', () => {
|
||||
expect(configContent).toContain('enabled: !!process.env.COOKIE_DOMAIN');
|
||||
});
|
||||
|
||||
it('should use COOKIE_DOMAIN for the cookie domain', () => {
|
||||
expect(configContent).toContain('domain: process.env.COOKIE_DOMAIN');
|
||||
});
|
||||
|
||||
it('should use sameSite lax for cross-subdomain navigation', () => {
|
||||
expect(configContent).toContain("sameSite: 'lax'");
|
||||
});
|
||||
|
||||
it('should set httpOnly to protect cookies from JS access', () => {
|
||||
expect(configContent).toContain('httpOnly: true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docker-compose alignment', () => {
|
||||
const dockerComposePath = path.resolve(__dirname, '../../../../docker-compose.macmini.yml');
|
||||
|
||||
it('should have COOKIE_DOMAIN set to .mana.how in production docker-compose', () => {
|
||||
if (!fs.existsSync(dockerComposePath)) {
|
||||
// Skip if docker-compose not available (e.g., in CI)
|
||||
return;
|
||||
}
|
||||
const dockerContent = fs.readFileSync(dockerComposePath, 'utf8');
|
||||
expect(dockerContent).toContain('COOKIE_DOMAIN: .mana.how');
|
||||
});
|
||||
|
||||
it('should have CORS_ORIGINS in docker-compose for mana-auth', () => {
|
||||
if (!fs.existsSync(dockerComposePath)) {
|
||||
return;
|
||||
}
|
||||
const dockerContent = fs.readFileSync(dockerComposePath, 'utf8');
|
||||
// All SSO apps should be in the CORS_ORIGINS
|
||||
const appsToCheck = [
|
||||
'calendar',
|
||||
'chat',
|
||||
'clock',
|
||||
'contacts',
|
||||
'mukke',
|
||||
'nutriphi',
|
||||
'photos',
|
||||
'picture',
|
||||
'planta',
|
||||
'presi',
|
||||
'questions',
|
||||
'skilltree',
|
||||
'storage',
|
||||
'todo',
|
||||
'zitare',
|
||||
];
|
||||
|
||||
for (const app of appsToCheck) {
|
||||
expect(dockerContent).toContain(`https://${app}.mana.how`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionToToken cookie detection', () => {
|
||||
it('should look for mana-prefixed cookies when COOKIE_DOMAIN is set', () => {
|
||||
const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts');
|
||||
const serviceContent = fs.readFileSync(servicePath, 'utf8');
|
||||
|
||||
// Verify cookie name detection logic
|
||||
expect(serviceContent).toContain(
|
||||
"const cookiePrefix = process.env.COOKIE_DOMAIN ? 'mana' : 'better-auth'"
|
||||
);
|
||||
expect(serviceContent).toContain('__Secure-${cookiePrefix}.session_token');
|
||||
expect(serviceContent).toContain('${cookiePrefix}.session_token');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
/**
|
||||
* SSO sessionToToken Contract Tests
|
||||
*
|
||||
* Validates the session-to-token exchange logic that powers cross-app SSO.
|
||||
* Tests cookie name detection, which is the critical piece that must match
|
||||
* between the client (trySSO) and server (sessionToToken).
|
||||
*
|
||||
* Flow:
|
||||
* 1. User logs in on app A → session cookie set with Domain=.mana.how
|
||||
* 2. User visits app B → browser sends the session cookie
|
||||
* 3. App B calls GET /api/auth/get-session (credentials: include)
|
||||
* 4. App B calls POST /api/v1/auth/session-to-token → gets JWT tokens
|
||||
* 5. JWT tokens stored in localStorage → user is authenticated
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('SSO sessionToToken contract', () => {
|
||||
const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts');
|
||||
const authServiceClientPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../../packages/shared-auth/src/core/authService.ts'
|
||||
);
|
||||
let serviceContent: string;
|
||||
let clientContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
serviceContent = fs.readFileSync(servicePath, 'utf8');
|
||||
clientContent = fs.readFileSync(authServiceClientPath, 'utf8');
|
||||
});
|
||||
|
||||
describe('cookie name detection (server side)', () => {
|
||||
it('should use "mana" prefix when COOKIE_DOMAIN is set', () => {
|
||||
// The server determines the cookie name based on COOKIE_DOMAIN
|
||||
expect(serviceContent).toContain(
|
||||
"const cookiePrefix = process.env.COOKIE_DOMAIN ? 'mana' : 'better-auth'"
|
||||
);
|
||||
});
|
||||
|
||||
it('should check both __Secure- and non-secure cookie names', () => {
|
||||
expect(serviceContent).toContain('__Secure-${cookiePrefix}.session_token');
|
||||
expect(serviceContent).toContain('${cookiePrefix}.session_token');
|
||||
});
|
||||
|
||||
it('should try the secure cookie first, then fallback', () => {
|
||||
// The order matters: __Secure- prefix is used in production (HTTPS)
|
||||
expect(serviceContent).toContain(
|
||||
'req.cookies?.[sessionCookieName] || req.cookies?.[fallbackCookieName]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('client-server contract alignment', () => {
|
||||
it('client should call get-session with credentials: include', () => {
|
||||
expect(clientContent).toContain("credentials: 'include'");
|
||||
expect(clientContent).toContain("method: 'GET'");
|
||||
// The get-session endpoint
|
||||
expect(clientContent).toContain('endpoints.getSession');
|
||||
});
|
||||
|
||||
it('client should call session-to-token with credentials: include', () => {
|
||||
expect(clientContent).toContain('/api/v1/auth/session-to-token');
|
||||
// Check that the session-to-token call uses credentials: include
|
||||
const tokenFetchMatch = clientContent.match(/session-to-token.*?credentials:\s*'include'/s);
|
||||
expect(tokenFetchMatch).not.toBeNull();
|
||||
});
|
||||
|
||||
it('server should expose session-to-token endpoint', () => {
|
||||
const controllerPath = path.resolve(__dirname, 'auth.controller.ts');
|
||||
const controllerContent = fs.readFileSync(controllerPath, 'utf8');
|
||||
expect(controllerContent).toContain('sessionToToken');
|
||||
});
|
||||
|
||||
it('client should store accessToken and refreshToken from response', () => {
|
||||
expect(clientContent).toContain('tokenData.accessToken');
|
||||
expect(clientContent).toContain('tokenData.refreshToken');
|
||||
});
|
||||
|
||||
it('server should return accessToken and refreshToken', () => {
|
||||
// The service method should return an object with these fields
|
||||
expect(serviceContent).toContain('accessToken');
|
||||
expect(serviceContent).toContain('refreshToken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO error handling', () => {
|
||||
it('client should handle get-session failure gracefully', () => {
|
||||
expect(clientContent).toContain('No SSO session found');
|
||||
});
|
||||
|
||||
it('client should handle token exchange failure gracefully', () => {
|
||||
expect(clientContent).toContain('Token exchange not available');
|
||||
});
|
||||
|
||||
it('client should handle missing tokens in response', () => {
|
||||
expect(clientContent).toContain('Invalid token response');
|
||||
});
|
||||
|
||||
it('client should catch and return network errors', () => {
|
||||
// trySSO should not throw - it returns { success: false, error: ... }
|
||||
expect(clientContent).toContain('SSO check failed');
|
||||
});
|
||||
|
||||
it('server should throw UnauthorizedException when no cookie found', () => {
|
||||
expect(serviceContent).toContain('No session cookie found');
|
||||
expect(serviceContent).toContain('UnauthorizedException');
|
||||
});
|
||||
|
||||
it('server should throw UnauthorizedException for invalid sessions', () => {
|
||||
expect(serviceContent).toContain('Invalid or expired session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('main.ts route configuration', () => {
|
||||
it('should exclude get-session from global API prefix', () => {
|
||||
const mainPath = path.resolve(__dirname, '../main.ts');
|
||||
const mainContent = fs.readFileSync(mainPath, 'utf8');
|
||||
// get-session must be excluded from the /api/v1 prefix because
|
||||
// Better Auth serves it at /api/auth/get-session (not /api/v1/api/auth/get-session)
|
||||
expect(mainContent).toContain('api/auth/get-session');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO cookie configuration alignment', () => {
|
||||
it('cookie prefix in config should match cookie detection in sessionToToken', () => {
|
||||
const configPath = path.resolve(__dirname, 'better-auth.config.ts');
|
||||
const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts');
|
||||
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
const serviceContent = fs.readFileSync(servicePath, 'utf8');
|
||||
|
||||
// Config sets cookiePrefix to 'mana'
|
||||
expect(configContent).toContain("cookiePrefix: 'mana'");
|
||||
|
||||
// sessionToToken uses 'mana' when COOKIE_DOMAIN is set
|
||||
// This must match! If config uses 'mana' but detection uses something else, SSO breaks.
|
||||
expect(serviceContent).toContain("process.env.COOKIE_DOMAIN ? 'mana'");
|
||||
});
|
||||
|
||||
it('.env.example should document COOKIE_DOMAIN', () => {
|
||||
const envExamplePath = path.resolve(__dirname, '../../.env.example');
|
||||
const envContent = fs.readFileSync(envExamplePath, 'utf8');
|
||||
expect(envContent).toContain('COOKIE_DOMAIN');
|
||||
expect(envContent).toContain('.mana.how');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
/**
|
||||
* Password Reset Redirect Store
|
||||
*
|
||||
* Temporary in-memory store for tracking which app a user requested
|
||||
* password reset from. This allows redirecting users back to the correct
|
||||
* app's reset-password page after clicking the email link.
|
||||
*
|
||||
* TTL: 1 hour (password reset tokens are short-lived)
|
||||
*/
|
||||
|
||||
interface ResetRedirectEntry {
|
||||
redirectUrl: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// In-memory store: email -> { redirectUrl, expiresAt }
|
||||
const store = new Map<string, ResetRedirectEntry>();
|
||||
|
||||
// TTL in milliseconds (1 hour)
|
||||
const TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
// Cleanup interval (every 15 minutes)
|
||||
const CLEANUP_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
// Start cleanup interval
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [email, entry] of store.entries()) {
|
||||
if (entry.expiresAt < now) {
|
||||
store.delete(email);
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
export const passwordResetRedirectStore = {
|
||||
/**
|
||||
* Store the redirect URL for a password reset request
|
||||
*/
|
||||
set(email: string, redirectUrl: string): void {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
store.set(normalizedEmail, {
|
||||
redirectUrl,
|
||||
expiresAt: Date.now() + TTL_MS,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the redirect URL for an email
|
||||
* Returns null if not found or expired
|
||||
*/
|
||||
get(email: string): string | null {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const entry = store.get(normalizedEmail);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
store.delete(normalizedEmail);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.redirectUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get and remove the redirect URL for an email
|
||||
* This is used after the user clicks the link to prevent re-use
|
||||
*/
|
||||
getAndDelete(email: string): string | null {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const entry = store.get(normalizedEmail);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
store.delete(normalizedEmail);
|
||||
|
||||
// Check if expired
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.redirectUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all entries (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
/**
|
||||
* Source App Store
|
||||
*
|
||||
* Temporary in-memory store for tracking which app a user registered from.
|
||||
* This allows redirecting users back to the correct app's login page
|
||||
* after email verification.
|
||||
*
|
||||
* TTL: 24 hours (matches verification token expiry)
|
||||
*/
|
||||
|
||||
interface SourceAppEntry {
|
||||
sourceAppUrl: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// In-memory store: email -> { sourceAppUrl, expiresAt }
|
||||
const store = new Map<string, SourceAppEntry>();
|
||||
|
||||
// TTL in milliseconds (24 hours)
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Cleanup interval (every hour)
|
||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
// Start cleanup interval
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [email, entry] of store.entries()) {
|
||||
if (entry.expiresAt < now) {
|
||||
store.delete(email);
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
export const sourceAppStore = {
|
||||
/**
|
||||
* Store the source app URL for an email
|
||||
*/
|
||||
set(email: string, sourceAppUrl: string): void {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
store.set(normalizedEmail, {
|
||||
sourceAppUrl,
|
||||
expiresAt: Date.now() + TTL_MS,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the source app URL for an email
|
||||
* Returns null if not found or expired
|
||||
*/
|
||||
get(email: string): string | null {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const entry = store.get(normalizedEmail);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
store.delete(normalizedEmail);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.sourceAppUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get and remove the source app URL for an email
|
||||
* This is used after verification to prevent re-use
|
||||
*/
|
||||
getAndDelete(email: string): string | null {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const entry = store.get(normalizedEmail);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
store.delete(normalizedEmail);
|
||||
|
||||
// Check if expired
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.sourceAppUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove entry for an email
|
||||
*/
|
||||
delete(email: string): void {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
store.delete(normalizedEmail);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all entries (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
|
|
@ -1,602 +0,0 @@
|
|||
/**
|
||||
* Better Auth Type Definitions
|
||||
*
|
||||
* This file provides types for Better Auth integration.
|
||||
*
|
||||
* STRATEGY: Import base types from Better Auth packages, extend only when needed.
|
||||
*
|
||||
* From 'better-auth/types':
|
||||
* - User, Session, Account, Auth, BetterAuthOptions, etc.
|
||||
*
|
||||
* From 'better-auth/plugins/organization':
|
||||
* - Organization, Member, Invitation, OrganizationRole, InvitationStatus
|
||||
*
|
||||
* This file defines:
|
||||
* 1. Extended types (adding fields Better Auth doesn't have)
|
||||
* 2. API response/request types for our service layer
|
||||
* 3. Service-specific DTOs and result types
|
||||
* 4. Type guards for runtime safety
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/concepts/typescript
|
||||
* @see https://www.better-auth.com/docs/plugins/organization
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Import core types from Better Auth packages
|
||||
// =============================================================================
|
||||
import type { User, Session } from 'better-auth/types';
|
||||
import type {
|
||||
Organization as BetterAuthOrganization,
|
||||
Member as BetterAuthMember,
|
||||
Invitation as BetterAuthInvitation,
|
||||
OrganizationRole as BetterAuthOrganizationRole,
|
||||
InvitationStatus as BetterAuthInvitationStatus,
|
||||
} from 'better-auth/plugins/organization';
|
||||
|
||||
// Re-export base types for convenience
|
||||
export type { User, Session };
|
||||
export type {
|
||||
BetterAuthOrganization,
|
||||
BetterAuthMember,
|
||||
BetterAuthInvitation,
|
||||
BetterAuthOrganizationRole,
|
||||
BetterAuthInvitationStatus,
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended User type with our additional fields
|
||||
* Better Auth's User type is the base, we extend it for our app
|
||||
*/
|
||||
export interface BetterAuthUser extends User {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended Session type with organization support
|
||||
* Better Auth's Session type is the base, organization plugin adds activeOrganizationId
|
||||
*/
|
||||
export interface BetterAuthSession extends Session {
|
||||
activeOrganizationId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Payload context passed to definePayload
|
||||
*/
|
||||
export interface JWTPayloadContext {
|
||||
user: BetterAuthUser;
|
||||
session: BetterAuthSession;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Types (aligned with Better Auth but with explicit fields)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Organization entity - mirrors Better Auth's Organization type
|
||||
* We define explicitly to ensure type safety in our service layer
|
||||
*/
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization member - mirrors Better Auth's Member type
|
||||
*/
|
||||
export interface OrganizationMember {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
role: OrganizationRole;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization role types - aligned with Better Auth defaults
|
||||
*/
|
||||
export type OrganizationRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
/**
|
||||
* Organization invitation - mirrors Better Auth's Invitation type
|
||||
*/
|
||||
export interface OrganizationInvitation {
|
||||
id: string;
|
||||
email: string;
|
||||
organizationId: string;
|
||||
role: OrganizationRole;
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'expired';
|
||||
inviterId: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Response Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Sign up response from Better Auth
|
||||
*/
|
||||
export interface SignUpResponse {
|
||||
user: BetterAuthUser;
|
||||
token?: string;
|
||||
session?: BetterAuthSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in response from Better Auth
|
||||
*/
|
||||
export interface SignInResponse {
|
||||
user: BetterAuthUser;
|
||||
token: string;
|
||||
session: BetterAuthSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization response
|
||||
*/
|
||||
export interface CreateOrganizationResponse extends Organization {
|
||||
// Organization fields are returned directly
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite member response
|
||||
*/
|
||||
export interface InviteMemberResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
organizationId: string;
|
||||
role: OrganizationRole;
|
||||
status: 'pending';
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept invitation response
|
||||
*/
|
||||
export interface AcceptInvitationResponse {
|
||||
member: OrganizationMember;
|
||||
organization: Organization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full organization response
|
||||
*/
|
||||
export interface GetFullOrganizationResponse extends Organization {
|
||||
members: Array<OrganizationMember & { user?: BetterAuthUser }>;
|
||||
invitations?: OrganizationInvitation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active organization response
|
||||
*/
|
||||
export interface SetActiveOrganizationResponse {
|
||||
userId: string;
|
||||
activeOrganizationId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
session?: BetterAuthSession;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Request Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Sign up request body
|
||||
*/
|
||||
export interface SignUpEmailBody {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization request body
|
||||
*/
|
||||
export interface CreateOrganizationBody {
|
||||
name: string;
|
||||
slug: string;
|
||||
logo?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite member request body
|
||||
*/
|
||||
export interface InviteMemberBody {
|
||||
email: string;
|
||||
role: OrganizationRole;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept invitation request body
|
||||
*/
|
||||
export interface AcceptInvitationBody {
|
||||
invitationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member request body
|
||||
*/
|
||||
export interface RemoveMemberBody {
|
||||
memberIdOrEmail: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active organization request body
|
||||
*/
|
||||
export interface SetActiveOrganizationBody {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full organization query
|
||||
*/
|
||||
export interface GetFullOrganizationQuery {
|
||||
organizationId?: string;
|
||||
organizationSlug?: string;
|
||||
membersLimit?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Method Types (with headers)
|
||||
// =============================================================================
|
||||
|
||||
export interface AuthenticatedRequest<TBody = unknown, TQuery = unknown> {
|
||||
body?: TBody;
|
||||
query?: TQuery;
|
||||
headers: {
|
||||
authorization: string;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Better Auth API Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Typed Better Auth API interface
|
||||
*
|
||||
* This interface describes the methods available on auth.api
|
||||
* when using the organization plugin.
|
||||
*/
|
||||
export interface BetterAuthAPI {
|
||||
// Core auth methods
|
||||
signUpEmail(params: { body: SignUpEmailBody }): Promise<SignUpResponse>;
|
||||
signInEmail(params: { body: { email: string; password: string } }): Promise<SignInResponse>;
|
||||
|
||||
// Organization methods
|
||||
createOrganization(
|
||||
params: AuthenticatedRequest<CreateOrganizationBody>
|
||||
): Promise<CreateOrganizationResponse>;
|
||||
|
||||
inviteMember(params: AuthenticatedRequest<InviteMemberBody>): Promise<InviteMemberResponse>;
|
||||
|
||||
acceptInvitation(
|
||||
params: AuthenticatedRequest<AcceptInvitationBody>
|
||||
): Promise<AcceptInvitationResponse>;
|
||||
|
||||
getFullOrganization(params: {
|
||||
query: GetFullOrganizationQuery;
|
||||
}): Promise<GetFullOrganizationResponse>;
|
||||
|
||||
removeMember(params: AuthenticatedRequest<RemoveMemberBody>): Promise<{ success: boolean }>;
|
||||
|
||||
setActiveOrganization(
|
||||
params: AuthenticatedRequest<SetActiveOrganizationBody>
|
||||
): Promise<SetActiveOrganizationResponse>;
|
||||
|
||||
listOrganizations(params: AuthenticatedRequest): Promise<Organization[]>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Service Response Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* B2C Registration result
|
||||
*/
|
||||
export interface RegisterB2CResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* B2B Registration result
|
||||
*/
|
||||
export interface RegisterB2BResult {
|
||||
user: BetterAuthUser;
|
||||
organization: Organization;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite employee result
|
||||
*/
|
||||
export interface InviteEmployeeResult {
|
||||
id: string;
|
||||
email: string;
|
||||
organizationId: string;
|
||||
role: OrganizationRole;
|
||||
status: 'pending';
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept invitation result
|
||||
*/
|
||||
export interface AcceptInvitationResult {
|
||||
member: OrganizationMember;
|
||||
organization?: Organization;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member result
|
||||
*/
|
||||
export interface RemoveMemberResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active organization result
|
||||
* Returns session data with the active organization ID
|
||||
*/
|
||||
export interface SetActiveOrganizationResult {
|
||||
userId: string;
|
||||
activeOrganizationId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
session?: BetterAuthSession;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DTO Types (for NestJS controllers)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* DTO for B2C user registration
|
||||
*/
|
||||
export interface RegisterB2CDto {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
sourceAppId?: string;
|
||||
sourceAppUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for B2B organization registration
|
||||
*/
|
||||
export interface RegisterB2BDto {
|
||||
ownerEmail: string;
|
||||
password: string;
|
||||
ownerName: string;
|
||||
organizationName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for employee invitation
|
||||
*/
|
||||
export interface InviteEmployeeDto {
|
||||
organizationId: string;
|
||||
employeeEmail: string;
|
||||
role: 'admin' | 'member';
|
||||
inviterToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for accepting invitation
|
||||
*/
|
||||
export interface AcceptInvitationDto {
|
||||
invitationId: string;
|
||||
userToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for removing organization member
|
||||
*/
|
||||
export interface RemoveMemberDto {
|
||||
organizationId: string;
|
||||
memberId: string;
|
||||
removerToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for setting active organization
|
||||
*/
|
||||
export interface SetActiveOrganizationDto {
|
||||
organizationId: string;
|
||||
userToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for user sign in
|
||||
*/
|
||||
export interface SignInDto {
|
||||
email: string;
|
||||
password: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in result
|
||||
*/
|
||||
export interface SignInResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role?: string;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for sign out
|
||||
*/
|
||||
export interface SignOutDto {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out result
|
||||
*/
|
||||
export interface SignOutResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session result
|
||||
*/
|
||||
export interface GetSessionResult {
|
||||
user: BetterAuthUser;
|
||||
session: BetterAuthSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* List user organizations result
|
||||
*/
|
||||
export interface ListOrganizationsResult {
|
||||
organizations: Organization[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for refresh token
|
||||
*/
|
||||
export interface RefreshTokenDto {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token result
|
||||
*/
|
||||
export interface RefreshTokenResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role?: string;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for token validation
|
||||
*/
|
||||
export interface ValidateTokenDto {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token payload structure (JWT claims)
|
||||
*/
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId: string;
|
||||
deviceId?: string;
|
||||
organizationId?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
aud?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token result
|
||||
*/
|
||||
export interface ValidateTokenResult {
|
||||
valid: boolean;
|
||||
payload?: TokenPayload;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type Guards
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type guard to check if response has user property
|
||||
*/
|
||||
export function hasUser(response: unknown): response is { user: BetterAuthUser } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'user' in response &&
|
||||
typeof (response as { user: unknown }).user === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has token property
|
||||
*/
|
||||
export function hasToken(response: unknown): response is { token: string } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'token' in response &&
|
||||
typeof (response as { token: unknown }).token === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has member property
|
||||
*/
|
||||
export function hasMember(response: unknown): response is { member: OrganizationMember } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'member' in response &&
|
||||
typeof (response as { member: unknown }).member === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has members array
|
||||
*/
|
||||
export function hasMembers(response: unknown): response is { members: OrganizationMember[] } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'members' in response &&
|
||||
Array.isArray((response as { members: unknown }).members)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response has session property
|
||||
*/
|
||||
export function hasSession(
|
||||
response: unknown
|
||||
): response is { user: BetterAuthUser; session: BetterAuthSession } {
|
||||
return (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'user' in response &&
|
||||
'session' in response &&
|
||||
typeof (response as { user: unknown }).user === 'object' &&
|
||||
typeof (response as { session: unknown }).session === 'object'
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* Auth Types Index
|
||||
*
|
||||
* Re-exports all authentication-related types
|
||||
*/
|
||||
|
||||
export * from './better-auth.types';
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { createParamDecorator } from '@nestjs/common';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserData {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
}
|
||||
);
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let errors: any = undefined;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
message = (exceptionResponse as any).message || message;
|
||||
errors = (exceptionResponse as any).errors;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
const errorResponse = {
|
||||
statusCode: status,
|
||||
message,
|
||||
...(errors && { errors }),
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
/**
|
||||
* JwtAuthGuard Unit Tests
|
||||
*
|
||||
* Tests JWT authentication guard functionality:
|
||||
* - Token extraction from Authorization header
|
||||
* - JWT verification using locally cached JWKS (EdDSA keys)
|
||||
* - Error handling for invalid/expired tokens
|
||||
* - User attachment to request object
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
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';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { createCachedLocalJWKSet } from './local-jwks-cache';
|
||||
|
||||
// Mock jose (auto-mocked via jest.config.js moduleNameMapper)
|
||||
jest.mock('jose');
|
||||
|
||||
// Mock the local JWKS cache
|
||||
jest.mock('./local-jwks-cache');
|
||||
|
||||
// Setup mock for createCachedLocalJWKSet to return a defined JWKS function
|
||||
const mockJWKS = jest.fn();
|
||||
const mockCreateLocalJWKSet = createCachedLocalJWKSet as jest.MockedFunction<
|
||||
typeof createCachedLocalJWKSet
|
||||
>;
|
||||
mockCreateLocalJWKSet.mockReturnValue(mockJWKS as any);
|
||||
|
||||
// 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;
|
||||
const mockJwtVerify = jwtVerify as jest.MockedFunction<typeof jwtVerify>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Ensure createCachedLocalJWKSet returns a defined value after clearing
|
||||
mockCreateLocalJWKSet.mockReturnValue(mockJWKS as any);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
JwtAuthGuard,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: createMockConfigService({
|
||||
BASE_URL: 'http://localhost:3001',
|
||||
'jwt.issuer': 'manacore',
|
||||
'jwt.audience': 'manacore',
|
||||
'database.url': 'postgresql://localhost:5432/test',
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: LoggerService,
|
||||
useValue: createMockLoggerService(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<JwtAuthGuard>(JwtAuthGuard);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should return true for valid JWT token', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const mockPayload = mockTokenFactory.validPayload({
|
||||
sub: 'user-123',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
mockJwtVerify.mockResolvedValue({
|
||||
payload: mockPayload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
key: {} as any,
|
||||
});
|
||||
|
||||
const result = await guard.canActivate(mockContext as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockRequest.user).toEqual({
|
||||
sub: 'user-123',
|
||||
userId: 'user-123',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when no token provided', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided');
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when authorization header is missing', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided');
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for expired token', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer expired-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const expiredError = new Error('JWT expired');
|
||||
(expiredError as any).code = 'ERR_JWT_EXPIRED';
|
||||
mockJwtVerify.mockRejectedValue(expiredError);
|
||||
|
||||
await silentError(async () => {
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('Invalid token');
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid token', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer invalid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const invalidError = new Error('JWT invalid');
|
||||
(invalidError as any).code = 'ERR_JWT_INVALID';
|
||||
mockJwtVerify.mockRejectedValue(invalidError);
|
||||
|
||||
await silentError(async () => {
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('Invalid token');
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for malformed token', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer not.a.valid.jwt',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
mockJwtVerify.mockRejectedValue(new Error('Invalid compact JWS'));
|
||||
|
||||
await silentError(async () => {
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
it('should verify token with correct issuer and audience', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const mockPayload = mockTokenFactory.validPayload();
|
||||
|
||||
mockJwtVerify.mockResolvedValue({
|
||||
payload: mockPayload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
key: {} as any,
|
||||
});
|
||||
|
||||
await guard.canActivate(mockContext as any);
|
||||
|
||||
// Note: issuer defaults to http://localhost:3001 when BASE_URL and jwt.issuer are not set
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith(
|
||||
'valid-jwt-token',
|
||||
expect.anything(), // JWKS
|
||||
expect.objectContaining({
|
||||
issuer: 'http://localhost:3001',
|
||||
audience: 'manacore',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should attach complete user info to request', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const mockPayload = mockTokenFactory.validPayload({
|
||||
sub: 'user-456',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
mockJwtVerify.mockResolvedValue({
|
||||
payload: mockPayload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
key: {} as any,
|
||||
});
|
||||
|
||||
await guard.canActivate(mockContext as any);
|
||||
|
||||
expect(mockRequest.user).toEqual({
|
||||
sub: 'user-456',
|
||||
userId: 'user-456',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize JWKS on first use', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const mockPayload = mockTokenFactory.validPayload();
|
||||
|
||||
mockJwtVerify.mockResolvedValue({
|
||||
payload: mockPayload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
key: {} as any,
|
||||
});
|
||||
|
||||
// First call initializes JWKS
|
||||
await guard.canActivate(mockContext as any);
|
||||
|
||||
expect(mockJwtVerify).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call reuses same JWKS
|
||||
await guard.canActivate(mockContext as any);
|
||||
|
||||
expect(mockJwtVerify).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTokenFromHeader', () => {
|
||||
it('should extract token from Bearer authorization header', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer my-secret-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const mockPayload = mockTokenFactory.validPayload();
|
||||
|
||||
mockJwtVerify.mockResolvedValue({
|
||||
payload: mockPayload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
key: {} as any,
|
||||
});
|
||||
|
||||
await guard.canActivate(mockContext as any);
|
||||
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith(
|
||||
'my-secret-token',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined for non-Bearer authorization', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Basic user:pass',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided');
|
||||
});
|
||||
|
||||
it('should return undefined for empty authorization header', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided');
|
||||
});
|
||||
|
||||
it('should return undefined when authorization header is just "Bearer"', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration', () => {
|
||||
it('should use local JWKS cache for key resolution', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const mockPayload = mockTokenFactory.validPayload();
|
||||
|
||||
mockJwtVerify.mockResolvedValue({
|
||||
payload: mockPayload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
key: {} as any,
|
||||
});
|
||||
|
||||
await guard.canActivate(mockContext as any);
|
||||
|
||||
// Should use createCachedLocalJWKSet instead of createRemoteJWKSet
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledWith(
|
||||
expect.any(String) // database URL
|
||||
);
|
||||
expect(mockJwtVerify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use default BASE_URL when not configured', async () => {
|
||||
// Create guard with config missing BASE_URL
|
||||
const guardWithDefaults = new JwtAuthGuard(
|
||||
createMockConfigService({
|
||||
'jwt.issuer': 'manacore',
|
||||
'jwt.audience': 'manacore',
|
||||
'database.url': 'postgresql://localhost:5432/test',
|
||||
}),
|
||||
createMockLoggerService()
|
||||
);
|
||||
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const mockPayload = mockTokenFactory.validPayload();
|
||||
|
||||
mockJwtVerify.mockResolvedValue({
|
||||
payload: mockPayload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
key: {} as any,
|
||||
});
|
||||
|
||||
await guardWithDefaults.canActivate(mockContext as any);
|
||||
|
||||
// Should still work with default localhost URL
|
||||
expect(mockJwtVerify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use configured issuer and audience', async () => {
|
||||
// Note: issuer = baseUrl || jwtIssuer || default, so we don't set BASE_URL to test jwt.issuer
|
||||
const guardWithCustomConfig = new JwtAuthGuard(
|
||||
createMockConfigService({
|
||||
'jwt.issuer': 'custom-issuer',
|
||||
'jwt.audience': 'custom-audience',
|
||||
'database.url': 'postgresql://localhost:5432/test',
|
||||
}),
|
||||
createMockLoggerService()
|
||||
);
|
||||
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
const mockPayload = mockTokenFactory.validPayload();
|
||||
|
||||
mockJwtVerify.mockResolvedValue({
|
||||
payload: mockPayload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
key: {} as any,
|
||||
});
|
||||
|
||||
await guardWithCustomConfig.canActivate(mockContext as any);
|
||||
|
||||
expect(mockJwtVerify).toHaveBeenCalledWith(
|
||||
'valid-jwt-token',
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
issuer: 'custom-issuer',
|
||||
audience: 'custom-audience',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security', () => {
|
||||
it('should not accept tokens without Bearer prefix', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided');
|
||||
});
|
||||
|
||||
it('should handle case-sensitive Bearer prefix', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'bearer valid-jwt-token', // lowercase
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
// Should not accept lowercase "bearer"
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided');
|
||||
});
|
||||
|
||||
it('should reject token with wrong issuer', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
mockJwtVerify.mockRejectedValue(new Error('unexpected "iss" claim value'));
|
||||
|
||||
await silentError(async () => {
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject token with wrong audience', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer valid-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
mockJwtVerify.mockRejectedValue(new Error('unexpected "aud" claim value'));
|
||||
|
||||
await silentError(async () => {
|
||||
await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not expose sensitive error details', async () => {
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
headers: {
|
||||
authorization: 'Bearer tampered-jwt-token',
|
||||
},
|
||||
});
|
||||
|
||||
const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest);
|
||||
|
||||
mockJwtVerify.mockRejectedValue(new Error('signature verification failed'));
|
||||
|
||||
await silentError(async () => {
|
||||
try {
|
||||
await guard.canActivate(mockContext as any);
|
||||
fail('Should have thrown UnauthorizedException');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(UnauthorizedException);
|
||||
// Should not expose the specific jose error message
|
||||
expect((error as any).message).toBe('Invalid token');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import {
|
||||
Injectable,
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { LoggerService } from '../logger';
|
||||
import { createCachedLocalJWKSet } from './local-jwks-cache';
|
||||
|
||||
/**
|
||||
* JWT Auth Guard using local JWKS cache (Better Auth compatible)
|
||||
*
|
||||
* Uses jose library with locally cached JWKS keys for EdDSA token verification.
|
||||
* Keys are read directly from the database instead of making HTTP requests
|
||||
* to the service's own JWKS endpoint.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
private jwks: ReturnType<typeof createCachedLocalJWKSet> | null = null;
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
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);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
// Lazy initialize local JWKS (reads from DB, cached in memory)
|
||||
if (!this.jwks) {
|
||||
const databaseUrl = this.configService.get<string>('database.url') || '';
|
||||
this.jwks = createCachedLocalJWKSet(databaseUrl);
|
||||
}
|
||||
|
||||
// IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts)
|
||||
// Better Auth uses: issuer = BASE_URL || JWT_ISSUER || 'http://localhost:3001'
|
||||
const baseUrl = this.configService.get<string>('BASE_URL');
|
||||
const jwtIssuer = this.configService.get<string>('jwt.issuer');
|
||||
const issuer = baseUrl || jwtIssuer || 'http://localhost:3001';
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
const { payload } = await jwtVerify(token, this.jwks, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
this.logger.debug('Token verification successful', { userId: payload.sub });
|
||||
|
||||
// Attach user to request
|
||||
// Include both 'sub' and 'userId' for compatibility with different controllers
|
||||
request.user = {
|
||||
sub: payload.sub,
|
||||
userId: payload.sub,
|
||||
email: payload.email as string,
|
||||
role: payload.role as string,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.warn('Token verification failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
/**
|
||||
* Local JWKS Cache Unit Tests
|
||||
*
|
||||
* Tests the in-memory JWKS cache that reads keys from the database
|
||||
* and provides jose-compatible key resolvers for JWT verification.
|
||||
*
|
||||
* - Happy path: loads keys from DB, returns working resolver
|
||||
* - Caching: returns cached result within TTL, refreshes after TTL
|
||||
* - Empty DB: throws meaningful error when no keys available
|
||||
* - DB failure: propagates errors with meaningful context
|
||||
* - Key rotation: picks up new keys after cache expires
|
||||
*/
|
||||
|
||||
import { createCachedLocalJWKSet, clearJwksCache } from './local-jwks-cache';
|
||||
|
||||
// Mock the DB connection module
|
||||
jest.mock('../../db/connection', () => ({
|
||||
getDb: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock jose - we test the cache logic, not jose internals
|
||||
jest.mock('jose', () => ({
|
||||
createLocalJWKSet: jest.fn(),
|
||||
}));
|
||||
|
||||
import { getDb } from '../../db/connection';
|
||||
import { createLocalJWKSet } from 'jose';
|
||||
|
||||
const mockGetDb = getDb as jest.MockedFunction<typeof getDb>;
|
||||
const mockCreateLocalJWKSet = createLocalJWKSet as jest.MockedFunction<typeof createLocalJWKSet>;
|
||||
|
||||
// Sample EdDSA JWK for testing
|
||||
const sampleJwk = {
|
||||
kty: 'OKP',
|
||||
crv: 'Ed25519',
|
||||
x: 'dGVzdC1wdWJsaWMta2V5LWJhc2U2NA',
|
||||
kid: 'test-key-1',
|
||||
};
|
||||
|
||||
const sampleDbRow = {
|
||||
id: 'test-key-1',
|
||||
publicKey: JSON.stringify(sampleJwk),
|
||||
privateKey: '{"kty":"OKP","crv":"Ed25519","d":"private","x":"dGVzdC1wdWJsaWMta2V5LWJhc2U2NA"}',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
describe('Local JWKS Cache', () => {
|
||||
let mockFrom: jest.Mock;
|
||||
let mockSelect: jest.Mock;
|
||||
let mockResolver: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
clearJwksCache();
|
||||
|
||||
// Setup DB mock chain: db.select().from(jwks) => rows
|
||||
mockFrom = jest.fn();
|
||||
mockSelect = jest.fn().mockReturnValue({ from: mockFrom });
|
||||
mockGetDb.mockReturnValue({ select: mockSelect } as any);
|
||||
|
||||
// Setup jose mock resolver
|
||||
mockResolver = jest.fn().mockResolvedValue({} as CryptoKey);
|
||||
mockCreateLocalJWKSet.mockReturnValue(mockResolver as any);
|
||||
});
|
||||
|
||||
describe('Happy path', () => {
|
||||
it('should read JWKS from DB and return a working key resolver', async () => {
|
||||
mockFrom.mockResolvedValue([sampleDbRow]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
const result = await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
|
||||
// Should have queried the DB
|
||||
expect(mockGetDb).toHaveBeenCalledWith('postgresql://localhost:5432/test');
|
||||
expect(mockSelect).toHaveBeenCalled();
|
||||
expect(mockFrom).toHaveBeenCalled();
|
||||
|
||||
// Should have created a local JWK set with the parsed keys
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({
|
||||
keys: [sampleJwk],
|
||||
});
|
||||
|
||||
// Should have called the resolver
|
||||
expect(mockResolver).toHaveBeenCalledWith({ alg: 'EdDSA' }, {});
|
||||
});
|
||||
|
||||
it('should set kid from row id when JWK has no kid', async () => {
|
||||
const jwkWithoutKid = { kty: 'OKP', crv: 'Ed25519', x: 'abc123' };
|
||||
const row = {
|
||||
id: 'row-id-123',
|
||||
publicKey: JSON.stringify(jwkWithoutKid),
|
||||
privateKey: '{}',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockFrom.mockResolvedValue([row]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({
|
||||
keys: [{ ...jwkWithoutKid, kid: 'row-id-123' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple keys from DB', async () => {
|
||||
const secondJwk = { kty: 'OKP', crv: 'Ed25519', x: 'c2Vjb25kLWtleQ', kid: 'key-2' };
|
||||
const rows = [
|
||||
sampleDbRow,
|
||||
{
|
||||
id: 'key-2',
|
||||
publicKey: JSON.stringify(secondJwk),
|
||||
privateKey: '{}',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockFrom.mockResolvedValue(rows);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({
|
||||
keys: [sampleJwk, secondJwk],
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip malformed JSON keys without crashing', async () => {
|
||||
const rows = [
|
||||
{ id: 'bad-key', publicKey: 'not-valid-json{', privateKey: '{}', createdAt: new Date() },
|
||||
sampleDbRow,
|
||||
];
|
||||
|
||||
mockFrom.mockResolvedValue(rows);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
|
||||
// Should only include the valid key
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({
|
||||
keys: [sampleJwk],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Caching behavior', () => {
|
||||
it('should use cached resolver on second call within TTL', async () => {
|
||||
mockFrom.mockResolvedValue([sampleDbRow]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
// First call - reads from DB
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
expect(mockFrom).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call - should use cache
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
expect(mockFrom).toHaveBeenCalledTimes(1); // Still 1 - no new DB query
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1); // Still 1
|
||||
});
|
||||
|
||||
it('should refresh cache after TTL expires', async () => {
|
||||
mockFrom.mockResolvedValue([sampleDbRow]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
// First call
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
expect(mockFrom).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time past TTL (5 minutes = 300000ms)
|
||||
const originalDateNow = Date.now;
|
||||
Date.now = jest.fn().mockReturnValue(originalDateNow() + 5 * 60 * 1000 + 1);
|
||||
|
||||
try {
|
||||
// Third call after TTL - should refresh
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
expect(mockFrom).toHaveBeenCalledTimes(2); // New DB query
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(2); // New resolver created
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('should not refresh cache before TTL expires', async () => {
|
||||
mockFrom.mockResolvedValue([sampleDbRow]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
// First call
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
|
||||
// Advance time to just before TTL (4 minutes 59 seconds)
|
||||
const originalDateNow = Date.now;
|
||||
Date.now = jest.fn().mockReturnValue(originalDateNow() + 4 * 60 * 1000 + 59 * 1000);
|
||||
|
||||
try {
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
expect(mockFrom).toHaveBeenCalledTimes(1); // No refresh
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty DB', () => {
|
||||
it('should throw error when no JWKS keys are in the database', async () => {
|
||||
mockFrom.mockResolvedValue([]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow(
|
||||
'No JWKS keys available in database'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when all keys have malformed JSON', async () => {
|
||||
mockFrom.mockResolvedValue([
|
||||
{ id: 'bad-1', publicKey: '{invalid', privateKey: '{}', createdAt: new Date() },
|
||||
{ id: 'bad-2', publicKey: 'not json', privateKey: '{}', createdAt: new Date() },
|
||||
]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow(
|
||||
'No JWKS keys available in database'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DB connection failure', () => {
|
||||
it('should propagate database errors with meaningful context', async () => {
|
||||
mockFrom.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow(
|
||||
'Connection refused'
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate timeout errors', async () => {
|
||||
mockFrom.mockRejectedValue(new Error('Query timeout'));
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow('Query timeout');
|
||||
});
|
||||
|
||||
it('should retry DB read after a failed attempt (no stale error cached)', async () => {
|
||||
// First call fails
|
||||
mockFrom.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow(
|
||||
'Connection refused'
|
||||
);
|
||||
|
||||
// Second call should try DB again (not cache the error)
|
||||
mockFrom.mockResolvedValueOnce([sampleDbRow]);
|
||||
|
||||
const result = await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
expect(mockFrom).toHaveBeenCalledTimes(2);
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Key rotation', () => {
|
||||
it('should pick up new keys after cache TTL expires', async () => {
|
||||
const originalKey = sampleDbRow;
|
||||
const rotatedJwk = { kty: 'OKP', crv: 'Ed25519', x: 'cm90YXRlZC1rZXk', kid: 'rotated-key' };
|
||||
const rotatedRow = {
|
||||
id: 'rotated-key',
|
||||
publicKey: JSON.stringify(rotatedJwk),
|
||||
privateKey: '{}',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// First call returns original key
|
||||
mockFrom.mockResolvedValueOnce([originalKey]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({
|
||||
keys: [sampleJwk],
|
||||
});
|
||||
|
||||
// Expire the cache
|
||||
const originalDateNow = Date.now;
|
||||
Date.now = jest.fn().mockReturnValue(originalDateNow() + 5 * 60 * 1000 + 1);
|
||||
|
||||
try {
|
||||
// Second call returns rotated key
|
||||
mockFrom.mockResolvedValueOnce([rotatedRow]);
|
||||
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(2);
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenLastCalledWith({
|
||||
keys: [rotatedJwk],
|
||||
});
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('should serve both old and new keys during rotation period', async () => {
|
||||
const oldJwk = sampleJwk;
|
||||
const newJwk = { kty: 'OKP', crv: 'Ed25519', x: 'bmV3LWtleQ', kid: 'new-key' };
|
||||
|
||||
// DB returns both keys (typical during rotation)
|
||||
mockFrom.mockResolvedValue([
|
||||
sampleDbRow,
|
||||
{
|
||||
id: 'new-key',
|
||||
publicKey: JSON.stringify(newJwk),
|
||||
privateKey: '{}',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
|
||||
expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({
|
||||
keys: [oldJwk, newJwk],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearJwksCache', () => {
|
||||
it('should force a DB re-read on next call after clearing', async () => {
|
||||
mockFrom.mockResolvedValue([sampleDbRow]);
|
||||
|
||||
const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test');
|
||||
|
||||
// First call
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
expect(mockFrom).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clear cache
|
||||
clearJwksCache();
|
||||
|
||||
// Next call should query DB again
|
||||
await keyGetter({ alg: 'EdDSA' } as any, {} as any);
|
||||
expect(mockFrom).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
/**
|
||||
* Local JWKS Cache
|
||||
*
|
||||
* Provides in-memory cached JWKS keys for JWT verification without
|
||||
* making HTTP requests. Since the auth service IS the JWKS provider,
|
||||
* it should read keys directly from the database instead of fetching
|
||||
* from its own HTTP endpoint.
|
||||
*
|
||||
* Uses jose's built-in createLocalJWKSet() for key resolution,
|
||||
* wrapping it with a database-backed cache layer.
|
||||
*/
|
||||
|
||||
import { createLocalJWKSet as joseCreateLocalJWKSet } from 'jose';
|
||||
import type { JWK, JSONWebKeySet, JWSHeaderParameters, FlattenedJWSInput, CryptoKey } from 'jose';
|
||||
import { getDb } from '../../db/connection';
|
||||
import { jwks } from '../../db/schema/auth.schema';
|
||||
|
||||
interface JwksCache {
|
||||
resolver: (
|
||||
protectedHeader?: JWSHeaderParameters,
|
||||
token?: FlattenedJWSInput
|
||||
) => Promise<CryptoKey>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Cache TTL in milliseconds (5 minutes) */
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Module-level cache shared across all consumers within this process */
|
||||
let cache: JwksCache | null = null;
|
||||
|
||||
/**
|
||||
* Load JWKS keys from the database and return as a JSONWebKeySet.
|
||||
*/
|
||||
async function loadJwksFromDb(databaseUrl: string): Promise<JSONWebKeySet> {
|
||||
const db = getDb(databaseUrl);
|
||||
const rows = await db.select().from(jwks);
|
||||
|
||||
const keys: JWK[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const jwk: JWK = JSON.parse(row.publicKey);
|
||||
|
||||
// Ensure the kid is set (use the row ID if the JWK doesn't have one)
|
||||
if (!jwk.kid) {
|
||||
jwk.kid = row.id;
|
||||
}
|
||||
|
||||
keys.push(jwk);
|
||||
} catch {
|
||||
// Skip malformed keys
|
||||
}
|
||||
}
|
||||
|
||||
return { keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or refresh the cached JWKS resolver.
|
||||
*/
|
||||
async function getCachedResolver(
|
||||
databaseUrl: string
|
||||
): Promise<
|
||||
(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput) => Promise<CryptoKey>
|
||||
> {
|
||||
const now = Date.now();
|
||||
|
||||
if (cache && cache.expiresAt > now) {
|
||||
return cache.resolver;
|
||||
}
|
||||
|
||||
const jwksData = await loadJwksFromDb(databaseUrl);
|
||||
|
||||
if (jwksData.keys.length === 0) {
|
||||
throw new Error('No JWKS keys available in database');
|
||||
}
|
||||
|
||||
const resolver = joseCreateLocalJWKSet(jwksData);
|
||||
|
||||
cache = {
|
||||
resolver,
|
||||
expiresAt: now + CACHE_TTL_MS,
|
||||
};
|
||||
|
||||
return resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a jose-compatible key getter function that reads JWKS from
|
||||
* the local database with in-memory caching.
|
||||
*
|
||||
* This replaces createRemoteJWKSet() for the auth service itself,
|
||||
* avoiding self-referential HTTP requests.
|
||||
*
|
||||
* @param databaseUrl - PostgreSQL connection URL
|
||||
* @returns A function compatible with jose's jwtVerify second argument
|
||||
*/
|
||||
export function createCachedLocalJWKSet(
|
||||
databaseUrl: string
|
||||
): (protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput) => Promise<CryptoKey> {
|
||||
return async (protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput) => {
|
||||
const resolver = await getCachedResolver(databaseUrl);
|
||||
return resolver(protectedHeader, token);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the JWKS cache. Useful for testing or when keys are rotated.
|
||||
*/
|
||||
export function clearJwksCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import type { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { createCachedLocalJWKSet } from './local-jwks-cache';
|
||||
|
||||
/**
|
||||
* Optional authentication guard using locally cached JWKS (Better Auth compatible)
|
||||
*
|
||||
* Attaches user to request if valid token is present, but doesn't require it.
|
||||
* Uses jose library with locally cached JWKS keys for EdDSA token verification.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard implements CanActivate {
|
||||
private jwks: ReturnType<typeof createCachedLocalJWKSet> | null = null;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
// No token - allow request but no user
|
||||
request.user = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Lazy initialize local JWKS (reads from DB, cached in memory)
|
||||
if (!this.jwks) {
|
||||
const databaseUrl = this.configService.get<string>('database.url') || '';
|
||||
this.jwks = createCachedLocalJWKSet(databaseUrl);
|
||||
}
|
||||
|
||||
// IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts)
|
||||
// Signing uses: issuer = BASE_URL, audience = JWT_AUDIENCE || 'manacore'
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const issuer = baseUrl; // Better Auth uses BASE_URL as issuer for OIDC compatibility
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
const { payload } = await jwtVerify(token, this.jwks, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email as string,
|
||||
role: payload.role as string,
|
||||
};
|
||||
} catch {
|
||||
// Invalid token - allow request but no user
|
||||
request.user = null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue