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:
Till JS 2026-03-28 03:04:01 +01:00
parent 14099cc42c
commit 5b673282f9
169 changed files with 43 additions and 36123 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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=

View file

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

View file

@ -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..."}'
```

View file

@ -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"]

View file

@ -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.

View file

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

View file

@ -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.

View file

@ -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(),
],
});

View file

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

View file

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

View file

@ -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
```

View file

@ -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();
```

View file

@ -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,
};
```

View file

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

View file

@ -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.

View file

@ -1,6 +0,0 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'manacore',
schemaFilter: ['auth', 'credits', 'gifts', 'subscriptions', 'public'],
});

View file

@ -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,
];

View file

@ -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,
};

View file

@ -1,10 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"webpack": false,
"tsConfigPath": "tsconfig.json"
}
}

View file

@ -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"
}
}

File diff suppressed because it is too large Load diff

View file

@ -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';

View file

@ -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;

View file

@ -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';

View file

@ -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';

View file

@ -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!"

View file

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

View file

@ -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 = [];
}

View file

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

View file

@ -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 {}

View file

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

View file

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

View file

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

View file

@ -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,
};
}
}
}

View file

@ -1,9 +0,0 @@
import { Module, Global } from '@nestjs/common';
import { AiService } from './ai.service';
@Global()
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View file

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

View file

@ -1,2 +0,0 @@
export * from './ai.module';
export * from './ai.service';

View file

@ -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,
};
}
}

View file

@ -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 {}

View file

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

View file

@ -1,3 +0,0 @@
export * from './analytics.module';
export * from './analytics.service';
export * from './analytics.controller';

View file

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

View file

@ -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 {}

View file

@ -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,
},
};
}
}

View file

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

View file

@ -1,2 +0,0 @@
export * from './create-api-key.dto';
export * from './validate-api-key.dto';

View file

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

View file

@ -1,4 +0,0 @@
export * from './api-keys.module';
export * from './api-keys.service';
export * from './api-keys.controller';
export * from './dto';

View file

@ -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 {}

View file

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

View file

@ -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 {}

View file

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

View file

@ -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>;

View file

@ -1,9 +0,0 @@
import { IsString } from 'class-validator';
/**
* DTO for accepting an organization invitation
*/
export class AcceptInvitationDto {
@IsString()
invitationId: string;
}

View file

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

View file

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

View file

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

View file

@ -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';

View file

@ -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';
}

View file

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

View file

@ -1,6 +0,0 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}

View file

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

View file

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

View file

@ -1,10 +0,0 @@
import { IsEmail, IsOptional, IsString } from 'class-validator';
export class ResendVerificationDto {
@IsEmail()
email: string;
@IsOptional()
@IsString()
sourceAppUrl?: string;
}

View file

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

View file

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

View file

@ -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';
}

View file

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

View file

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

View file

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

View file

@ -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/*');
});
});
});

View file

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

View file

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

View file

@ -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',
});
}
}
}

View file

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

View file

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

View file

@ -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([]);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
},
};

View file

@ -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();
},
};

View file

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

View file

@ -1,7 +0,0 @@
/**
* Auth Types Index
*
* Re-exports all authentication-related types
*/
export * from './better-auth.types';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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