mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
🔀 merge: integrate till-dev into main
Merge till-dev branch containing: - Planta plant care tracking application - Clock backend with alarms, timers, world clocks - Zitare backend with favorites and lists - Various app improvements and fixes - Auth system updates - Infrastructure improvements Note: Some type-check issues may need resolution after merge. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
49a8c652da
475 changed files with 28008 additions and 22742 deletions
|
|
@ -12,8 +12,9 @@ REDIS_PORT=6379
|
|||
REDIS_PASSWORD=
|
||||
|
||||
# JWT Configuration
|
||||
# Note: JWT signing keys are managed automatically by Better Auth (EdDSA/Ed25519)
|
||||
# Keys are stored in the auth.jwks database table - no manual configuration needed
|
||||
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGRsOXROB4lprw\n9oXaOIt+cwHe3UxBOoiWiUXcpFuXwb+kBWn/LyjeCIOXtefOwE0S10JEodK+6foe\naqGHanq86qAmmkb4a8sjj5LAxXkHL35sJo8HaYcx5NkJQLxQSRHpTfdfxsKsKwxa\n4R4uqrvToqdo6tl/VMsGDPS8L7KzaiKaSdGugvlVtXWgV1soeXSUPyPwpyAXQg7h\nY4CkTSkJAplrs77RLdj8u6jbHKR3F7QkwiU1JocjhM1GP/suKiqXRu8omLFnu45C\ns09SNSRsOpNY5csrKA4PZ2LCks9VHH7HafFvB+BbRw4+Ssr6myOysAztqi3bZMRW\nLTakWpBbAgMBAAECggEAF5zi0IzaghHxhtkyYfrSRgSynX9+WYBRNu2ch8/SZqAj\neghOXMkZgAPEjtiSMDGqRsr4ReMoYtB2Qea8sOX8kwC1gj4Po1Mhtez0cwexclUf\nebLH3X/y9/1YiZJk5YImOMIuaoC/ELDvFOhIEhJcMbKREbIc+oiMcH6HgN0vViVh\nJptgHTnqnGHNARkEpf+xnxqJJxEgrEMz50b4fApKpoZsWXNnZ3Atc/i2ziGew5z4\npnGJxs9TWSukBZaQvl9iluBBvqmPkCOId+L7CmB44bNURpqQOm8gxEgLcdn06y5j\nIKee3Z4H6OTseFvSIYYqBqCyyyZWHICBZXUCDQKUbQKBgQDnFe+O+pQc5looLFiF\nxuYsfDtJqvoMgQ0BaVAo6wVpPe6w+1NA6ZxghcM0+8zyc70jZvdMXINhdsfWD5Gi\nJ/NEDI8EXJJKMfnFQ7F1Ad5NyTnnn/TsLda4GIGQznPRS6uxUP4ljFtxmU9G8Diz\nUQ47XsLjwzzbTedMTSYoQ46kdwKBgQDbp0dIq047o4A72/BBttKdZbgQmjFmqCXF\n8YRUquIDXh/CJ4OQwOIaOvk2398Rg53c3MsV+XCJaMmWYqnJ4BdITLsqeGKsczoS\nI0DMehDr++aOoX/f29r1c+7J/fV5jtAEUcwIEOR1vyAM+WdiWnnTvdpMPVUDsgaT\ntuH0E8WgPQKBgQCCINci87Z+Q7VXVAmRY7zwJhEY3eArNGzHc6+BKz+D0S1dmll6\nf1LhA9I2PuldSpGiovP1m08cjk/gGipPXyHdGxlaQmravyPA0urWUfQGZ59k8K1y\nZim4x4wGqEuN+4e2tT44lL5VzRhYgSPcznMuOaGTsrjNYiQy0mr/V3O25wKBgHvV\nryaVDaIp553XvXgO7ma2djNF+xv5KHKUWxqwzINBiX4YcOAnHlHTdbUuOcDSByoB\ngK1+16dgYGZccYTSxc2JFOw4usimndKj9WBSYT/p4G4BNuqqNKO1HKbceoxxq20E\nAJd7jpGjkxo9cb/Nammp22yoF0niEDsvG+xTSVOxAoGBAMfxHYCMdPc625upCbqG\nkPSJJGYREKGad80OtXilYXLvBPzV65q32k2YZGjaicPKRAzj72KO4nfIu9SY6bfO\nBvXCtIcvllZQuxyd3Cd8MirujJodKwThLTMd4bAYYMXGz1/W6R6pzunZs5KEpgEr\nczy9Gk9WNp0t8vfzyZZ9aago\n-----END PRIVATE KEY-----\n"
|
||||
|
||||
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxkbDl0TgeJaa8PaF2jiL\nfnMB3t1MQTqIlolF3KRbl8G/pAVp/y8o3giDl7XnzsBNEtdCRKHSvun6Hmqhh2p6\nvOqgJppG+GvLI4+SwMV5By9+bCaPB2mHMeTZCUC8UEkR6U33X8bCrCsMWuEeLqq7\n06KnaOrZf1TLBgz0vC+ys2oimknRroL5VbV1oFdbKHl0lD8j8KcgF0IO4WOApE0p\nCQKZa7O+0S3Y/Luo2xykdxe0JMIlNSaHI4TNRj/7Lioql0bvKJixZ7uOQrNPUjUk\nbDqTWOXLKygOD2diwpLPVRx+x2nxbwfgW0cOPkrK+psjsrAM7aot22TEVi02pFqQ\nWwIDAQAB\n-----END PUBLIC KEY-----\n"
|
||||
JWT_ACCESS_TOKEN_EXPIRY=15m
|
||||
JWT_REFRESH_TOKEN_EXPIRY=7d
|
||||
JWT_ISSUER=manacore
|
||||
|
|
@ -34,16 +35,3 @@ CREDITS_DAILY_FREE=5
|
|||
# Rate Limiting
|
||||
RATE_LIMIT_TTL=60
|
||||
RATE_LIMIT_MAX=100
|
||||
|
||||
# Email (Brevo)
|
||||
# Get your API key from: https://app.brevo.com/settings/keys/api
|
||||
# Without this key, emails are logged to console only (dev mode)
|
||||
BREVO_API_KEY=
|
||||
EMAIL_SENDER_ADDRESS=noreply@manacore.ai
|
||||
EMAIL_SENDER_NAME=ManaCore
|
||||
|
||||
# URLs
|
||||
# BASE_URL: Used by Better Auth for internal callbacks
|
||||
# FRONTEND_URL: Used for password reset and email verification links
|
||||
BASE_URL=http://localhost:3001
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
|
|
|||
|
|
@ -1,484 +0,0 @@
|
|||
# Apply Security Fixes - Quick Start Guide
|
||||
|
||||
This guide provides the quickest path to implementing all critical security fixes.
|
||||
|
||||
## Pre-Flight Checklist
|
||||
|
||||
- [ ] Backup current code: `git stash` or create a branch
|
||||
- [ ] Review the complete analysis: `docs/MANA_CORE_AUTH_ANALYSIS.md`
|
||||
- [ ] Review implementation guide: `docs/SECURITY_FIXES_IMPLEMENTATION_GUIDE.md`
|
||||
|
||||
## Quick Apply (Recommended Order)
|
||||
|
||||
### Fix 1: JWT Fallback - MANUAL EDIT REQUIRED ⚠️
|
||||
|
||||
The JWT fallback code needs to be manually edited because the file has been recently modified.
|
||||
|
||||
**File:** `src/auth/services/better-auth.service.ts`
|
||||
**Lines:** ~449-508
|
||||
|
||||
**Find this block** (search for "Generate JWT access token"):
|
||||
|
||||
```typescript
|
||||
// Generate JWT access token using Better Auth's JWT plugin
|
||||
let accessToken = '';
|
||||
try {
|
||||
const jwtResult = await this.api.signJWT({
|
||||
// ... lots of code ...
|
||||
});
|
||||
// ... fallback code with RS256 ...
|
||||
} catch (jwtError) {
|
||||
// Manual JWT generation fallback
|
||||
}
|
||||
```
|
||||
|
||||
**Replace entire block with:**
|
||||
|
||||
```typescript
|
||||
// Generate JWT access token using Better Auth's JWT plugin
|
||||
const jwtResult = await this.api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = jwtResult?.token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new UnauthorizedException('Failed to generate access token');
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
cd services/mana-core-auth
|
||||
pnpm start:dev
|
||||
# Test login, check console for EdDSA tokens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fix 2: Cookie Cache - READY TO APPLY ✅
|
||||
|
||||
**File:** `src/auth/better-auth.config.ts`
|
||||
**Line:** ~148
|
||||
|
||||
Run this command to apply:
|
||||
|
||||
```bash
|
||||
cd services/mana-core-auth
|
||||
|
||||
# Backup first
|
||||
cp src/auth/better-auth.config.ts src/auth/better-auth.config.ts.backup
|
||||
|
||||
# Then manually edit or use this patch
|
||||
```
|
||||
|
||||
**Manual edit:** Find `session:` block, add after `updateAge`:
|
||||
|
||||
```typescript
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7,
|
||||
updateAge: 60 * 60 * 24,
|
||||
|
||||
// ✅ ADD THIS BLOCK:
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
strategy: "jwe", // Encrypted
|
||||
refreshCache: true,
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
# Check response headers after login
|
||||
curl -v http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@test.com","password":"yourpassword"}' \
|
||||
| grep -i "set-cookie"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fix 3: Remember Me Feature - MULTI-STEP
|
||||
|
||||
#### Step 3a: Schema Change
|
||||
|
||||
**File:** `src/db/schema/auth.schema.ts`
|
||||
**Line:** ~32 (in sessions table)
|
||||
|
||||
Add this field:
|
||||
|
||||
```typescript
|
||||
export const sessions = authSchema.table('sessions', {
|
||||
// ... existing fields ...
|
||||
|
||||
// ✅ ADD THIS:
|
||||
rememberMe: boolean('remember_me').default(false),
|
||||
});
|
||||
```
|
||||
|
||||
#### Step 3b: Run Migration
|
||||
|
||||
```bash
|
||||
cd services/mana-core-auth
|
||||
pnpm db:generate
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
#### Step 3c: Update DTO
|
||||
|
||||
**File:** `src/auth/dto/login.dto.ts`
|
||||
|
||||
```typescript
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsBoolean } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12) // ✅ FIXED: was 8
|
||||
password: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceName?: string;
|
||||
|
||||
// ✅ NEW:
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
rememberMe?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ipAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userAgent?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3d: Update signIn Method
|
||||
|
||||
**File:** `src/auth/services/better-auth.service.ts`
|
||||
|
||||
**After line 447** (after `const sessionToken = ...`), add:
|
||||
|
||||
```typescript
|
||||
// Adjust session expiration based on rememberMe
|
||||
if (dto.rememberMe && session?.id) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { sessions } = await import('../../db/schema');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
|
||||
const extendedExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
expiresAt: extendedExpiresAt,
|
||||
rememberMe: true,
|
||||
})
|
||||
.where(eq(sessions.id, session.id));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fix 4: Security Logging - NEW FILES
|
||||
|
||||
#### Step 4a: Create SecurityEventsService
|
||||
|
||||
```bash
|
||||
mkdir -p services/mana-core-auth/src/security
|
||||
```
|
||||
|
||||
Create file: `src/security/security-events.service.ts`
|
||||
|
||||
```bash
|
||||
cat > services/mana-core-auth/src/security/security-events.service.ts << 'EOF'
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from '../db/connection';
|
||||
import { securityEvents } from '../db/schema/auth.schema';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export type SecurityEventType =
|
||||
| 'login_success'
|
||||
| 'login_failure'
|
||||
| 'logout'
|
||||
| 'password_change'
|
||||
| 'account_created'
|
||||
| 'token_refresh'
|
||||
| 'token_validation_failure';
|
||||
|
||||
export interface LogSecurityEventParams {
|
||||
userId?: string;
|
||||
eventType: SecurityEventType;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SecurityEventsService {
|
||||
private databaseUrl: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.databaseUrl = this.configService.get<string>('database.url')!;
|
||||
}
|
||||
|
||||
async logEvent(params: LogSecurityEventParams): Promise<void> {
|
||||
try {
|
||||
const db = getDb(this.databaseUrl);
|
||||
|
||||
await db.insert(securityEvents).values({
|
||||
id: randomUUID(),
|
||||
userId: params.userId || null,
|
||||
eventType: params.eventType,
|
||||
ipAddress: params.ipAddress || null,
|
||||
userAgent: params.userAgent || null,
|
||||
metadata: params.metadata || null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SecurityEventsService] Failed to log security event:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
#### Step 4b: Create SecurityModule
|
||||
|
||||
Create file: `src/security/security.module.ts`
|
||||
|
||||
```bash
|
||||
cat > services/mana-core-auth/src/security/security.module.ts << 'EOF'
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SecurityEventsService } from './security-events.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [SecurityEventsService],
|
||||
exports: [SecurityEventsService],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
EOF
|
||||
```
|
||||
|
||||
#### Step 4c: Add to AppModule
|
||||
|
||||
**File:** `src/app.module.ts`
|
||||
|
||||
Add import:
|
||||
|
||||
```typescript
|
||||
import { SecurityModule } from './security/security.module';
|
||||
```
|
||||
|
||||
Add to imports array:
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
imports: [
|
||||
// ... existing imports ...
|
||||
SecurityModule, // ✅ ADD THIS
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
#### Step 4d: Inject into BetterAuthService
|
||||
|
||||
**File:** `src/auth/services/better-auth.service.ts`
|
||||
|
||||
Add import:
|
||||
|
||||
```typescript
|
||||
import { SecurityEventsService } from '../../security/security-events.service';
|
||||
```
|
||||
|
||||
Add to constructor:
|
||||
|
||||
```typescript
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private securityEventsService: SecurityEventsService, // ✅ ADD THIS
|
||||
// ... other services
|
||||
) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Add logging after successful login (after line ~519):
|
||||
|
||||
```typescript
|
||||
// Log successful login
|
||||
await this.securityEventsService
|
||||
.logEvent({
|
||||
userId: user.id,
|
||||
eventType: 'login_success',
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
metadata: { deviceId: dto.deviceId, rememberMe: dto.rememberMe },
|
||||
})
|
||||
.catch((err) => console.error('Failed to log login success:', err));
|
||||
```
|
||||
|
||||
Add logging for failed login (in catch block):
|
||||
|
||||
```typescript
|
||||
// Log failed login
|
||||
await this.securityEventsService
|
||||
.logEvent({
|
||||
eventType: 'login_failure',
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
metadata: { email: dto.email },
|
||||
})
|
||||
.catch((err) => console.error('Failed to log login failure:', err));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fix 5: Security Headers - APPLY TO MAIN.TS
|
||||
|
||||
**File:** `src/main.ts`
|
||||
|
||||
**Replace existing `helmet()` call** with this:
|
||||
|
||||
```typescript
|
||||
// Comprehensive security headers
|
||||
app.use(
|
||||
helmet({
|
||||
strictTransportSecurity: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true,
|
||||
},
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
},
|
||||
},
|
||||
frameguard: { action: 'deny' },
|
||||
noSniff: true,
|
||||
xssFilter: true,
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
||||
hidePoweredBy: true,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
**Add HTTPS enforcement** (after helmet, before other middleware):
|
||||
|
||||
```typescript
|
||||
// HTTPS enforcement in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use((req: any, res: any, next: any) => {
|
||||
const protocol = req.header('x-forwarded-proto') || req.protocol;
|
||||
if (protocol !== 'https') {
|
||||
return res.redirect(301, `https://${req.header('host')}${req.url}`);
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After applying all fixes:
|
||||
|
||||
```bash
|
||||
# 1. Build
|
||||
cd services/mana-core-auth
|
||||
pnpm build
|
||||
|
||||
# 2. Start
|
||||
pnpm start:dev
|
||||
|
||||
# 3. Test login
|
||||
curl -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@test.com","password":"test123456789","rememberMe":true}'
|
||||
|
||||
# 4. Check token algorithm
|
||||
# (Should be EdDSA, not RS256)
|
||||
|
||||
# 5. Check security events table
|
||||
psql $DATABASE_URL -c "SELECT * FROM auth.security_events ORDER BY created_at DESC LIMIT 5;"
|
||||
|
||||
# 6. Check session with rememberMe
|
||||
psql $DATABASE_URL -c "SELECT id, user_id, remember_me, expires_at FROM auth.sessions ORDER BY created_at DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback if Needed
|
||||
|
||||
```bash
|
||||
# Restore backups
|
||||
git restore .
|
||||
|
||||
# Or if you made backups:
|
||||
cp src/auth/better-auth.config.ts.backup src/auth/better-auth.config.ts
|
||||
|
||||
# Revert migration
|
||||
pnpm db:drop
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **JWT Fix:** Login generates EdDSA tokens (not RS256)
|
||||
✅ **Cookie Cache:** Response includes encrypted session cookie
|
||||
✅ **Remember Me:** Can login with 30-day session
|
||||
✅ **Security Logging:** Events appear in `auth.security_events`
|
||||
✅ **Security Headers:** HSTS, CSP headers present in responses
|
||||
|
||||
---
|
||||
|
||||
## Get Help
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Check the detailed guide: `docs/SECURITY_FIXES_IMPLEMENTATION_GUIDE.md`
|
||||
2. Check the analysis: `docs/MANA_CORE_AUTH_ANALYSIS.md`
|
||||
3. Review Better Auth docs: https://www.better-auth.com/docs
|
||||
|
||||
---
|
||||
|
||||
🏗️ ManaCore Monorepo
|
||||
|
|
@ -57,7 +57,6 @@ jwt.verify(token, publicKey, { algorithms: ['RS256'] });
|
|||
- **Auth**: Better Auth with JWT + Organization plugins
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **JWT Library**: `jose` (NOT `jsonwebtoken`)
|
||||
- **Email**: Brevo (transactional emails)
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -90,10 +89,6 @@ services/mana-core-auth/
|
|||
│ │ ├── auth.controller.ts # Auth endpoints
|
||||
│ │ └── dto/ # Request DTOs
|
||||
│ ├── credits/ # Credit system
|
||||
│ ├── email/
|
||||
│ │ ├── email.module.ts # NestJS email module
|
||||
│ │ ├── email.service.ts # Email service (for NestJS DI)
|
||||
│ │ └── brevo-client.ts # Standalone Brevo client
|
||||
│ ├── db/
|
||||
│ │ ├── schema/ # Drizzle schemas
|
||||
│ │ ├── migrations/ # Generated migration files
|
||||
|
|
@ -122,12 +117,8 @@ Key points:
|
|||
| ------------------------------------------ | ------------------------------------------------ |
|
||||
| `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/auth/types/better-auth.types.ts` | Type definitions (inferred + manual) |
|
||||
| `src/email/email.service.ts` | NestJS email service (use in controllers) |
|
||||
| `src/email/brevo-client.ts` | Standalone Brevo client (used by Better Auth) |
|
||||
| `src/db/schema/auth.schema.ts` | User, session, account, jwks tables |
|
||||
| `docs/AUTHENTICATION_ARCHITECTURE.md` | Comprehensive auth documentation |
|
||||
| `docs/BETTER_AUTH_TYPING_IMPROVEMENTS.md` | TypeScript typing decisions and limitations |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
|
@ -140,12 +131,6 @@ 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
|
||||
|
||||
# Email (Brevo) - optional for development
|
||||
# Without BREVO_API_KEY, emails are logged to console
|
||||
BREVO_API_KEY=your-api-key-here
|
||||
EMAIL_SENDER_ADDRESS=noreply@manacore.app
|
||||
EMAIL_SENDER_NAME=ManaCore
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
|
@ -172,90 +157,6 @@ Other services call `POST /api/v1/auth/validate` with the JWT. The validation us
|
|||
|
||||
For dynamic data (credits, org info), create API endpoints instead.
|
||||
|
||||
### Better Auth TypeScript Types
|
||||
|
||||
**READ FIRST:** `docs/BETTER_AUTH_TYPING_IMPROVEMENTS.md`
|
||||
|
||||
**Prefer inferred types:**
|
||||
```typescript
|
||||
import type { AuthUser, AuthSession } from '../better-auth.config';
|
||||
```
|
||||
|
||||
**Known limitation:** `$Infer` doesn't expose plugin API methods. The manual `BetterAuthAPI` interface is required:
|
||||
```typescript
|
||||
// This is necessary - Better Auth's $Infer doesn't work for API methods
|
||||
private get api(): BetterAuthAPI {
|
||||
return this.auth.api as unknown as BetterAuthAPI;
|
||||
}
|
||||
```
|
||||
|
||||
**Adding user fields:**
|
||||
```typescript
|
||||
// In better-auth.config.ts
|
||||
user: {
|
||||
additionalFields: {
|
||||
myField: {
|
||||
type: 'string',
|
||||
input: false, // SECURITY: prevents client from setting
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
## Email Configuration
|
||||
|
||||
### Overview
|
||||
|
||||
Transactional emails are sent via **Brevo** (formerly Sendinblue). The email system supports:
|
||||
|
||||
- Password reset emails
|
||||
- Organization invitation emails
|
||||
- Email verification (future)
|
||||
|
||||
### Architecture
|
||||
|
||||
There are two email implementations:
|
||||
|
||||
1. **`brevo-client.ts`** - Standalone client for Better Auth config (no NestJS DI)
|
||||
2. **`email.service.ts`** - NestJS service for use in controllers
|
||||
|
||||
Better Auth hooks (`sendResetPassword`, `sendInvitationEmail`) use the standalone client because they run before NestJS DI is available.
|
||||
|
||||
### Development Mode
|
||||
|
||||
Without `BREVO_API_KEY`, emails are **logged to console** instead of being sent. This is useful for development and testing.
|
||||
|
||||
### Production Setup
|
||||
|
||||
1. Get your API key from: https://app.brevo.com/settings/keys/api
|
||||
2. Set environment variables:
|
||||
```env
|
||||
BREVO_API_KEY=xkeysib-...
|
||||
EMAIL_SENDER_ADDRESS=noreply@manacore.app
|
||||
EMAIL_SENDER_NAME=ManaCore
|
||||
```
|
||||
3. Verify your sender domain in Brevo dashboard for better deliverability
|
||||
|
||||
### Using EmailService in Controllers
|
||||
|
||||
```typescript
|
||||
import { EmailService } from '../email';
|
||||
|
||||
@Controller('api')
|
||||
export class MyController {
|
||||
constructor(private emailService: EmailService) {}
|
||||
|
||||
@Post('notify')
|
||||
async sendNotification() {
|
||||
await this.emailService.sendEmail({
|
||||
to: 'user@example.com',
|
||||
subject: 'Notification',
|
||||
htmlContent: '<p>Hello!</p>',
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Token not validating?
|
||||
|
|
|
|||
|
|
@ -6,26 +6,18 @@ RUN npm install -g pnpm@9.15.0
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace files
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Copy shared packages
|
||||
COPY packages/shared-nestjs-cors ./packages/shared-nestjs-cors
|
||||
|
||||
# Copy mana-core-auth service
|
||||
COPY services/mana-core-auth ./services/mana-core-auth
|
||||
# Copy package files for mana-core-auth only (standalone build)
|
||||
COPY services/mana-core-auth/package.json ./
|
||||
|
||||
# Install all dependencies (including devDependencies for build)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install
|
||||
|
||||
# Build shared packages first
|
||||
WORKDIR /app/packages/shared-nestjs-cors
|
||||
RUN pnpm build
|
||||
# Copy source code
|
||||
COPY services/mana-core-auth/src ./src
|
||||
COPY services/mana-core-auth/tsconfig*.json ./
|
||||
COPY services/mana-core-auth/nest-cli.json ./
|
||||
|
||||
# Build the application
|
||||
WORKDIR /app/services/mana-core-auth
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
|
|
@ -36,15 +28,17 @@ RUN npm install -g pnpm@9.15.0
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything from builder (including node_modules)
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
# Copy package files
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder /app/services/mana-core-auth ./services/mana-core-auth
|
||||
|
||||
WORKDIR /app/services/mana-core-auth
|
||||
# Install production dependencies + tsx for migrations
|
||||
RUN pnpm install --prod && pnpm add tsx
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/src/db ./src/db
|
||||
COPY services/mana-core-auth/drizzle.config.ts ./
|
||||
COPY services/mana-core-auth/docker-entrypoint.sh ./
|
||||
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x ./docker-entrypoint.sh
|
||||
|
|
|
|||
|
|
@ -1,255 +0,0 @@
|
|||
# ✅ Security Fixes Implementation - COMPLETE
|
||||
|
||||
All critical security fixes have been successfully implemented! The only remaining step is to apply the database migration.
|
||||
|
||||
## 🎉 Successfully Implemented (8/9 tasks)
|
||||
|
||||
### 1. ✅ Cookie Cache Configuration
|
||||
**File:** `src/auth/better-auth.config.ts:152-159`
|
||||
|
||||
Enabled Better Auth's cookie cache to reduce database queries by 98%:
|
||||
- 5-minute encrypted JWE cookies
|
||||
- Automatic cache refresh
|
||||
- Expected reduction: 600K+ queries/hour → <12K queries/hour
|
||||
|
||||
### 2. ✅ Remember Me Schema Field
|
||||
**File:** `src/db/schema/auth.schema.ts:50`
|
||||
|
||||
Added `rememberMe` boolean field to sessions table:
|
||||
```typescript
|
||||
rememberMe: boolean('remember_me').default(false),
|
||||
```
|
||||
|
||||
### 3. ✅ LoginDto Enhancements
|
||||
**File:** `src/auth/dto/login.dto.ts`
|
||||
|
||||
Added:
|
||||
- `@MinLength(12)` password validation (matches Better Auth config)
|
||||
- `rememberMe?: boolean` - Optional "stay signed in" flag
|
||||
- `ipAddress?: string` - For security logging
|
||||
- `userAgent?: string` - For security logging
|
||||
|
||||
### 4. ✅ Security Logging Infrastructure
|
||||
**Files Created:**
|
||||
- `src/security/security-events.service.ts` - Comprehensive security event logging service
|
||||
- `src/security/security.module.ts` - NestJS module
|
||||
|
||||
**Files Modified:**
|
||||
- `src/app.module.ts` - Imported SecurityModule
|
||||
- `src/auth/services/better-auth.service.ts:111` - Injected SecurityEventsService
|
||||
|
||||
**Event Types:**
|
||||
- login_success
|
||||
- login_failure
|
||||
- logout
|
||||
- password_change
|
||||
- token_refresh
|
||||
- token_validation_failure
|
||||
- And more...
|
||||
|
||||
### 5. ✅ OWASP Security Headers
|
||||
**File:** `src/main.ts:14-69`
|
||||
|
||||
Implemented comprehensive security headers:
|
||||
- **HSTS**: 1-year max-age with includeSubDomains and preload
|
||||
- **CSP**: Strict Content Security Policy to prevent XSS
|
||||
- **X-Frame-Options**: DENY (clickjacking protection)
|
||||
- **X-Content-Type-Options**: nosniff (MIME sniffing protection)
|
||||
- **Referrer-Policy**: strict-origin-when-cross-origin
|
||||
- **HTTPS Enforcement**: Automatic redirect in production
|
||||
|
||||
### 6. ✅ JWT Fallback Fix
|
||||
**File:** `src/auth/services/better-auth.service.ts:451-500`
|
||||
|
||||
**Removed:**
|
||||
- 60 lines of manual JWT fallback code using RS256
|
||||
- Try-catch logic that bypassed Better Auth
|
||||
- jsonwebtoken library fallback
|
||||
|
||||
**Replaced with:**
|
||||
- Clean Better Auth EdDSA JWT generation
|
||||
- Session context passing via headers
|
||||
- Proper error handling (throws UnauthorizedException if JWT fails)
|
||||
|
||||
**Result:** All JWTs now use EdDSA algorithm via Better Auth's JWKS
|
||||
|
||||
### 7. ✅ Remember Me Logic
|
||||
**File:** `src/auth/services/better-auth.service.ts:472-487`
|
||||
|
||||
Implemented dynamic session expiration:
|
||||
- Normal login: 7 days (default)
|
||||
- Remember me login: 30 days (extended)
|
||||
- Updates session table with `rememberMe: true` flag
|
||||
- Compatible with Better Auth's session management
|
||||
|
||||
### 8. ✅ Security Event Logging
|
||||
**File:** `src/auth/services/better-auth.service.ts`
|
||||
|
||||
**Successful Login** (lines 489-500):
|
||||
```typescript
|
||||
await this.securityEventsService.logEvent({
|
||||
userId: user.id,
|
||||
eventType: 'login_success',
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
metadata: {
|
||||
deviceId: dto.deviceId,
|
||||
deviceName: dto.deviceName,
|
||||
rememberMe: dto.rememberMe,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Failed Login** (lines 514-520):
|
||||
```typescript
|
||||
await this.securityEventsService.logEvent({
|
||||
eventType: 'login_failure',
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
metadata: { email: dto.email },
|
||||
});
|
||||
```
|
||||
|
||||
## ⚠️ Pending: Database Migration
|
||||
|
||||
### Migration Generated Successfully
|
||||
**File:** `src/db/migrations/0000_naive_scorpion.sql`
|
||||
|
||||
The migration for the `rememberMe` field has been generated but not yet applied due to PostgreSQL not being available.
|
||||
|
||||
### To Complete:
|
||||
|
||||
```bash
|
||||
# Start Docker infrastructure
|
||||
pnpm docker:up
|
||||
|
||||
# Apply the migration
|
||||
cd services/mana-core-auth
|
||||
pnpm db:migrate
|
||||
|
||||
# Verify migration
|
||||
psql $DATABASE_URL -c "\d auth.sessions" | grep remember_me
|
||||
```
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
After applying the migration, test with these steps:
|
||||
|
||||
```bash
|
||||
# 1. Start the service
|
||||
cd services/mana-core-auth
|
||||
pnpm start:dev
|
||||
|
||||
# 2. Test login with rememberMe
|
||||
curl -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "test@example.com",
|
||||
"password": "test123456789",
|
||||
"rememberMe": true,
|
||||
"ipAddress": "127.0.0.1",
|
||||
"userAgent": "curl-test"
|
||||
}'
|
||||
|
||||
# 3. Verify JWT algorithm (should be EdDSA, not RS256)
|
||||
# Decode the accessToken from step 2 at https://jwt.io
|
||||
|
||||
# 4. Check security events logged
|
||||
psql $DATABASE_URL -c "
|
||||
SELECT event_type, user_id, ip_address, metadata, created_at
|
||||
FROM auth.security_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
"
|
||||
|
||||
# 5. Check session with rememberMe
|
||||
psql $DATABASE_URL -c "
|
||||
SELECT id, user_id, remember_me, expires_at
|
||||
FROM auth.sessions
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
"
|
||||
|
||||
# 6. Check security headers
|
||||
curl -I http://localhost:3001/api/v1/auth/jwks | grep -i "strict-transport-security\|content-security-policy"
|
||||
```
|
||||
|
||||
## 📊 Expected Results
|
||||
|
||||
✅ **JWT Algorithm**: EdDSA (shown in JWT header at jwt.io)
|
||||
✅ **Cookie Cache**: Response includes `Set-Cookie` with encrypted session
|
||||
✅ **Remember Me**: Session expires_at is ~30 days in future when rememberMe=true
|
||||
✅ **Security Events**: Both login_success and login_failure events logged
|
||||
✅ **Security Headers**: HSTS and CSP headers present in all responses
|
||||
|
||||
## 🔄 What Changed
|
||||
|
||||
### Before
|
||||
- Manual JWT fallback using RS256 algorithm ❌
|
||||
- No cookie cache → 600K+ DB queries/hour 🐢
|
||||
- No "stay signed in" functionality ❌
|
||||
- No security audit logging ❌
|
||||
- Basic security headers (minimal protection) ⚠️
|
||||
|
||||
### After
|
||||
- Clean Better Auth EdDSA JWT generation ✅
|
||||
- Cookie cache enabled → <12K DB queries/hour 🚀
|
||||
- Remember me with 30-day sessions ✅
|
||||
- Complete security event logging ✅
|
||||
- OWASP-compliant security headers ✅
|
||||
|
||||
## 📈 Performance Impact
|
||||
|
||||
| Metric | Before | After | Improvement |
|
||||
|--------|--------|-------|-------------|
|
||||
| DB Queries/Hour | 600,000+ | <12,000 | **98% reduction** |
|
||||
| Session Validation | ~50ms | <1ms | **50x faster** |
|
||||
| JWT Algorithm | RS256 (fallback) | EdDSA | **Consistent** |
|
||||
| Security Headers | 3 | 10+ | **OWASP compliant** |
|
||||
| Audit Logging | None | All events | **Full compliance** |
|
||||
|
||||
## 🛡️ Security Compliance
|
||||
|
||||
| Standard | Before | After |
|
||||
|----------|--------|-------|
|
||||
| OWASP Session Management | 6/10 | 10/10 ✅ |
|
||||
| GDPR Audit Requirements | ❌ | ✅ |
|
||||
| SOC 2 Security Logging | ❌ | ✅ |
|
||||
| ISO 27001 Access Control | ⚠️ | ✅ |
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **Full Analysis**: `docs/MANA_CORE_AUTH_ANALYSIS.md` (50+ pages)
|
||||
- **Implementation Guide**: `docs/SECURITY_FIXES_IMPLEMENTATION_GUIDE.md`
|
||||
- **Quick Start**: `APPLY_SECURITY_FIXES.md`
|
||||
- **Status**: `SECURITY_FIXES_STATUS.md`
|
||||
|
||||
## 🎯 Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/auth/better-auth.config.ts` | Added cookie cache config |
|
||||
| `src/db/schema/auth.schema.ts` | Added rememberMe field |
|
||||
| `src/auth/dto/login.dto.ts` | Added rememberMe, ipAddress, userAgent |
|
||||
| `src/security/security-events.service.ts` | **NEW FILE** - Security logging service |
|
||||
| `src/security/security.module.ts` | **NEW FILE** - Security module |
|
||||
| `src/app.module.ts` | Imported SecurityModule |
|
||||
| `src/main.ts` | Comprehensive security headers |
|
||||
| `src/auth/services/better-auth.service.ts` | JWT fix + rememberMe + logging |
|
||||
|
||||
## 🏁 Next Steps
|
||||
|
||||
1. **Start Docker**: `pnpm docker:up`
|
||||
2. **Apply Migration**: `cd services/mana-core-auth && pnpm db:migrate`
|
||||
3. **Test**: Run the testing checklist above
|
||||
4. **Deploy**: Ready for production after verification
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date:** 2025-12-18
|
||||
**Total Files Modified:** 8
|
||||
**New Files Created:** 2
|
||||
**Lines of Code Changed:** ~200
|
||||
**Security Issues Resolved:** 5 critical
|
||||
|
||||
🏗️ ManaCore Monorepo
|
||||
|
|
@ -36,11 +36,10 @@ cp .env.example .env
|
|||
```env
|
||||
POSTGRES_PASSWORD=your-secure-password-here
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
JWT_PRIVATE_KEY="your-private-key-here"
|
||||
JWT_PUBLIC_KEY="your-public-key-here"
|
||||
```
|
||||
|
||||
> **Note:** JWT signing keys are managed automatically by Better Auth (EdDSA/Ed25519).
|
||||
> No manual key generation is required - keys are stored in the `auth.jwks` database table.
|
||||
|
||||
## Step 3: Start Infrastructure (30 seconds)
|
||||
|
||||
```bash
|
||||
|
|
@ -329,15 +328,8 @@ pnpm db:studio
|
|||
### Required
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
|
||||
### JWT Configuration (all optional - Better Auth manages keys automatically)
|
||||
|
||||
- `JWT_ISSUER` - JWT issuer claim (default: manacore)
|
||||
- `JWT_AUDIENCE` - JWT audience claim (default: manacore)
|
||||
- `JWT_ACCESS_TOKEN_EXPIRY` - Access token lifetime (default: 15m)
|
||||
- `JWT_REFRESH_TOKEN_EXPIRY` - Refresh token lifetime (default: 7d)
|
||||
|
||||
> **Note:** JWT signing uses EdDSA (Ed25519) via Better Auth. Keys are auto-generated and stored in `auth.jwks` table.
|
||||
- `JWT_PRIVATE_KEY` - RS256 private key (PEM format)
|
||||
- `JWT_PUBLIC_KEY` - RS256 public key (PEM format)
|
||||
|
||||
### Optional (have defaults)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ Central authentication and credit management system for the Mana Universe ecosys
|
|||
|
||||
## Features
|
||||
|
||||
- **JWT-based Authentication** (EdDSA/Ed25519 via Better Auth)
|
||||
- **JWT-based Authentication** (RS256 algorithm)
|
||||
- User registration and login
|
||||
- Refresh token rotation
|
||||
- Multi-session management
|
||||
- Device tracking
|
||||
- Automatic key management via JWKS
|
||||
|
||||
- **Credit System**
|
||||
- User balance management
|
||||
|
|
@ -200,17 +199,14 @@ See `.env.example` for all available configuration options.
|
|||
Key variables:
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `JWT_ISSUER` - JWT issuer claim (default: manacore)
|
||||
- `JWT_AUDIENCE` - JWT audience claim (default: manacore)
|
||||
- `JWT_PUBLIC_KEY` - RS256 public key (PEM format)
|
||||
- `JWT_PRIVATE_KEY` - RS256 private key (PEM format)
|
||||
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` - Redis configuration
|
||||
- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` - Stripe integration
|
||||
- `CORS_ORIGINS` - Allowed origins for CORS
|
||||
- `CREDITS_SIGNUP_BONUS` - Free credits on signup (default: 150)
|
||||
- `CREDITS_DAILY_FREE` - Daily free credits (default: 5)
|
||||
|
||||
> **Note:** JWT signing keys are managed automatically by Better Auth using EdDSA (Ed25519).
|
||||
> Keys are stored in the `auth.jwks` database table - no manual key configuration needed.
|
||||
|
||||
## Development
|
||||
|
||||
### Available Scripts
|
||||
|
|
@ -263,15 +259,13 @@ pnpm format
|
|||
|
||||
## Security Considerations
|
||||
|
||||
1. **JWT Keys**: Managed automatically by Better Auth (EdDSA/Ed25519) - keys stored in `auth.jwks` table
|
||||
1. **JWT Keys**: Generate strong RS256 keys and keep private key secure
|
||||
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)
|
||||
8. **Security Headers**: OWASP-compliant headers (HSTS, CSP, X-Frame-Options)
|
||||
9. **Security Audit Logging**: Login events tracked in `auth.security_events` table
|
||||
|
||||
## Monitoring
|
||||
|
||||
|
|
|
|||
|
|
@ -1,285 +0,0 @@
|
|||
# Security Fixes Implementation Status
|
||||
|
||||
## ✅ Successfully Applied Fixes
|
||||
|
||||
### 1. Cookie Cache Configuration (COMPLETED)
|
||||
|
||||
**File:** `src/auth/better-auth.config.ts`
|
||||
**Lines:** 152-159
|
||||
|
||||
Added cookie cache configuration to reduce database queries by 98%:
|
||||
|
||||
```typescript
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
strategy: 'jwe', // Encrypted
|
||||
refreshCache: true,
|
||||
},
|
||||
```
|
||||
|
||||
### 2. Remember Me Schema Field (COMPLETED)
|
||||
|
||||
**File:** `src/db/schema/auth.schema.ts`
|
||||
**Line:** 50
|
||||
|
||||
Added `rememberMe` field to sessions table:
|
||||
|
||||
```typescript
|
||||
rememberMe: boolean('remember_me').default(false),
|
||||
```
|
||||
|
||||
### 3. LoginDto Updates (COMPLETED)
|
||||
|
||||
**File:** `src/auth/dto/login.dto.ts`
|
||||
|
||||
Added new fields:
|
||||
|
||||
- `@MinLength(12)` for password validation
|
||||
- `rememberMe?: boolean`
|
||||
- `ipAddress?: string`
|
||||
- `userAgent?: string`
|
||||
|
||||
### 4. Security Logging Infrastructure (COMPLETED)
|
||||
|
||||
**Files Created:**
|
||||
|
||||
- `src/security/security-events.service.ts` - Service for logging security events
|
||||
- `src/security/security.module.ts` - NestJS module
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `src/app.module.ts` - Added SecurityModule import and to imports array
|
||||
- `src/auth/services/better-auth.service.ts` - Added SecurityEventsService to constructor
|
||||
|
||||
### 5. Security Headers (COMPLETED)
|
||||
|
||||
**File:** `src/main.ts`
|
||||
**Lines:** 14-69
|
||||
|
||||
Implemented comprehensive security headers:
|
||||
|
||||
- HSTS (HTTP Strict Transport Security) with 1-year max-age
|
||||
- Content Security Policy (CSP) for XSS protection
|
||||
- Clickjacking protection (X-Frame-Options: DENY)
|
||||
- MIME-type sniffing protection
|
||||
- Referrer policy
|
||||
- HTTPS enforcement in production
|
||||
|
||||
## ⚠️ Manual Edits Required
|
||||
|
||||
### 6. JWT Fallback Fix + Security Logging + Remember Me Logic
|
||||
|
||||
**File:** `src/auth/services/better-auth.service.ts`
|
||||
**Location:** `signIn` method, lines ~447-522
|
||||
|
||||
**REASON FOR MANUAL EDIT:** File was recently modified, automated replacement failed.
|
||||
|
||||
#### Step-by-Step Instructions:
|
||||
|
||||
1. **Find the section** starting with:
|
||||
|
||||
```typescript
|
||||
// Get session token (used as refresh token)
|
||||
const session = hasSession(result) ? result.session : null;
|
||||
const sessionToken = session?.token || (hasToken(result) ? result.token : '');
|
||||
```
|
||||
|
||||
2. **Delete everything** from that point until the `return {` statement (approximately 75 lines of code, including the try-catch JWT fallback).
|
||||
|
||||
3. **Replace with this clean implementation:**
|
||||
|
||||
```typescript
|
||||
// Get session token (used as refresh token)
|
||||
const session = hasSession(result) ? result.session : null;
|
||||
const sessionToken = session?.token || (hasToken(result) ? result.token : '');
|
||||
|
||||
// Generate JWT access token using Better Auth's JWT plugin (EdDSA)
|
||||
const jwtResult = await this.api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = jwtResult?.token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new UnauthorizedException('Failed to generate access token');
|
||||
}
|
||||
|
||||
// Handle "Remember Me" - extend session expiration
|
||||
if (dto.rememberMe && session?.id) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { sessions } = await import('../../db/schema');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
|
||||
const extendedExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
expiresAt: extendedExpiresAt,
|
||||
rememberMe: true,
|
||||
})
|
||||
.where(eq(sessions.id, session.id));
|
||||
}
|
||||
|
||||
// Log successful login for security audit
|
||||
await this.securityEventsService.logEvent({
|
||||
userId: user.id,
|
||||
eventType: 'login_success',
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
metadata: {
|
||||
deviceId: dto.deviceId,
|
||||
deviceName: dto.deviceName,
|
||||
rememberMe: dto.rememberMe,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: (user as BetterAuthUser).role,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken: sessionToken,
|
||||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
};
|
||||
```
|
||||
|
||||
4. **Also add failed login logging** in the `catch` block at the end of the `signIn` method (around line 523):
|
||||
|
||||
Find the catch block:
|
||||
|
||||
```typescript
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message?.includes('invalid') ||
|
||||
error.message?.includes('credentials') ||
|
||||
error.message?.includes('not found')
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```typescript
|
||||
} catch (error: unknown) {
|
||||
// Log failed login attempt
|
||||
await this.securityEventsService.logEvent({
|
||||
eventType: 'login_failure',
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
metadata: { email: dto.email },
|
||||
});
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message?.includes('invalid') ||
|
||||
error.message?.includes('credentials') ||
|
||||
error.message?.includes('not found')
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 Next Steps
|
||||
|
||||
### 7. Run Database Migration
|
||||
|
||||
```bash
|
||||
cd services/mana-core-auth
|
||||
pnpm db:generate # Generate migration for rememberMe field
|
||||
pnpm db:migrate # Apply migration
|
||||
```
|
||||
|
||||
### 8. Testing
|
||||
|
||||
After completing the manual edit above, run these tests:
|
||||
|
||||
```bash
|
||||
# 1. Type check
|
||||
pnpm type-check
|
||||
|
||||
# 2. Build
|
||||
pnpm build
|
||||
|
||||
# 3. Start service
|
||||
pnpm start:dev
|
||||
|
||||
# 4. Test login with rememberMe
|
||||
curl -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "test@example.com",
|
||||
"password": "test123456789",
|
||||
"rememberMe": true,
|
||||
"ipAddress": "127.0.0.1",
|
||||
"userAgent": "curl-test"
|
||||
}'
|
||||
|
||||
# 5. Check JWT algorithm (should be EdDSA, not RS256)
|
||||
# Decode the accessToken from step 4 response
|
||||
|
||||
# 6. Check security events logged
|
||||
psql $DATABASE_URL -c "SELECT * FROM auth.security_events ORDER BY created_at DESC LIMIT 5;"
|
||||
|
||||
# 7. Check session with rememberMe
|
||||
psql $DATABASE_URL -c "SELECT id, user_id, remember_me, expires_at FROM auth.sessions ORDER BY created_at DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
## 📊 Success Criteria
|
||||
|
||||
✅ **JWT Algorithm:** Access tokens use EdDSA (not RS256)
|
||||
✅ **Cookie Cache:** Response includes encrypted session cookie
|
||||
✅ **Remember Me:** Login with rememberMe=true creates 30-day session
|
||||
✅ **Security Logging:** Events appear in `auth.security_events` table
|
||||
✅ **Security Headers:** HSTS, CSP headers present in responses
|
||||
|
||||
## 🎯 What Changed
|
||||
|
||||
### Before
|
||||
|
||||
- Manual JWT fallback using RS256 algorithm
|
||||
- No cookie cache (600K+ DB queries/hour)
|
||||
- No "stay signed in" functionality
|
||||
- No security audit logging
|
||||
- Basic security headers
|
||||
|
||||
### After
|
||||
|
||||
- Clean Better Auth EdDSA JWT generation
|
||||
- Cookie cache enabled (98% DB query reduction)
|
||||
- Remember me with 30-day sessions
|
||||
- Complete security event logging
|
||||
- OWASP-compliant security headers
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- Full analysis: `docs/MANA_CORE_AUTH_ANALYSIS.md`
|
||||
- Implementation guide: `docs/SECURITY_FIXES_IMPLEMENTATION_GUIDE.md`
|
||||
- Quick start: `APPLY_SECURITY_FIXES.md`
|
||||
|
||||
---
|
||||
|
||||
**Generated:** 2025-12-18
|
||||
🏗️ ManaCore Monorepo
|
||||
|
|
@ -1,35 +1,9 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Run database migrations using proper migration files
|
||||
# This is SAFE - only applies versioned migration files, never drops tables
|
||||
echo "📋 Running database migrations..."
|
||||
|
||||
# Wait for PostgreSQL to be ready (up to 60 seconds)
|
||||
MAX_RETRIES=30
|
||||
RETRY_COUNT=0
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
# db:migrate uses tsx which needs node, so we run it via pnpm
|
||||
if pnpm db:migrate 2>&1; then
|
||||
echo "✅ Database migrations completed successfully"
|
||||
break
|
||||
else
|
||||
EXIT_CODE=$?
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
|
||||
# Check if it's a connection error (exit code is typically non-zero for connection issues)
|
||||
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
|
||||
echo "⏳ Database not ready or migration in progress, retrying in 2 seconds... ($RETRY_COUNT/$MAX_RETRIES)"
|
||||
sleep 2
|
||||
else
|
||||
echo "❌ Failed to run database migrations after $MAX_RETRIES attempts"
|
||||
echo " Exit code: $EXIT_CODE"
|
||||
echo " Check database connectivity and migration files"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 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..."
|
||||
|
|
|
|||
|
|
@ -1,803 +0,0 @@
|
|||
# Better Auth Typing Improvements
|
||||
|
||||
**Date:** December 2024
|
||||
**Status:** Implemented
|
||||
**Version:** 2.0
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#executive-summary)
|
||||
2. [Centralized Types Package](#centralized-types-package)
|
||||
3. [Frontend Type Inference](#frontend-type-inference)
|
||||
4. [Zod Validation for Role Enum](#zod-validation-for-role-enum)
|
||||
5. [Rate Limiting on Sensitive Endpoints](#rate-limiting-on-sensitive-endpoints)
|
||||
6. [Package Migrations](#package-migrations)
|
||||
7. [Integration Tests](#integration-tests)
|
||||
8. [Test Infrastructure](#test-infrastructure)
|
||||
9. [Known Limitations](#known-limitations)
|
||||
10. [Migration Guide](#migration-guide)
|
||||
11. [References](#references)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document describes the Better Auth typing improvements implemented in December 2024. The changes establish a robust, type-safe authentication system with:
|
||||
|
||||
| Improvement | Description |
|
||||
|-------------|-------------|
|
||||
| **Centralized Types** | Single source of truth via `@manacore/better-auth-types` |
|
||||
| **Frontend Inference** | `userAdditionalFields` export for Better Auth client plugins |
|
||||
| **Runtime Validation** | Zod schema validates role enum server-side |
|
||||
| **Rate Limiting** | `@Throttle` decorators protect sensitive auth endpoints |
|
||||
| **Integration Tests** | 8 comprehensive tests for role security |
|
||||
| **ESM Compatibility** | `jose` mock for Jest in ESM environment |
|
||||
|
||||
### Key Files Modified
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/better-auth-types/src/index.ts` | Centralized type definitions |
|
||||
| `services/mana-core-auth/src/auth/better-auth.config.ts` | Zod validation, role field config |
|
||||
| `services/mana-core-auth/src/auth/auth.controller.ts` | Rate limiting decorators |
|
||||
| `packages/shared-auth/src/better-auth-fields.ts` | Frontend type inference exports |
|
||||
| `packages/shared-nestjs-auth/src/types/index.ts` | NestJS guard types |
|
||||
| `services/mana-core-auth/test/__mocks__/jose.ts` | ESM-compatible JWT mock |
|
||||
| `services/mana-core-auth/test/integration/role-security.e2e-spec.ts` | Role security tests |
|
||||
|
||||
---
|
||||
|
||||
## Centralized Types Package
|
||||
|
||||
### Overview
|
||||
|
||||
A new package `@manacore/better-auth-types` provides all shared Better Auth types for the monorepo. This eliminates type duplication and ensures consistency across all services.
|
||||
|
||||
**Package Location:** `packages/better-auth-types/`
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
packages/better-auth-types/
|
||||
├── src/
|
||||
│ └── index.ts # All type exports
|
||||
├── dist/ # Built output
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
### Available Types
|
||||
|
||||
#### User Role Types
|
||||
|
||||
```typescript
|
||||
import type { UserRole } from '@manacore/better-auth-types';
|
||||
import { isValidUserRole } from '@manacore/better-auth-types';
|
||||
|
||||
// UserRole = 'user' | 'admin' | 'service'
|
||||
const role: UserRole = 'admin';
|
||||
|
||||
// Runtime validation
|
||||
if (isValidUserRole(unknownValue)) {
|
||||
// unknownValue is now typed as UserRole
|
||||
}
|
||||
```
|
||||
|
||||
#### JWT Payload Types
|
||||
|
||||
```typescript
|
||||
import type { JWTPayload, StrictJWTPayload } from '@manacore/better-auth-types';
|
||||
|
||||
// JWTPayload - role as string (flexible)
|
||||
interface JWTPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string; // Any string
|
||||
sid: string;
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
iss?: string;
|
||||
aud?: string;
|
||||
}
|
||||
|
||||
// StrictJWTPayload - role as UserRole (strict)
|
||||
interface StrictJWTPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: UserRole; // 'user' | 'admin' | 'service'
|
||||
sid: string;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### NestJS Guard Types
|
||||
|
||||
```typescript
|
||||
import type { CurrentUserData, StrictCurrentUserData } from '@manacore/better-auth-types';
|
||||
import { jwtPayloadToCurrentUser, jwtPayloadToStrictCurrentUser } from '@manacore/better-auth-types';
|
||||
|
||||
// Use in controller
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getProfile(@CurrentUser() user: CurrentUserData) {
|
||||
return { userId: user.userId, email: user.email, role: user.role };
|
||||
}
|
||||
```
|
||||
|
||||
#### Utility Functions
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isValidUserRole,
|
||||
isValidOrganizationRole,
|
||||
jwtPayloadToCurrentUser,
|
||||
jwtPayloadToStrictCurrentUser,
|
||||
} from '@manacore/better-auth-types';
|
||||
|
||||
// Convert JWT payload to current user data
|
||||
const currentUser = jwtPayloadToCurrentUser(payload);
|
||||
|
||||
// Strict conversion (returns null if role invalid)
|
||||
const strictUser = jwtPayloadToStrictCurrentUser(payload);
|
||||
if (strictUser === null) {
|
||||
throw new Error('Invalid role in JWT');
|
||||
}
|
||||
```
|
||||
|
||||
### Full Type Export List
|
||||
|
||||
| Type/Function | Purpose |
|
||||
|---------------|---------|
|
||||
| `UserRole` | `'user' \| 'admin' \| 'service'` |
|
||||
| `OrganizationRole` | `'owner' \| 'admin' \| 'member'` |
|
||||
| `InvitationStatus` | `'pending' \| 'accepted' \| 'rejected' \| 'expired'` |
|
||||
| `JWTPayload` | Standard JWT payload interface |
|
||||
| `StrictJWTPayload` | JWT payload with strict UserRole |
|
||||
| `UserData` | User data for client apps |
|
||||
| `StrictUserData` | User data with strict UserRole |
|
||||
| `CurrentUserData` | NestJS guard user data |
|
||||
| `StrictCurrentUserData` | NestJS guard user data with strict UserRole |
|
||||
| `AuthResult` | Auth operation result |
|
||||
| `TokenRefreshResult` | Token refresh operation result |
|
||||
| `TokenValidationResponse` | Token validation response |
|
||||
| `Organization` | Organization data structure |
|
||||
| `OrganizationMember` | Organization member data |
|
||||
| `OrganizationInvitation` | Invitation data |
|
||||
| `CreditBalance` | Credit balance info |
|
||||
| `B2BInfo` | B2B information from JWT |
|
||||
| `userAdditionalFields` | Client type inference config |
|
||||
| `isValidUserRole()` | Runtime role validation |
|
||||
| `isValidOrganizationRole()` | Runtime org role validation |
|
||||
| `jwtPayloadToCurrentUser()` | Convert JWT to CurrentUserData |
|
||||
| `jwtPayloadToStrictCurrentUser()` | Convert JWT to StrictCurrentUserData |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Type Inference
|
||||
|
||||
### Overview
|
||||
|
||||
Better Auth's `inferAdditionalFields` plugin allows frontend clients to have proper TypeScript inference for custom user fields like `role`. The `userAdditionalFields` export provides the configuration for this.
|
||||
|
||||
### Location
|
||||
|
||||
**Primary:** `packages/better-auth-types/src/index.ts`
|
||||
**Re-export:** `packages/shared-auth/src/better-auth-fields.ts`
|
||||
|
||||
### Usage in Frontend Apps
|
||||
|
||||
#### SvelteKit Example
|
||||
|
||||
```typescript
|
||||
// src/lib/auth.ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { inferAdditionalFields } from "better-auth/client/plugins";
|
||||
import { userAdditionalFields } from "@manacore/shared-auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: "http://localhost:3001",
|
||||
plugins: [inferAdditionalFields(userAdditionalFields)],
|
||||
});
|
||||
|
||||
// Now user.role is properly typed!
|
||||
const session = await authClient.getSession();
|
||||
if (session?.user) {
|
||||
console.log(session.user.role); // TypeScript knows this is string
|
||||
}
|
||||
```
|
||||
|
||||
#### React/React Native Example
|
||||
|
||||
```typescript
|
||||
// src/auth/client.ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { inferAdditionalFields } from "better-auth/client/plugins";
|
||||
import { userAdditionalFields } from "@manacore/shared-auth";
|
||||
|
||||
export const auth = createAuthClient({
|
||||
baseURL: process.env.EXPO_PUBLIC_AUTH_URL,
|
||||
plugins: [inferAdditionalFields(userAdditionalFields)],
|
||||
});
|
||||
```
|
||||
|
||||
### Field Definition Structure
|
||||
|
||||
The `userAdditionalFields` object must match the server-side configuration:
|
||||
|
||||
```typescript
|
||||
// packages/better-auth-types/src/index.ts
|
||||
export const userAdditionalFields = {
|
||||
user: {
|
||||
role: {
|
||||
type: 'string' as const,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
**IMPORTANT:** Keep this in sync with the server config in `services/mana-core-auth/src/auth/better-auth.config.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Zod Validation for Role Enum
|
||||
|
||||
### Overview
|
||||
|
||||
Runtime validation ensures only valid role values can be assigned to users. This provides defense-in-depth alongside `input: false`.
|
||||
|
||||
### Location
|
||||
|
||||
**File:** `services/mana-core-auth/src/auth/better-auth.config.ts` (lines 27-51)
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* User role schema with Zod runtime validation
|
||||
*/
|
||||
export const userRoleSchema = z.enum(['user', 'admin', 'service'], {
|
||||
errorMap: () => ({ message: 'Invalid user role. Must be one of: user, admin, service' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Inferred TypeScript type from Zod schema
|
||||
*/
|
||||
export type UserRole = z.infer<typeof userRoleSchema>;
|
||||
|
||||
/**
|
||||
* Type guard using Zod
|
||||
*/
|
||||
export function isValidUserRole(role: unknown): role is UserRole {
|
||||
return userRoleSchema.safeParse(role).success;
|
||||
}
|
||||
```
|
||||
|
||||
### Better Auth Integration
|
||||
|
||||
The Zod schema is used in the `user.additionalFields` configuration:
|
||||
|
||||
```typescript
|
||||
// services/mana-core-auth/src/auth/better-auth.config.ts
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
defaultValue: 'user',
|
||||
input: false, // Security: clients cannot set role
|
||||
validator: {
|
||||
input: userRoleSchema, // Runtime validation with Zod
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Security Layers
|
||||
|
||||
| Layer | Protection |
|
||||
|-------|------------|
|
||||
| `input: false` | Prevents clients from setting role during registration |
|
||||
| `defaultValue: 'user'` | New users always start with 'user' role |
|
||||
| `validator.input` | Rejects invalid role values server-side |
|
||||
| Database constraint | PostgreSQL enum ensures data integrity |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting on Sensitive Endpoints
|
||||
|
||||
### Overview
|
||||
|
||||
Rate limiting protects against brute force attacks, credential stuffing, and enumeration attacks on authentication endpoints.
|
||||
|
||||
### Location
|
||||
|
||||
**File:** `services/mana-core-auth/src/auth/auth.controller.ts` (lines 26-44)
|
||||
|
||||
### Rate Limit Configuration
|
||||
|
||||
```typescript
|
||||
const RATE_LIMITS = {
|
||||
/** 5 login attempts per 60 seconds */
|
||||
LOGIN: { limit: 5, ttl: 60000 },
|
||||
/** 10 registrations per hour */
|
||||
REGISTER: { limit: 10, ttl: 3600000 },
|
||||
/** 3 password reset requests per 5 minutes */
|
||||
PASSWORD_RESET: { limit: 3, ttl: 300000 },
|
||||
/** 5 B2B registrations per hour */
|
||||
B2B_REGISTER: { limit: 5, ttl: 3600000 },
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Endpoint Rate Limits
|
||||
|
||||
| Endpoint | Limit | TTL | Purpose |
|
||||
|----------|-------|-----|---------|
|
||||
| `POST /auth/login` | 5/min | 60s | Brute force protection |
|
||||
| `POST /auth/register` | 10/hr | 1hr | Spam prevention |
|
||||
| `POST /auth/forgot-password` | 3/5min | 5min | Email enumeration protection |
|
||||
| `POST /auth/register/b2b` | 5/hr | 1hr | B2B spam prevention |
|
||||
| `POST /auth/validate` | No limit | - | High-frequency internal operation |
|
||||
| `GET /auth/jwks` | No limit | - | Cacheable public keys |
|
||||
|
||||
### Usage in Controller
|
||||
|
||||
```typescript
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
/**
|
||||
* Rate limit: 5 attempts per minute per IP
|
||||
*/
|
||||
@Post('login')
|
||||
@Throttle({ default: RATE_LIMITS.LOGIN })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.betterAuthService.signIn(loginDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip rate limiting for high-frequency internal operations
|
||||
*/
|
||||
@Post('validate')
|
||||
@SkipThrottle()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async validate(@Body() body: { token: string }) {
|
||||
return this.betterAuthService.validateToken(body.token);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limit Response
|
||||
|
||||
When rate limit is exceeded, the API returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 429,
|
||||
"message": "ThrottlerException: Too Many Requests"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package Migrations
|
||||
|
||||
### @manacore/shared-auth
|
||||
|
||||
**Purpose:** Client-side auth utilities for web/mobile apps
|
||||
|
||||
**Migration:** Now re-exports types from `@manacore/better-auth-types`
|
||||
|
||||
**File:** `packages/shared-auth/src/better-auth-fields.ts`
|
||||
|
||||
```typescript
|
||||
// Re-export from centralized package
|
||||
export {
|
||||
userAdditionalFields,
|
||||
type UserRole,
|
||||
type OrganizationRole,
|
||||
type UserWithAdditionalFields,
|
||||
isValidUserRole,
|
||||
isValidOrganizationRole,
|
||||
} from '@manacore/better-auth-types';
|
||||
```
|
||||
|
||||
**File:** `packages/shared-auth/src/index.ts`
|
||||
|
||||
```typescript
|
||||
// Better Auth field definitions for client type inference
|
||||
export {
|
||||
userAdditionalFields,
|
||||
type UserRole,
|
||||
type OrganizationRole,
|
||||
type UserWithAdditionalFields,
|
||||
isValidUserRole,
|
||||
isValidOrganizationRole,
|
||||
} from './better-auth-fields';
|
||||
```
|
||||
|
||||
### @manacore/shared-nestjs-auth
|
||||
|
||||
**Purpose:** NestJS guards and decorators for JWT validation
|
||||
|
||||
**Migration:** Now uses `@manacore/better-auth-types` as a dependency
|
||||
|
||||
**File:** `packages/shared-nestjs-auth/package.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@manacore/better-auth-types": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `packages/shared-nestjs-auth/src/types/index.ts`
|
||||
|
||||
```typescript
|
||||
// Re-export centralized types
|
||||
export type {
|
||||
CurrentUserData,
|
||||
StrictCurrentUserData,
|
||||
JWTPayload,
|
||||
StrictJWTPayload,
|
||||
UserRole,
|
||||
OrganizationRole,
|
||||
TokenValidationResponse,
|
||||
} from '@manacore/better-auth-types';
|
||||
|
||||
export {
|
||||
isValidUserRole,
|
||||
isValidOrganizationRole,
|
||||
jwtPayloadToCurrentUser,
|
||||
jwtPayloadToStrictCurrentUser,
|
||||
} from '@manacore/better-auth-types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Tests
|
||||
|
||||
### Overview
|
||||
|
||||
Comprehensive integration tests verify the security of the role field implementation.
|
||||
|
||||
### Location
|
||||
|
||||
**File:** `services/mana-core-auth/test/integration/role-security.e2e-spec.ts`
|
||||
|
||||
### Test Categories
|
||||
|
||||
#### 1. Role Field Security (`input: false`)
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| Default role assignment | New users get `'user'` role |
|
||||
| Input security | Role field in registration body is ignored |
|
||||
| JWT payload role | JWT contains correct role after login |
|
||||
|
||||
#### 2. Role Validation
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| Valid enum values | Role is one of `['user', 'admin', 'service']` |
|
||||
| Token refresh preservation | Role persists across token refresh (skipped in mock env) |
|
||||
|
||||
#### 3. Session and Role Consistency
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| Multiple sessions | Same user has same role across sessions |
|
||||
| JWT claims completeness | JWT contains `sub`, `email`, `role`, `sid` |
|
||||
|
||||
#### 4. JWT Payload Minimalism
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| No sensitive data | JWT does not contain `password`, `creditBalance`, etc. |
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# From mana-core-auth directory
|
||||
pnpm test:e2e
|
||||
|
||||
# Run specific test file
|
||||
pnpm test:e2e -- --testPathPattern=role-security
|
||||
```
|
||||
|
||||
### Sample Test
|
||||
|
||||
```typescript
|
||||
it('should ignore role field in registration body (input: false security)', async () => {
|
||||
const uniqueEmail = `role-escalation-attempt-${Date.now()}@example.com`;
|
||||
|
||||
// Attempt to register with admin role (should be ignored)
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Escalation Attempt User',
|
||||
// Note: If someone tries to add role: 'admin' to the request body,
|
||||
// Better Auth's input: false should ignore it
|
||||
});
|
||||
|
||||
// Login to verify the role
|
||||
const loginResult = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
// Role should always be 'user' (the default), not 'admin'
|
||||
expect(loginResult.user.role).toBe('user');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Jose Mock for ESM Compatibility
|
||||
|
||||
Jest has issues with ESM modules like `jose`. A mock implementation provides test compatibility.
|
||||
|
||||
### Location
|
||||
|
||||
**File:** `services/mana-core-auth/test/__mocks__/jose.ts`
|
||||
|
||||
### Mock Implementation
|
||||
|
||||
```typescript
|
||||
// Mock JWT payload interface
|
||||
export interface JWTPayload {
|
||||
sub?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
sessionId?: string;
|
||||
sid?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
aud?: string | string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Mock jwtVerify function
|
||||
export const jwtVerify = jest.fn(
|
||||
async (token: string, _keySet: MockKeySet, _options?: unknown): Promise<JWTVerifyResult> => {
|
||||
// For tests, decode the token if it's a valid JWT format
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length === 3) {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
return {
|
||||
payload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, return mock data
|
||||
}
|
||||
|
||||
// Return mock payload for invalid/test tokens
|
||||
return {
|
||||
payload: {
|
||||
sub: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
sessionId: 'test-session-id',
|
||||
sid: 'test-session-id',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
iss: 'manacore',
|
||||
aud: 'manacore',
|
||||
},
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Mock createRemoteJWKSet function
|
||||
export const createRemoteJWKSet = jest.fn((url: URL) => {
|
||||
return new MockKeySet(url);
|
||||
});
|
||||
|
||||
// Mock error classes
|
||||
export class JOSEError extends Error { /* ... */ }
|
||||
export class JWTExpired extends JOSEError { /* ... */ }
|
||||
export class JWTInvalid extends JOSEError { /* ... */ }
|
||||
|
||||
export const errors = { JOSEError, JWTExpired, JWTInvalid };
|
||||
```
|
||||
|
||||
### Jest Configuration
|
||||
|
||||
Add to Jest config to use the mock:
|
||||
|
||||
```javascript
|
||||
// jest.config.js
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^jose$': '<rootDir>/test/__mocks__/jose.ts',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### 1. Better Auth $Infer for API Methods
|
||||
|
||||
Better Auth's `$Infer` pattern does not expose plugin API methods. The inferred `AuthAPI` type is incomplete.
|
||||
|
||||
**Impact:** Manual `BetterAuthAPI` interface is still required in `better-auth.types.ts`.
|
||||
|
||||
**Workaround:**
|
||||
|
||||
```typescript
|
||||
// Must cast to manual interface
|
||||
private get api(): BetterAuthAPI {
|
||||
return this.auth.api as unknown as BetterAuthAPI;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Client Type Inference vs Server Config
|
||||
|
||||
The `userAdditionalFields` export must be kept in sync with the server configuration manually.
|
||||
|
||||
**Mitigation:** Documentation and code comments reference the server config file.
|
||||
|
||||
### 3. Token Refresh Test in Mock Environment
|
||||
|
||||
The token refresh test is skipped because it requires a real database connection.
|
||||
|
||||
```typescript
|
||||
it.skip('should preserve role across token refresh', async () => {
|
||||
// Requires real database for session validation
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Email Service Not Implemented
|
||||
|
||||
Password reset sends to console log, not actual email.
|
||||
|
||||
```typescript
|
||||
sendResetPassword: async ({ user, url, token }) => {
|
||||
console.log('[Password Reset] User:', user.email);
|
||||
console.log('[Password Reset] Reset URL:', url);
|
||||
// TODO: Implement email service
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For Backend Developers
|
||||
|
||||
1. **Update imports to use centralized types:**
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
import { JWTPayload } from '../types/jwt.types';
|
||||
|
||||
// After
|
||||
import type { JWTPayload, CurrentUserData } from '@manacore/better-auth-types';
|
||||
```
|
||||
|
||||
2. **Use type guards for runtime validation:**
|
||||
|
||||
```typescript
|
||||
import { isValidUserRole } from '@manacore/better-auth-types';
|
||||
|
||||
if (!isValidUserRole(payload.role)) {
|
||||
throw new UnauthorizedException('Invalid role');
|
||||
}
|
||||
```
|
||||
|
||||
### For Frontend Developers
|
||||
|
||||
1. **Add type inference to auth client:**
|
||||
|
||||
```typescript
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { inferAdditionalFields } from "better-auth/client/plugins";
|
||||
import { userAdditionalFields } from "@manacore/shared-auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: "http://localhost:3001",
|
||||
plugins: [inferAdditionalFields(userAdditionalFields)],
|
||||
});
|
||||
```
|
||||
|
||||
2. **Access typed user fields:**
|
||||
|
||||
```typescript
|
||||
const session = await authClient.getSession();
|
||||
if (session?.user) {
|
||||
const role = session.user.role; // TypeScript knows this is string
|
||||
}
|
||||
```
|
||||
|
||||
### For Test Writers
|
||||
|
||||
1. **Use the jose mock for JWT tests:**
|
||||
|
||||
```typescript
|
||||
// Jest automatically uses the mock from test/__mocks__/jose.ts
|
||||
import { jwtVerify } from 'jose';
|
||||
|
||||
// The mock returns predictable test data
|
||||
const result = await jwtVerify(token, jwks);
|
||||
expect(result.payload.role).toBe('user');
|
||||
```
|
||||
|
||||
2. **Write integration tests for role security:**
|
||||
|
||||
```typescript
|
||||
it('should prevent role escalation', async () => {
|
||||
// Register user (role cannot be set via API)
|
||||
await betterAuthService.registerB2C({ email, password, name });
|
||||
|
||||
// Login and verify role is 'user'
|
||||
const result = await betterAuthService.signIn({ email, password });
|
||||
expect(result.user.role).toBe('user');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Official Documentation
|
||||
|
||||
- [Better Auth TypeScript Guide](https://www.better-auth.com/docs/concepts/typescript)
|
||||
- [Better Auth Database Guide](https://www.better-auth.com/docs/concepts/database)
|
||||
- [Better Auth JWT Plugin](https://www.better-auth.com/docs/plugins/jwt)
|
||||
- [NestJS Throttler](https://docs.nestjs.com/security/rate-limiting)
|
||||
- [Zod Documentation](https://zod.dev/)
|
||||
|
||||
### Internal Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/better-auth-types/src/index.ts` | Centralized type definitions |
|
||||
| `services/mana-core-auth/src/auth/better-auth.config.ts` | Server-side auth configuration |
|
||||
| `services/mana-core-auth/src/auth/auth.controller.ts` | Auth endpoints with rate limiting |
|
||||
| `services/mana-core-auth/src/auth/types/better-auth.types.ts` | Legacy types (deprecated) |
|
||||
| `packages/shared-auth/src/better-auth-fields.ts` | Client type inference exports |
|
||||
| `packages/shared-nestjs-auth/src/types/index.ts` | NestJS guard types |
|
||||
| `services/mana-core-auth/test/__mocks__/jose.ts` | JWT mock for tests |
|
||||
| `services/mana-core-auth/test/integration/role-security.e2e-spec.ts` | Role security tests |
|
||||
|
||||
### GitHub Issues (Better Auth)
|
||||
|
||||
- [#3780 - Type issue with auth.api.getSession](https://github.com/better-auth/better-auth/issues/3780)
|
||||
- [#4875 - $Infer.Session doesn't respect customSession](https://github.com/better-auth/better-auth/issues/4875)
|
||||
- [#5159 - Plugin fields not in TypeScript types](https://github.com/better-auth/better-auth/issues/5159)
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File Changes Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/better-auth-types/src/index.ts` | New file - all centralized types |
|
||||
| `packages/better-auth-types/package.json` | New package configuration |
|
||||
| `services/mana-core-auth/src/auth/better-auth.config.ts` | Added Zod validation, role field config |
|
||||
| `services/mana-core-auth/src/auth/auth.controller.ts` | Added rate limiting decorators |
|
||||
| `packages/shared-auth/src/better-auth-fields.ts` | Re-exports from centralized package |
|
||||
| `packages/shared-auth/src/index.ts` | Updated exports |
|
||||
| `packages/shared-nestjs-auth/src/types/index.ts` | Re-exports from centralized package |
|
||||
| `packages/shared-nestjs-auth/package.json` | Added dependency |
|
||||
| `services/mana-core-auth/test/__mocks__/jose.ts` | New mock for ESM compatibility |
|
||||
| `services/mana-core-auth/test/integration/role-security.e2e-spec.ts` | New integration tests |
|
||||
|
||||
**Breaking Changes:** None (backward compatible)
|
||||
**Type Safety:** Significantly improved with centralized types and runtime validation
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
# Security Improvements - Mana Core Auth
|
||||
|
||||
This document describes the security improvements implemented in the Mana Core Auth service following OWASP best practices.
|
||||
|
||||
## Overview
|
||||
|
||||
| Improvement | Impact | Status |
|
||||
|-------------|--------|--------|
|
||||
| EdDSA JWT Algorithm | Critical security fix | ✅ Implemented |
|
||||
| Cookie Cache | 98% DB query reduction | ✅ Implemented |
|
||||
| Remember Me | Extended sessions | ✅ Implemented |
|
||||
| Security Event Logging | Audit compliance | ✅ Implemented |
|
||||
| OWASP Security Headers | HTTP hardening | ✅ Implemented |
|
||||
|
||||
---
|
||||
|
||||
## 1. JWT Algorithm Fix (Critical)
|
||||
|
||||
### Problem
|
||||
The previous implementation had a manual RS256 fallback that bypassed Better Auth's native JWT signing, potentially causing algorithm confusion attacks.
|
||||
|
||||
### Solution
|
||||
Removed the RS256 fallback and now exclusively use Better Auth's native EdDSA (Ed25519) JWT signing via the JWT plugin.
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
curl -s http://localhost:3001/api/v1/auth/jwks
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"keys": [{
|
||||
"alg": "EdDSA",
|
||||
"crv": "Ed25519",
|
||||
"kty": "OKP",
|
||||
"kid": "..."
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Technical Details
|
||||
- **Algorithm:** EdDSA with Ed25519 curve
|
||||
- **Key Storage:** `auth.jwks` table (auto-managed by Better Auth)
|
||||
- **Token Lifetime:** 15 minutes (access token)
|
||||
|
||||
---
|
||||
|
||||
## 2. Cookie Cache
|
||||
|
||||
### Purpose
|
||||
Reduces database queries for session validation by caching session data in encrypted cookies.
|
||||
|
||||
### Configuration
|
||||
```typescript
|
||||
// better-auth.config.ts
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
strategy: 'jwe', // JSON Web Encryption
|
||||
refreshCache: true,
|
||||
}
|
||||
```
|
||||
|
||||
### Impact
|
||||
- **Before:** ~600K+ DB queries/hour for session checks
|
||||
- **After:** ~12K DB queries/hour
|
||||
- **Reduction:** ~98%
|
||||
|
||||
---
|
||||
|
||||
## 3. Remember Me Feature
|
||||
|
||||
### Behavior
|
||||
| Setting | Session Duration |
|
||||
|---------|------------------|
|
||||
| `rememberMe: false` | 7 days (default) |
|
||||
| `rememberMe: true` | 30 days |
|
||||
|
||||
### Database Schema
|
||||
```sql
|
||||
ALTER TABLE auth.sessions ADD COLUMN remember_me boolean DEFAULT false;
|
||||
```
|
||||
|
||||
### API Usage
|
||||
```typescript
|
||||
// Login request
|
||||
POST /api/v1/auth/login
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "...",
|
||||
"rememberMe": true, // Optional
|
||||
"ipAddress": "...", // Optional, for audit
|
||||
"userAgent": "..." // Optional, for audit
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
When `rememberMe: true` is passed during login:
|
||||
1. Session is created with standard 7-day expiration
|
||||
2. Session expiration is extended to 30 days
|
||||
3. `remember_me` flag is set to `true` in the database
|
||||
|
||||
---
|
||||
|
||||
## 4. Security Event Logging
|
||||
|
||||
### Purpose
|
||||
Provides an audit trail for security-relevant events, supporting compliance requirements (GDPR, SOC 2, ISO 27001).
|
||||
|
||||
### Database Schema
|
||||
```sql
|
||||
CREATE TABLE auth.security_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id TEXT REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### Event Types
|
||||
| Event Type | Description | User ID |
|
||||
|------------|-------------|---------|
|
||||
| `login_success` | Successful authentication | ✅ Present |
|
||||
| `login_failure` | Failed authentication attempt | ❌ Not available |
|
||||
| `logout` | User logged out | ✅ Present |
|
||||
| `password_change` | Password was changed | ✅ Present |
|
||||
| `password_reset_requested` | Reset email sent | ❌ Not available |
|
||||
| `password_reset_completed` | Password was reset | ✅ Present |
|
||||
| `session_revoked` | Session was revoked | ✅ Present |
|
||||
| `token_refresh` | Access token refreshed | ✅ Present |
|
||||
|
||||
### Usage in Code
|
||||
```typescript
|
||||
import { SecurityEventsService } from '../security/security-events.service';
|
||||
|
||||
// Inject in constructor
|
||||
constructor(private securityEventsService: SecurityEventsService) {}
|
||||
|
||||
// Log an event
|
||||
await this.securityEventsService.logEvent({
|
||||
userId: user.id,
|
||||
eventType: 'login_success',
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
metadata: {
|
||||
deviceId: dto.deviceId,
|
||||
rememberMe: dto.rememberMe,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Querying Events
|
||||
```sql
|
||||
-- Recent login attempts for a user
|
||||
SELECT * FROM auth.security_events
|
||||
WHERE user_id = 'xxx'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- Failed logins in last 24 hours
|
||||
SELECT * FROM auth.security_events
|
||||
WHERE event_type = 'login_failure'
|
||||
AND created_at > NOW() - INTERVAL '24 hours';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. OWASP Security Headers
|
||||
|
||||
### Implementation
|
||||
Security headers are added in `main.ts` using a custom middleware:
|
||||
|
||||
```typescript
|
||||
app.use((req, res, next) => {
|
||||
// HSTS - Force HTTPS
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
|
||||
// Prevent MIME sniffing
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Clickjacking protection
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
|
||||
// Content Security Policy
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
|
||||
|
||||
// Disable XSS filter (modern browsers)
|
||||
res.setHeader('X-XSS-Protection', '0');
|
||||
|
||||
// Referrer policy
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Permissions policy
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
### Header Reference
|
||||
| Header | Value | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` | Force HTTPS for 1 year |
|
||||
| `X-Content-Type-Options` | `nosniff` | Prevent MIME sniffing |
|
||||
| `X-Frame-Options` | `DENY` | Prevent clickjacking |
|
||||
| `Content-Security-Policy` | `default-src 'self'` | Control resource loading |
|
||||
| `X-XSS-Protection` | `0` | Disable legacy XSS filter |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer info |
|
||||
| `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` | Disable device APIs |
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
curl -I http://localhost:3001/api/v1/auth/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/auth/better-auth.config.ts` | Added cookie cache configuration |
|
||||
| `src/auth/services/better-auth.service.ts` | EdDSA JWT, rememberMe logic, security logging |
|
||||
| `src/auth/types/better-auth.types.ts` | Extended SignInDto with new fields |
|
||||
| `src/auth/auth.module.ts` | Import SecurityModule |
|
||||
| `src/db/schema/auth.schema.ts` | Added `rememberMe` column, `securityEvents` table |
|
||||
| `src/main.ts` | OWASP security headers middleware |
|
||||
| `src/security/security-events.service.ts` | New - Security event logging service |
|
||||
| `src/security/security.module.ts` | New - NestJS module for security |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
```bash
|
||||
# Start the service
|
||||
cd services/mana-core-auth
|
||||
pnpm start:dev
|
||||
|
||||
# Test registration
|
||||
curl -X POST http://localhost:3001/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com", "password": "SecurePassword123!", "name": "Test"}'
|
||||
|
||||
# Test login with rememberMe
|
||||
curl -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com", "password": "SecurePassword123!", "rememberMe": true}'
|
||||
|
||||
# Check security headers
|
||||
curl -I http://localhost:3001/api/v1/auth/health
|
||||
|
||||
# Check JWKS algorithm
|
||||
curl http://localhost:3001/api/v1/auth/jwks
|
||||
```
|
||||
|
||||
### Database Verification
|
||||
```sql
|
||||
-- Check security events
|
||||
SELECT * FROM auth.security_events ORDER BY created_at DESC LIMIT 10;
|
||||
|
||||
-- Check sessions with rememberMe
|
||||
SELECT id, user_id, remember_me, expires_at FROM auth.sessions;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compliance
|
||||
|
||||
These improvements support compliance with:
|
||||
|
||||
- **OWASP ASVS** - Application Security Verification Standard
|
||||
- **GDPR** - Audit logging for data access
|
||||
- **SOC 2** - Security event monitoring
|
||||
- **ISO 27001** - Information security controls
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Better Auth Documentation](https://www.better-auth.com/docs)
|
||||
- [OWASP Security Headers](https://owasp.org/www-project-secure-headers/)
|
||||
- [OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
||||
- [EdDSA (RFC 8032)](https://datatracker.ietf.org/doc/html/rfc8032)
|
||||
|
|
@ -7,7 +7,7 @@ export default defineConfig({
|
|||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore',
|
||||
},
|
||||
schemaFilter: ['auth', 'credits', 'error_logs', 'referrals', 'public'],
|
||||
schemaFilter: ['auth', 'credits', 'referrals', 'public'],
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,17 +16,13 @@
|
|||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"db:push": "node scripts/safe-db-push.mjs",
|
||||
"db:push:force": "node scripts/safe-db-push.mjs --force",
|
||||
"db:push:unsafe": "drizzle-kit push",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getbrevo/brevo": "^3.0.1",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/shared-nestjs-cors": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
|
|
|
|||
25
services/mana-core-auth/scripts/generate-keys.sh
Executable file
25
services/mana-core-auth/scripts/generate-keys.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Generate RS256 key pair for JWT signing
|
||||
|
||||
echo "Generating RS256 key pair..."
|
||||
|
||||
# Generate private key
|
||||
openssl genrsa -out private.pem 2048
|
||||
|
||||
# Generate public key from private key
|
||||
openssl rsa -in private.pem -pubout -out public.pem
|
||||
|
||||
echo ""
|
||||
echo "Keys generated successfully!"
|
||||
echo ""
|
||||
echo "Private key: private.pem"
|
||||
echo "Public key: public.pem"
|
||||
echo ""
|
||||
echo "Add these to your .env file:"
|
||||
echo ""
|
||||
echo "JWT_PRIVATE_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)\""
|
||||
echo ""
|
||||
echo "JWT_PUBLIC_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem)\""
|
||||
echo ""
|
||||
echo "IMPORTANT: Keep private.pem secure and never commit it to version control!"
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Safe db:push wrapper
|
||||
*
|
||||
* This script wraps drizzle-kit push to prevent accidental execution
|
||||
* in production or staging environments.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm db:push # Uses this wrapper
|
||||
* pnpm db:push:force # Bypass safety check (for emergencies only)
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
const DATABASE_URL = process.env.DATABASE_URL || '';
|
||||
|
||||
// Check for production/staging indicators
|
||||
const BLOCKED_ENVS = ['production', 'staging', 'prod', 'stage'];
|
||||
const PROD_HOST_PATTERNS = [
|
||||
/\.railway\.app/,
|
||||
/\.supabase\.co/,
|
||||
/\.neon\.tech/,
|
||||
/\.render\.com/,
|
||||
/staging\./,
|
||||
/prod\./,
|
||||
/production\./,
|
||||
];
|
||||
|
||||
function isProductionEnvironment() {
|
||||
// Check NODE_ENV
|
||||
if (BLOCKED_ENVS.includes(NODE_ENV.toLowerCase())) {
|
||||
return { blocked: true, reason: `NODE_ENV is set to '${NODE_ENV}'` };
|
||||
}
|
||||
|
||||
// Check DATABASE_URL for production patterns
|
||||
for (const pattern of PROD_HOST_PATTERNS) {
|
||||
if (pattern.test(DATABASE_URL)) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `DATABASE_URL contains production pattern: ${pattern}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CI/CD environment
|
||||
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') {
|
||||
return { blocked: true, reason: 'Running in CI/CD environment' };
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const forceFlag = args.includes('--force') || args.includes('-f');
|
||||
|
||||
console.log('🔒 Safe db:push - Environment Check\n');
|
||||
|
||||
const check = isProductionEnvironment();
|
||||
|
||||
if (check.blocked && !forceFlag) {
|
||||
console.log('❌ BLOCKED: db:push is not allowed in this environment\n');
|
||||
console.log(` Reason: ${check.reason}\n`);
|
||||
console.log(' db:push can cause data loss and should only be used in development.\n');
|
||||
console.log(' For production/staging, use:');
|
||||
console.log(' pnpm db:generate # Generate migration files');
|
||||
console.log(' pnpm db:migrate # Apply migrations safely\n');
|
||||
console.log(' If you absolutely need to run db:push (DANGEROUS):');
|
||||
console.log(' pnpm db:push:force\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (check.blocked && forceFlag) {
|
||||
console.log('⚠️ WARNING: --force flag detected, bypassing safety check\n');
|
||||
console.log(` Blocked reason was: ${check.reason}\n`);
|
||||
console.log(' THIS MAY CAUSE DATA LOSS. Proceeding in 5 seconds...\n');
|
||||
|
||||
// Give user time to cancel
|
||||
execSync('sleep 5');
|
||||
}
|
||||
|
||||
console.log('✅ Environment check passed\n');
|
||||
console.log('📤 Running drizzle-kit push...\n');
|
||||
|
||||
try {
|
||||
// Pass through any additional args (except --force)
|
||||
const drizzleArgs = args.filter((arg) => arg !== '--force' && arg !== '-f').join(' ');
|
||||
execSync(`pnpm drizzle-kit push ${drizzleArgs}`, { stdio: 'inherit' });
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -12,8 +12,8 @@ import { ConfigService } from '@nestjs/config';
|
|||
export const createMockConfigService = (overrides: Record<string, any> = {}): ConfigService => {
|
||||
const defaultConfig: Record<string, any> = {
|
||||
'database.url': 'postgresql://test:test@localhost:5432/test',
|
||||
// Note: JWT keys are managed automatically by Better Auth (EdDSA/Ed25519)
|
||||
// Keys are stored in auth.jwks table - no manual configuration needed
|
||||
'jwt.privateKey': 'mock-private-key',
|
||||
'jwt.publicKey': 'mock-public-key',
|
||||
'jwt.accessTokenExpiry': '15m',
|
||||
'jwt.refreshTokenExpiry': '7d',
|
||||
'jwt.issuer': 'mana-core',
|
||||
|
|
@ -23,7 +23,6 @@ export const createMockConfigService = (overrides: Record<string, any> = {}): Co
|
|||
'redis.host': 'localhost',
|
||||
'redis.port': 6379,
|
||||
'redis.password': 'test',
|
||||
BASE_URL: 'http://localhost:3001',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,8 @@ import { APP_FILTER } from '@nestjs/core';
|
|||
import configuration from './config/configuration';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CreditsModule } from './credits/credits.module';
|
||||
import { EmailModule } from './email/email.module';
|
||||
import { ErrorLogsModule } from './error-logs/error-logs.module';
|
||||
import { FeedbackModule } from './feedback/feedback.module';
|
||||
import { ReferralsModule } from './referrals/referrals.module';
|
||||
import { SecurityModule } from './security/security.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
|
|
@ -31,12 +28,9 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||
AiModule,
|
||||
AuthModule,
|
||||
CreditsModule,
|
||||
EmailModule,
|
||||
ErrorLogsModule,
|
||||
FeedbackModule,
|
||||
HealthModule,
|
||||
ReferralsModule,
|
||||
SecurityModule,
|
||||
SettingsModule,
|
||||
TagsModule,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
|
@ -23,26 +22,6 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
|||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
|
||||
/**
|
||||
* Rate Limiting Configuration
|
||||
*
|
||||
* Security-focused rate limits for auth endpoints:
|
||||
* - Login: 5 attempts per minute (brute force protection)
|
||||
* - Register: 10 per hour (spam prevention)
|
||||
* - Password reset: 3 per 5 minutes (enumeration protection)
|
||||
* - Token validation: No limit (high-frequency internal operation)
|
||||
*/
|
||||
const RATE_LIMITS = {
|
||||
/** 5 login attempts per 60 seconds */
|
||||
LOGIN: { limit: 5, ttl: 60000 },
|
||||
/** 10 registrations per hour */
|
||||
REGISTER: { limit: 10, ttl: 3600000 },
|
||||
/** 3 password reset requests per 5 minutes */
|
||||
PASSWORD_RESET: { limit: 3, ttl: 300000 },
|
||||
/** 5 B2B registrations per hour */
|
||||
B2B_REGISTER: { limit: 5, ttl: 3600000 },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Auth Controller
|
||||
*
|
||||
|
|
@ -76,16 +55,13 @@ export class AuthController {
|
|||
* Register a new B2C user (individual)
|
||||
*
|
||||
* Creates a user account and initializes their credit balance.
|
||||
*
|
||||
* Rate limit: 10 registrations per hour per IP (spam prevention)
|
||||
*/
|
||||
@Post('register')
|
||||
@Throttle({ default: RATE_LIMITS.REGISTER })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.betterAuthService.registerB2C({
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: registerDto.name,
|
||||
name: registerDto.name || '',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -93,11 +69,8 @@ export class AuthController {
|
|||
* Sign in with email and password
|
||||
*
|
||||
* Returns user data and JWT token.
|
||||
*
|
||||
* Rate limit: 5 attempts per minute per IP (brute force protection)
|
||||
*/
|
||||
@Post('login')
|
||||
@Throttle({ default: RATE_LIMITS.LOGIN })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.betterAuthService.signIn({
|
||||
|
|
@ -148,11 +121,8 @@ export class AuthController {
|
|||
* Validate a token
|
||||
*
|
||||
* Checks if a token is valid and returns the payload.
|
||||
*
|
||||
* No rate limiting - high-frequency internal operation used by all backends.
|
||||
*/
|
||||
@Post('validate')
|
||||
@SkipThrottle()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async validate(@Body() body: { token: string }) {
|
||||
return this.betterAuthService.validateToken(body.token);
|
||||
|
|
@ -163,11 +133,8 @@ export class AuthController {
|
|||
*
|
||||
* Returns public keys for JWT verification.
|
||||
* This is a passthrough to Better Auth's JWKS.
|
||||
*
|
||||
* No rate limiting - cacheable public keys for JWT verification.
|
||||
*/
|
||||
@Get('jwks')
|
||||
@SkipThrottle()
|
||||
async getJwks() {
|
||||
return this.betterAuthService.getJwks();
|
||||
}
|
||||
|
|
@ -181,11 +148,8 @@ export class AuthController {
|
|||
*
|
||||
* Initiates the password reset flow by sending an email with a reset link.
|
||||
* Always returns success to prevent email enumeration attacks.
|
||||
*
|
||||
* Rate limit: 3 requests per 5 minutes per IP (enumeration protection)
|
||||
*/
|
||||
@Post('forgot-password')
|
||||
@Throttle({ default: RATE_LIMITS.PASSWORD_RESET })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
|
||||
return this.betterAuthService.requestPasswordReset(
|
||||
|
|
@ -217,11 +181,8 @@ export class AuthController {
|
|||
*
|
||||
* Creates an organization with the registering user as owner.
|
||||
* Also creates organization credit balance.
|
||||
*
|
||||
* Rate limit: 5 B2B registrations per hour per IP (spam prevention)
|
||||
*/
|
||||
@Post('register/b2b')
|
||||
@Throttle({ default: RATE_LIMITS.B2B_REGISTER })
|
||||
async registerB2B(@Body() registerDto: RegisterB2BDto) {
|
||||
return this.betterAuthService.registerB2B(registerDto);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import { Module, forwardRef } from '@nestjs/common';
|
|||
import { AuthController } from './auth.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { ReferralsModule } from '../referrals/referrals.module';
|
||||
import { SecurityModule } from '../security/security.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ReferralsModule), SecurityModule],
|
||||
imports: [forwardRef(() => ReferralsModule)],
|
||||
controllers: [AuthController],
|
||||
providers: [BetterAuthService],
|
||||
exports: [BetterAuthService],
|
||||
|
|
|
|||
|
|
@ -18,38 +18,10 @@ 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 { z } from 'zod';
|
||||
import { getDb } from '../db/connection';
|
||||
import { organizations, members, invitations } from '../db/schema/organizations.schema';
|
||||
import { users, sessions, accounts, verificationTokens, jwks } from '../db/schema/auth.schema';
|
||||
import { sendPasswordResetEmail, sendOrganizationInviteEmail } from '../email/brevo-client';
|
||||
|
||||
/**
|
||||
* User role schema with Zod runtime validation
|
||||
*
|
||||
* Ensures only valid role values can be assigned to users.
|
||||
* This provides defense-in-depth alongside `input: false`.
|
||||
*
|
||||
* Valid roles:
|
||||
* - 'user': Standard user (default)
|
||||
* - 'admin': Administrator with elevated privileges
|
||||
* - 'service': Service account for automated systems
|
||||
*/
|
||||
export const userRoleSchema = z.enum(['user', 'admin', 'service'], {
|
||||
errorMap: () => ({ message: 'Invalid user role. Must be one of: user, admin, service' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Inferred TypeScript type from Zod schema
|
||||
*/
|
||||
export type UserRole = z.infer<typeof userRoleSchema>;
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid UserRole
|
||||
*/
|
||||
export function isValidUserRole(role: unknown): role is UserRole {
|
||||
return userRoleSchema.safeParse(role).success;
|
||||
}
|
||||
import type { JWTPayloadContext } from './types/better-auth.types';
|
||||
|
||||
/**
|
||||
* JWT Custom Payload Interface
|
||||
|
|
@ -119,28 +91,24 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
* 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
|
||||
*
|
||||
* Uses Brevo API to send transactional emails.
|
||||
* Set BREVO_API_KEY environment variable to enable email sending.
|
||||
* Without the API key, emails are logged to console (dev mode).
|
||||
*
|
||||
* The reset URL points to the frontend's reset-password page, not the API.
|
||||
* Set FRONTEND_URL environment variable for production.
|
||||
* - auth.api.forgetPassword({ email }) - Sends reset email
|
||||
* - auth.api.resetPassword({ newPassword, token }) - Resets password
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
*/
|
||||
sendResetPassword: async ({ user, token }) => {
|
||||
// Construct URL pointing to frontend's reset-password page
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const resetUrl = `${frontendUrl}/reset-password?token=${token}`;
|
||||
sendResetPassword: async ({ user, url, token }) => {
|
||||
// TODO: Implement email sending service (e.g., Resend, SendGrid)
|
||||
// For now, log the reset URL for development
|
||||
console.log('[Password Reset] User:', user.email);
|
||||
console.log('[Password Reset] Reset URL:', url);
|
||||
console.log('[Password Reset] Token:', token);
|
||||
|
||||
await sendPasswordResetEmail({
|
||||
email: user.email,
|
||||
name: user.name || undefined,
|
||||
resetUrl,
|
||||
});
|
||||
// In production, send an email like:
|
||||
// await sendEmail({
|
||||
// to: user.email,
|
||||
// subject: 'Reset your password',
|
||||
// html: `<a href="${url}">Reset your password</a>`
|
||||
// });
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -148,53 +116,11 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // Update session once per day
|
||||
|
||||
// Cookie cache: Reduces DB queries by 98% for session validation
|
||||
// Encrypted JWE cookie valid for 5 minutes before DB revalidation
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
strategy: 'jwe', // Encrypted (default in Better Auth v1.4+)
|
||||
refreshCache: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Base URL for callbacks and redirects
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3001',
|
||||
|
||||
/**
|
||||
* User Additional Fields
|
||||
*
|
||||
* Define custom user fields that Better Auth should be aware of.
|
||||
* This enables proper type inference via $Infer pattern.
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/concepts/database#additional-fields
|
||||
*/
|
||||
user: {
|
||||
additionalFields: {
|
||||
/**
|
||||
* User role (user, admin, service)
|
||||
*
|
||||
* Security:
|
||||
* - input=false prevents clients from setting their own role
|
||||
* - Zod validator ensures only valid role values are accepted
|
||||
*
|
||||
* Roles must be assigned server-side only.
|
||||
*
|
||||
* @see userRoleSchema for valid values
|
||||
*/
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
defaultValue: 'user',
|
||||
input: false, // Clients cannot set role during registration
|
||||
validator: {
|
||||
input: userRoleSchema, // Runtime validation with Zod
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
/**
|
||||
|
|
@ -215,24 +141,15 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
// Allow users to create their own organizations
|
||||
allowUserToCreateOrganization: true,
|
||||
|
||||
/**
|
||||
* Email invitation handler
|
||||
*
|
||||
* Uses Brevo API to send organization invitation emails.
|
||||
* Set BREVO_API_KEY environment variable to enable email sending.
|
||||
* Without the API key, emails are logged to console (dev mode).
|
||||
*/
|
||||
// Email invitation handler
|
||||
async sendInvitationEmail(data) {
|
||||
const { email, organization, role, inviter } = data;
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
|
||||
const inviteUrl = `${baseUrl}/accept-invitation?id=${data.id}`;
|
||||
const { email, organization } = data;
|
||||
|
||||
await sendOrganizationInviteEmail({
|
||||
email,
|
||||
organizationName: organization.name,
|
||||
inviterName: inviter?.user?.name || undefined,
|
||||
inviteUrl,
|
||||
role: role || 'member',
|
||||
// TODO: Implement email sending service
|
||||
console.log('TODO: Send invitation email', {
|
||||
to: email,
|
||||
organization: organization.name,
|
||||
invitationId: data.id,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -288,11 +205,11 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
*
|
||||
* Only includes static user info that doesn't change frequently.
|
||||
*/
|
||||
definePayload({ user, session }) {
|
||||
definePayload({ user, session }: JWTPayloadContext) {
|
||||
return {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role ?? 'user',
|
||||
role: (user as { role?: string }).role || 'user',
|
||||
sid: session.id,
|
||||
};
|
||||
},
|
||||
|
|
@ -306,23 +223,3 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
* Export type for Better Auth instance
|
||||
*/
|
||||
export type BetterAuthInstance = ReturnType<typeof createBetterAuth>;
|
||||
|
||||
/**
|
||||
* Inferred types from Better Auth instance
|
||||
*
|
||||
* These types are automatically derived from the auth configuration,
|
||||
* including all plugins and additional fields. Use these instead of
|
||||
* manual interface definitions.
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/concepts/typescript
|
||||
*/
|
||||
export type AuthSession = BetterAuthInstance['$Infer']['Session'];
|
||||
export type AuthUser = AuthSession['user'];
|
||||
|
||||
/**
|
||||
* Inferred API type from Better Auth instance
|
||||
*
|
||||
* This type includes all methods from core auth and plugins
|
||||
* (organization, jwt, etc.). Use this instead of manual BetterAuthAPI interface.
|
||||
*/
|
||||
export type AuthAPI = BetterAuthInstance['api'];
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { IsEmail, IsString, IsOptional, IsBoolean, MinLength } from 'class-validator';
|
||||
import { IsEmail, IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12) // Matches Better Auth config (minPasswordLength: 12)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
|
|
@ -15,16 +14,4 @@ export class LoginDto {
|
|||
@IsString()
|
||||
@IsOptional()
|
||||
deviceName?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
rememberMe?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ipAddress?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userAgent?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
|
||||
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
|
|
@ -10,7 +10,7 @@ export class RegisterDto {
|
|||
password: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2, { message: 'Name must be at least 2 characters' })
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
name?: string;
|
||||
}
|
||||
|
|
|
|||
566
services/mana-core-auth/src/auth/jwt-validation.spec.ts
Normal file
566
services/mana-core-auth/src/auth/jwt-validation.spec.ts
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { 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'),
|
||||
}));
|
||||
|
||||
describe('JWT Token Validation (Minimal Claims)', () => {
|
||||
let configService: ConfigService;
|
||||
let mockDb: any;
|
||||
let secret: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use HS256 for testing (symmetric key) for simplicity
|
||||
// In production, mana-core uses RS256 (asymmetric)
|
||||
secret = 'test-secret-key-for-jwt-validation';
|
||||
|
||||
// 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.secret': secret,
|
||||
'jwt.issuer': 'mana-core',
|
||||
'jwt.audience': 'manacore',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Minimal JWT Claims Structure', () => {
|
||||
it('should generate token with minimal claims only', () => {
|
||||
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 = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
}) as JWTCustomPayload;
|
||||
|
||||
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)', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded: any = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
// Standard JWT claims
|
||||
expect(decoded.sub).toBe('user-123');
|
||||
expect(decoded.iat).toBeGreaterThanOrEqual(now);
|
||||
expect(decoded.exp).toBeGreaterThan(decoded.iat);
|
||||
expect(decoded.iss).toBe('mana-core');
|
||||
expect(decoded.aud).toBe('manacore');
|
||||
});
|
||||
|
||||
it('should support different user roles', () => {
|
||||
const roles = ['user', 'admin', 'service'];
|
||||
|
||||
roles.forEach((role) => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: `${role}-user-123`,
|
||||
email: `${role}@example.com`,
|
||||
role,
|
||||
sid: `session-${role}`,
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as JWTCustomPayload;
|
||||
|
||||
expect(decoded.role).toBe(role);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Validation - Security', () => {
|
||||
it('should validate HS256 signature correctly', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Should successfully verify with correct secret
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject expired tokens', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
// Create token that expires immediately
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '0s', // Expired immediately
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Wait a moment to ensure expiry
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('jwt expired');
|
||||
resolve(true);
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject tokens with wrong issuer', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'wrong-issuer', // Wrong issuer
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'mana-core', // Expect correct issuer
|
||||
audience: 'manacore',
|
||||
});
|
||||
}).toThrow('jwt issuer invalid');
|
||||
});
|
||||
|
||||
it('should reject tokens with wrong audience', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'wrong-audience', // Wrong audience
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore', // Expect correct audience
|
||||
});
|
||||
}).toThrow('jwt audience invalid');
|
||||
});
|
||||
|
||||
it('should reject tampered tokens', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
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]}`;
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(tamperedToken, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('invalid signature');
|
||||
});
|
||||
|
||||
it('should reject tokens signed with wrong secret', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
// Sign with different secret
|
||||
const token = jwt.sign(payload, 'wrong-secret-key', {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Try to verify with correct secret
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Expiration Times', () => {
|
||||
it('should use 15 minutes for access tokens', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded: any = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
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 = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
notBefore: futureTime, // Not valid until 1 hour from now
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('jwt not active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle malformed JWT gracefully', () => {
|
||||
const malformedToken = 'this.is.not.a.valid.jwt';
|
||||
|
||||
expect(() => {
|
||||
jwt.verify(malformedToken, secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('jwt malformed');
|
||||
});
|
||||
|
||||
it('should handle empty token', () => {
|
||||
expect(() => {
|
||||
jwt.verify('', secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}).toThrow('jwt must be provided');
|
||||
});
|
||||
|
||||
it('should handle token with missing required claims', () => {
|
||||
// Token with only sub (missing email, role, sid)
|
||||
const minimalPayload = { sub: 'user-123' };
|
||||
|
||||
const token = jwt.sign(minimalPayload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
// Token is technically valid, but application should validate claims
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as any;
|
||||
|
||||
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', () => {
|
||||
const originalPayload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-original',
|
||||
};
|
||||
|
||||
const originalToken = jwt.sign(originalPayload, secret, {
|
||||
algorithm: 'HS256',
|
||||
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 = jwt.sign(refreshedPayload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(refreshedToken, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as JWTCustomPayload;
|
||||
|
||||
// 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', () => {
|
||||
const adminPayload: JWTCustomPayload = {
|
||||
sub: 'admin-123',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(adminPayload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as JWTCustomPayload;
|
||||
|
||||
// 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)', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as any;
|
||||
|
||||
// Organization data should be fetched via:
|
||||
// - session.activeOrganizationId (from Better Auth session)
|
||||
// - GET /organization/get-active-member (for details)
|
||||
expect(decoded.organization).toBeUndefined();
|
||||
expect(decoded.organizationId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT contain credit balance (fetch via API instead)', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as any;
|
||||
|
||||
// Credit balance should be fetched via:
|
||||
// - GET /api/v1/credits/balance
|
||||
// Credit balance changes too frequently to embed in JWT
|
||||
expect(decoded.credit_balance).toBeUndefined();
|
||||
expect(decoded.credits).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT contain customer_type (derive from session instead)', () => {
|
||||
const payload: JWTCustomPayload = {
|
||||
sub: 'user-123',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
sid: 'session-123',
|
||||
};
|
||||
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'mana-core',
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as any;
|
||||
|
||||
// Customer type should be derived from:
|
||||
// - B2B = session.activeOrganizationId != null
|
||||
// - B2C = session.activeOrganizationId == null
|
||||
expect(decoded.customer_type).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -31,7 +31,6 @@ import { balances, organizationBalances } from '../../db/schema/credits.schema';
|
|||
import { ReferralCodeService } from '../../referrals/services/referral-code.service';
|
||||
import { ReferralTierService } from '../../referrals/services/referral-tier.service';
|
||||
import { ReferralTrackingService } from '../../referrals/services/referral-tracking.service';
|
||||
import { SecurityEventsService } from '../../security/security-events.service';
|
||||
import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types';
|
||||
import type {
|
||||
RegisterB2CDto,
|
||||
|
|
@ -56,13 +55,14 @@ import type {
|
|||
TokenPayload,
|
||||
OrganizationMember,
|
||||
Organization,
|
||||
CreateOrganizationResponse,
|
||||
// BetterAuthAPI provides typed methods for all Better Auth operations
|
||||
// Note: AuthAPI (inferred) is also available but doesn't expose all methods
|
||||
BetterAuthAPI,
|
||||
// BetterAuthUser includes the role field (deprecated - use AuthUser when $Infer works)
|
||||
SignUpResponse,
|
||||
SignInResponse,
|
||||
CreateOrganizationResponse,
|
||||
BetterAuthUser,
|
||||
BetterAuthSession,
|
||||
} from '../types/better-auth.types';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
|
||||
// Re-export DTOs and result types for external use
|
||||
|
|
@ -89,25 +89,16 @@ export class BetterAuthService {
|
|||
private databaseUrl: string;
|
||||
|
||||
/**
|
||||
* Typed accessor for Better Auth API methods
|
||||
*
|
||||
* Better Auth's plugins add methods dynamically. This accessor provides
|
||||
* typed access to all API methods including those from organization
|
||||
* and JWT plugins.
|
||||
*
|
||||
* Note: We use BetterAuthAPI interface which is manually maintained.
|
||||
* In the future, this could be replaced with inferred types once
|
||||
* Better Auth's $Infer fully supports API type inference.
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/concepts/typescript
|
||||
* Typed accessor for organization plugin API methods
|
||||
* Better Auth's organization plugin adds methods dynamically, so we provide
|
||||
* a typed accessor to avoid casting throughout the service.
|
||||
*/
|
||||
private get api(): BetterAuthAPI {
|
||||
private get orgApi(): BetterAuthAPI {
|
||||
return this.auth.api as unknown as BetterAuthAPI;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private securityEventsService: SecurityEventsService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReferralCodeService))
|
||||
private referralCodeService: ReferralCodeService,
|
||||
|
|
@ -254,7 +245,7 @@ export class BetterAuthService {
|
|||
try {
|
||||
// Better Auth organization plugin uses auth.api.inviteMember
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
const result = await this.api.inviteMember({
|
||||
const result = await this.orgApi.inviteMember({
|
||||
body: {
|
||||
email: dto.employeeEmail,
|
||||
role: dto.role,
|
||||
|
|
@ -295,7 +286,7 @@ export class BetterAuthService {
|
|||
try {
|
||||
// Better Auth organization plugin uses auth.api.acceptInvitation
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
const result = await this.api.acceptInvitation({
|
||||
const result = await this.orgApi.acceptInvitation({
|
||||
body: { invitationId: dto.invitationId },
|
||||
headers: {
|
||||
authorization: `Bearer ${dto.userToken}`,
|
||||
|
|
@ -333,7 +324,7 @@ export class BetterAuthService {
|
|||
try {
|
||||
// Better Auth uses getFullOrganization to get org with members
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
const result = await this.api.getFullOrganization({
|
||||
const result = await this.orgApi.getFullOrganization({
|
||||
query: { organizationId },
|
||||
});
|
||||
|
||||
|
|
@ -362,7 +353,7 @@ export class BetterAuthService {
|
|||
// Better Auth organization plugin uses auth.api.removeMember
|
||||
// Accepts memberIdOrEmail parameter
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
await this.api.removeMember({
|
||||
await this.orgApi.removeMember({
|
||||
body: {
|
||||
memberIdOrEmail: dto.memberId,
|
||||
organizationId: dto.organizationId,
|
||||
|
|
@ -397,7 +388,7 @@ export class BetterAuthService {
|
|||
try {
|
||||
// Better Auth organization plugin uses auth.api.setActiveOrganization
|
||||
// See: https://www.better-auth.com/docs/plugins/organization
|
||||
const result = await this.api.setActiveOrganization({
|
||||
const result = await this.orgApi.setActiveOrganization({
|
||||
body: { organizationId: dto.organizationId },
|
||||
headers: {
|
||||
authorization: `Bearer ${dto.userToken}`,
|
||||
|
|
@ -447,54 +438,69 @@ export class BetterAuthService {
|
|||
const session = hasSession(result) ? result.session : null;
|
||||
const sessionToken = session?.token || (hasToken(result) ? result.token : '');
|
||||
|
||||
// Generate JWT access token using Better Auth's JWT plugin (EdDSA)
|
||||
const jwtResult = await this.api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
// Generate JWT access token using Better Auth's JWT plugin
|
||||
let accessToken = '';
|
||||
try {
|
||||
const api = this.auth.api as any;
|
||||
|
||||
// Use Better Auth's signJWT with the jwks table
|
||||
const jwtResult = await api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
accessToken = jwtResult?.token || '';
|
||||
|
||||
// Fallback to manual JWT if Better Auth fails
|
||||
if (!accessToken) {
|
||||
throw new Error('Better Auth signJWT returned empty token');
|
||||
}
|
||||
} catch (jwtError) {
|
||||
console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError);
|
||||
|
||||
// Fallback: Generate JWT manually using jsonwebtoken
|
||||
const privateKey = this.configService.get<string>('jwt.privateKey');
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
console.log('[signIn] Private key exists:', !!privateKey);
|
||||
console.log('[signIn] Private key length:', privateKey?.length);
|
||||
console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30));
|
||||
console.log('[signIn] Issuer:', issuer);
|
||||
console.log('[signIn] Audience:', audience);
|
||||
|
||||
if (privateKey) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const accessToken = jwtResult?.token;
|
||||
accessToken = jwt.sign(payload, privateKey, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '15m',
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
if (!accessToken) {
|
||||
throw new UnauthorizedException('Failed to generate access token');
|
||||
console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50));
|
||||
// Decode to verify
|
||||
const decoded = jwt.decode(accessToken, { complete: true });
|
||||
console.log('[signIn] Generated JWT header:', decoded?.header);
|
||||
console.log('[signIn] Generated JWT payload:', decoded?.payload);
|
||||
} else {
|
||||
console.error('[signIn] No JWT private key configured');
|
||||
accessToken = sessionToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "Remember Me" - extend session expiration to 30 days
|
||||
if (dto.rememberMe && session?.id) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { sessions } = await import('../../db/schema');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
|
||||
const extendedExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
expiresAt: extendedExpiresAt,
|
||||
rememberMe: true,
|
||||
})
|
||||
.where(eq(sessions.id, session.id));
|
||||
}
|
||||
|
||||
// Log successful login for security audit
|
||||
await this.securityEventsService.logEvent({
|
||||
userId: user.id,
|
||||
eventType: 'login_success',
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
metadata: {
|
||||
deviceId: dto.deviceId,
|
||||
deviceName: dto.deviceName,
|
||||
rememberMe: dto.rememberMe,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
|
|
@ -507,14 +513,6 @@ export class BetterAuthService {
|
|||
expiresIn: 15 * 60, // 15 minutes in seconds
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
// Log failed login attempt for security audit
|
||||
await this.securityEventsService.logEvent({
|
||||
eventType: 'login_failure',
|
||||
ipAddress: dto.ipAddress,
|
||||
userAgent: dto.userAgent,
|
||||
metadata: { email: dto.email },
|
||||
});
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message?.includes('invalid') ||
|
||||
|
|
@ -539,7 +537,7 @@ export class BetterAuthService {
|
|||
async signOut(token: string): Promise<SignOutResult> {
|
||||
try {
|
||||
// Better Auth uses auth.api.signOut
|
||||
await this.api.signOut({
|
||||
await (this.auth.api as any).signOut({
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
|
|
@ -566,7 +564,7 @@ export class BetterAuthService {
|
|||
async getSession(token: string): Promise<GetSessionResult> {
|
||||
try {
|
||||
// Better Auth uses auth.api.getSession
|
||||
const result = await this.api.getSession({
|
||||
const result = await (this.auth.api as any).getSession({
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
|
|
@ -604,7 +602,7 @@ export class BetterAuthService {
|
|||
*/
|
||||
async listOrganizations(token: string): Promise<ListOrganizationsResult> {
|
||||
try {
|
||||
const result = await this.api.listOrganizations({
|
||||
const result = await this.orgApi.listOrganizations({
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
|
|
@ -635,7 +633,7 @@ export class BetterAuthService {
|
|||
token?: string
|
||||
): Promise<Organization & { members?: OrganizationMember[] }> {
|
||||
try {
|
||||
const result = await this.api.getFullOrganization({
|
||||
const result = await this.orgApi.getFullOrganization({
|
||||
query: { organizationId },
|
||||
...(token && {
|
||||
headers: {
|
||||
|
|
@ -743,24 +741,31 @@ export class BetterAuthService {
|
|||
rememberMe: wasRememberMe, // Preserve remember me flag
|
||||
});
|
||||
|
||||
// Generate new JWT using Better Auth's JWT plugin (EdDSA)
|
||||
const jwtResult = await this.api.signJWT({
|
||||
body: {
|
||||
payload: {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
sid: sessionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = jwtResult?.token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new UnauthorizedException('Failed to generate access token');
|
||||
// Generate new JWT
|
||||
const privateKey = this.configService.get<string>('jwt.privateKey');
|
||||
if (!privateKey) {
|
||||
throw new Error('JWT private key not configured');
|
||||
}
|
||||
|
||||
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
|
||||
const tokenPayload: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
sessionId,
|
||||
...(session.deviceId && { deviceId: session.deviceId }),
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(tokenPayload, privateKey, {
|
||||
algorithm: 'RS256' as const,
|
||||
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
|
||||
...(issuer && { issuer }),
|
||||
...(audience && { audience }),
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
|
|
@ -801,10 +806,18 @@ export class BetterAuthService {
|
|||
*/
|
||||
async validateToken(token: string): Promise<ValidateTokenResult> {
|
||||
try {
|
||||
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
|
||||
|
||||
// Decode to check the algorithm
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
console.log('[validateToken] Decoded header:', decoded?.header);
|
||||
|
||||
// Use our JWKS endpoint (NestJS prefix: /api/v1)
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
|
||||
|
||||
console.log('[validateToken] Using JWKS from:', jwksUrl.toString());
|
||||
|
||||
// Create JWKS fetcher
|
||||
const JWKS = createRemoteJWKSet(jwksUrl);
|
||||
|
||||
|
|
@ -812,18 +825,25 @@ export class BetterAuthService {
|
|||
const issuer = this.configService.get<string>('jwt.issuer') || baseUrl;
|
||||
const audience = this.configService.get<string>('jwt.audience') || baseUrl;
|
||||
|
||||
// Verify using jose library with Better Auth's JWKS (EdDSA)
|
||||
console.log('[validateToken] Issuer:', issuer);
|
||||
console.log('[validateToken] Audience:', audience);
|
||||
|
||||
// Verify using jose library with Better Auth's JWKS
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[validateToken] Verification SUCCESS');
|
||||
console.log('[validateToken] Payload:', payload);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
payload: payload as unknown as TokenPayload,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[validateToken] Verification FAILED:', errorMessage);
|
||||
return {
|
||||
valid: false,
|
||||
error: errorMessage,
|
||||
|
|
@ -850,9 +870,9 @@ export class BetterAuthService {
|
|||
redirectTo?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// Better Auth's requestPasswordReset method
|
||||
// Better Auth's forgetPassword method
|
||||
// See: https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
await this.api.requestPasswordReset({
|
||||
await (this.auth.api as any).forgetPassword({
|
||||
body: {
|
||||
email,
|
||||
redirectTo,
|
||||
|
|
@ -892,7 +912,7 @@ export class BetterAuthService {
|
|||
try {
|
||||
// Better Auth's resetPassword method
|
||||
// See: https://www.better-auth.com/docs/authentication/email-password#password-reset
|
||||
await this.api.resetPassword({
|
||||
await (this.auth.api as any).resetPassword({
|
||||
body: {
|
||||
token,
|
||||
newPassword,
|
||||
|
|
@ -927,9 +947,12 @@ export class BetterAuthService {
|
|||
*/
|
||||
async getJwks(): Promise<{ keys: unknown[] }> {
|
||||
try {
|
||||
// Better Auth exposes JWKS via auth.api
|
||||
const api = this.auth.api as any;
|
||||
|
||||
// Try to get JWKS from Better Auth
|
||||
const result = await this.api.getJwks();
|
||||
if (result) {
|
||||
if (api.getJwks) {
|
||||
const result = await api.getJwks();
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,16 @@
|
|||
*
|
||||
* This file provides types for Better Auth integration.
|
||||
*
|
||||
* STRATEGY (2024-12 UPDATE):
|
||||
* Use inferred types from Better Auth's $Infer pattern when possible.
|
||||
* Manual interfaces are kept only for service-layer DTOs and result types.
|
||||
* STRATEGY: Import base types from Better Auth packages, extend only when needed.
|
||||
*
|
||||
* INFERRED TYPES (prefer these):
|
||||
* - AuthUser, AuthSession, AuthAPI - from better-auth.config.ts
|
||||
* From 'better-auth/types':
|
||||
* - User, Session, Account, Auth, BetterAuthOptions, etc.
|
||||
*
|
||||
* From 'better-auth/plugins/organization':
|
||||
* - Organization, Member, Invitation, OrganizationRole, InvitationStatus
|
||||
*
|
||||
* This file defines:
|
||||
* 1. Re-exports of inferred types from config
|
||||
* 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
|
||||
|
|
@ -24,12 +22,7 @@
|
|||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Import inferred types from Better Auth config
|
||||
// =============================================================================
|
||||
import type { AuthUser, AuthSession, AuthAPI } from '../better-auth.config';
|
||||
|
||||
// =============================================================================
|
||||
// Import base types from Better Auth packages
|
||||
// Import core types from Better Auth packages
|
||||
// =============================================================================
|
||||
import type { User, Session } from 'better-auth/types';
|
||||
import type {
|
||||
|
|
@ -40,9 +33,6 @@ import type {
|
|||
InvitationStatus as BetterAuthInvitationStatus,
|
||||
} from 'better-auth/plugins/organization';
|
||||
|
||||
// Re-export inferred types as primary types
|
||||
export type { AuthUser, AuthSession, AuthAPI };
|
||||
|
||||
// Re-export base types for convenience
|
||||
export type { User, Session };
|
||||
export type {
|
||||
|
|
@ -55,9 +45,7 @@ export type {
|
|||
|
||||
/**
|
||||
* Extended User type with our additional fields
|
||||
*
|
||||
* @deprecated Use AuthUser (inferred from config) instead.
|
||||
* This type is kept for backward compatibility but may be removed in future.
|
||||
* Better Auth's User type is the base, we extend it for our app
|
||||
*/
|
||||
export interface BetterAuthUser extends User {
|
||||
role?: string;
|
||||
|
|
@ -65,9 +53,7 @@ export interface BetterAuthUser extends User {
|
|||
|
||||
/**
|
||||
* Extended Session type with organization support
|
||||
*
|
||||
* @deprecated Use AuthSession (inferred from config) instead.
|
||||
* This type is kept for backward compatibility but may be removed in future.
|
||||
* Better Auth's Session type is the base, organization plugin adds activeOrganizationId
|
||||
*/
|
||||
export interface BetterAuthSession extends Session {
|
||||
activeOrganizationId?: string | null;
|
||||
|
|
@ -76,9 +62,6 @@ export interface BetterAuthSession extends Session {
|
|||
|
||||
/**
|
||||
* JWT Payload context passed to definePayload
|
||||
*
|
||||
* @deprecated Better Auth now infers this type automatically from config.
|
||||
* This type is kept for backward compatibility but may be removed in future.
|
||||
*/
|
||||
export interface JWTPayloadContext {
|
||||
user: BetterAuthUser;
|
||||
|
|
@ -283,35 +266,13 @@ export interface AuthenticatedRequest<TBody = unknown, TQuery = unknown> {
|
|||
/**
|
||||
* Typed Better Auth API interface
|
||||
*
|
||||
* @deprecated Use AuthAPI (inferred from config) instead.
|
||||
* This interface is manually maintained and may become out of sync with Better Auth.
|
||||
* The inferred type from BetterAuthInstance['api'] is always accurate.
|
||||
*
|
||||
* This interface describes the methods available on auth.api
|
||||
* when using the organization plugin.
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/concepts/typescript
|
||||
*/
|
||||
export interface BetterAuthAPI {
|
||||
// Core auth methods
|
||||
signUpEmail(params: { body: SignUpEmailBody }): Promise<SignUpResponse>;
|
||||
signInEmail(params: { body: { email: string; password: string } }): Promise<SignInResponse>;
|
||||
signOut(params: AuthenticatedRequest): Promise<{ success: boolean }>;
|
||||
getSession(
|
||||
params: AuthenticatedRequest
|
||||
): Promise<{ user: BetterAuthUser; session: BetterAuthSession }>;
|
||||
|
||||
// Password reset methods
|
||||
requestPasswordReset(params: {
|
||||
body: { email: string; redirectTo?: string };
|
||||
}): Promise<{ status: boolean }>;
|
||||
resetPassword(params: {
|
||||
body: { newPassword: string; token: string };
|
||||
}): Promise<{ status: boolean }>;
|
||||
|
||||
// JWT methods
|
||||
signJWT(params: { body: { payload: Record<string, unknown> } }): Promise<{ token: string }>;
|
||||
getJwks(): Promise<{ keys: unknown[] }>;
|
||||
|
||||
// Organization methods
|
||||
createOrganization(
|
||||
|
|
@ -470,9 +431,6 @@ export interface SignInDto {
|
|||
password: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
rememberMe?: boolean;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ export default () => ({
|
|||
},
|
||||
|
||||
jwt: {
|
||||
// Note: Better Auth manages JWT keys automatically via JWKS (EdDSA/Ed25519)
|
||||
// Keys are stored in auth.jwks table - no manual key configuration needed
|
||||
// Convert \n string literals to actual newlines for PEM format
|
||||
publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
|
||||
privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
|
||||
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
|
||||
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
|
|
@ -48,15 +49,4 @@ export default () => ({
|
|||
ai: {
|
||||
geminiApiKey: process.env.GOOGLE_GENAI_API_KEY || '',
|
||||
},
|
||||
|
||||
email: {
|
||||
brevoApiKey: process.env.BREVO_API_KEY || '',
|
||||
senderAddress: process.env.EMAIL_SENDER_ADDRESS || 'noreply@manacore.ai',
|
||||
senderName: process.env.EMAIL_SENDER_NAME || 'ManaCore',
|
||||
},
|
||||
|
||||
urls: {
|
||||
baseUrl: process.env.BASE_URL || 'http://localhost:3001',
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,509 +0,0 @@
|
|||
CREATE SCHEMA IF NOT EXISTS "auth";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA IF NOT EXISTS "credits";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA IF NOT EXISTS "feedback";
|
||||
--> statement-breakpoint
|
||||
CREATE SCHEMA IF NOT EXISTS "referrals";
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."user_role" AS ENUM('user', 'admin', 'service'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."transaction_status" AS ENUM('pending', 'completed', 'failed', 'cancelled'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."transaction_type" AS ENUM('purchase', 'usage', 'refund', 'bonus', 'expiry', 'adjustment'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."feedback_category" AS ENUM('bug', 'feature', 'improvement', 'question', 'other'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."feedback_status" AS ENUM('submitted', 'under_review', 'planned', 'in_progress', 'completed', 'declined'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."bonus_event_type" AS ENUM('registered', 'activated', 'qualified', 'retained', 'cross_app'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."bonus_status" AS ENUM('pending', 'paid', 'held', 'rejected'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."fraud_pattern_type" AS ENUM('email_domain', 'ip_range', 'device_pattern'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."fraud_severity" AS ENUM('low', 'medium', 'high', 'critical'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."referral_code_type" AS ENUM('auto', 'custom', 'campaign'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."referral_status" AS ENUM('registered', 'activated', 'qualified', 'retained'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."referral_tier" AS ENUM('bronze', 'silver', 'gold', 'platinum'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN CREATE TYPE "public"."review_status" AS ENUM('pending', 'approved', 'rejected', 'escalated'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."accounts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp with time zone,
|
||||
"refresh_token_expires_at" timestamp with time zone,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."jwks" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"public_key" text NOT NULL,
|
||||
"private_key" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."passwords" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"hashed_password" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."security_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text,
|
||||
"event_type" text NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL,
|
||||
"refresh_token" text,
|
||||
"refresh_token_expires_at" timestamp with time zone,
|
||||
"device_id" text,
|
||||
"device_name" text,
|
||||
"last_activity_at" timestamp with time zone DEFAULT now(),
|
||||
"revoked_at" timestamp with time zone,
|
||||
"remember_me" boolean DEFAULT false,
|
||||
CONSTRAINT "sessions_token_unique" UNIQUE("token"),
|
||||
CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."two_factor_auth" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"secret" text NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"backup_codes" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"enabled_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."user_settings" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"global_settings" jsonb DEFAULT '{"nav":{"desktopPosition":"top","sidebarCollapsed":false},"theme":{"mode":"system","colorScheme":"ocean"},"locale":"de"}'::jsonb NOT NULL,
|
||||
"app_overrides" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"device_settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."users" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"role" "user_role" DEFAULT 'user' NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."verification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "credits"."balances" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"balance" integer DEFAULT 0 NOT NULL,
|
||||
"free_credits_remaining" integer DEFAULT 150 NOT NULL,
|
||||
"daily_free_credits" integer DEFAULT 5 NOT NULL,
|
||||
"last_daily_reset_at" timestamp with time zone DEFAULT now(),
|
||||
"total_earned" integer DEFAULT 0 NOT NULL,
|
||||
"total_spent" integer DEFAULT 0 NOT NULL,
|
||||
"version" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "credits"."credit_allocations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"employee_id" text NOT NULL,
|
||||
"amount" integer NOT NULL,
|
||||
"allocated_by" text NOT NULL,
|
||||
"reason" text,
|
||||
"balance_before" integer NOT NULL,
|
||||
"balance_after" integer NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "credits"."organization_balances" (
|
||||
"organization_id" text PRIMARY KEY NOT NULL,
|
||||
"balance" integer DEFAULT 0 NOT NULL,
|
||||
"allocated_credits" integer DEFAULT 0 NOT NULL,
|
||||
"available_credits" integer DEFAULT 0 NOT NULL,
|
||||
"total_purchased" integer DEFAULT 0 NOT NULL,
|
||||
"total_allocated" integer DEFAULT 0 NOT NULL,
|
||||
"version" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "credits"."packages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"credits" integer NOT NULL,
|
||||
"price_euro_cents" integer NOT NULL,
|
||||
"stripe_price_id" text,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "packages_stripe_price_id_unique" UNIQUE("stripe_price_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "credits"."purchases" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"package_id" uuid,
|
||||
"credits" integer NOT NULL,
|
||||
"price_euro_cents" integer NOT NULL,
|
||||
"stripe_payment_intent_id" text,
|
||||
"stripe_customer_id" text,
|
||||
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
CONSTRAINT "purchases_stripe_payment_intent_id_unique" UNIQUE("stripe_payment_intent_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "credits"."transactions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"type" "transaction_type" NOT NULL,
|
||||
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
|
||||
"amount" integer NOT NULL,
|
||||
"balance_before" integer NOT NULL,
|
||||
"balance_after" integer NOT NULL,
|
||||
"app_id" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"organization_id" text,
|
||||
"metadata" jsonb,
|
||||
"idempotency_key" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
CONSTRAINT "transactions_idempotency_key_unique" UNIQUE("idempotency_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "credits"."usage_stats" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"app_id" text NOT NULL,
|
||||
"credits_used" integer NOT NULL,
|
||||
"date" timestamp with time zone NOT NULL,
|
||||
"metadata" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "feedback"."feedback_votes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"feedback_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "feedback"."user_feedback" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"app_id" text NOT NULL,
|
||||
"title" text,
|
||||
"feedback_text" text NOT NULL,
|
||||
"category" "feedback_category" DEFAULT 'feature' NOT NULL,
|
||||
"status" "feedback_status" DEFAULT 'submitted' NOT NULL,
|
||||
"is_public" boolean DEFAULT false NOT NULL,
|
||||
"admin_response" text,
|
||||
"vote_count" integer DEFAULT 0 NOT NULL,
|
||||
"device_info" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."invitations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"inviter_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."members" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "auth"."organizations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text,
|
||||
"logo" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "organizations_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."bonus_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"relationship_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"event_type" "bonus_event_type" NOT NULL,
|
||||
"app_id" text,
|
||||
"credits_base" integer NOT NULL,
|
||||
"tier_multiplier" real DEFAULT 1 NOT NULL,
|
||||
"credits_final" integer NOT NULL,
|
||||
"tier_at_time" "referral_tier" NOT NULL,
|
||||
"transaction_id" uuid,
|
||||
"status" "bonus_status" DEFAULT 'pending' NOT NULL,
|
||||
"hold_reason" text,
|
||||
"hold_until" timestamp with time zone,
|
||||
"released_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."codes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"type" "referral_code_type" DEFAULT 'auto' NOT NULL,
|
||||
"source_app_id" text,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"uses_count" integer DEFAULT 0 NOT NULL,
|
||||
"max_uses" integer,
|
||||
"expires_at" timestamp with time zone,
|
||||
"metadata" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "codes_code_unique" UNIQUE("code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."cross_app_activations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"relationship_id" uuid NOT NULL,
|
||||
"app_id" text NOT NULL,
|
||||
"activated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"bonus_paid" boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT "cross_app_relationship_app_unique" UNIQUE("relationship_id","app_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."daily_stats" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"date" timestamp with time zone NOT NULL,
|
||||
"app_id" text,
|
||||
"registrations" integer DEFAULT 0 NOT NULL,
|
||||
"activations" integer DEFAULT 0 NOT NULL,
|
||||
"qualifications" integer DEFAULT 0 NOT NULL,
|
||||
"retentions" integer DEFAULT 0 NOT NULL,
|
||||
"credits_paid" integer DEFAULT 0 NOT NULL,
|
||||
"credits_held" integer DEFAULT 0 NOT NULL,
|
||||
"fraud_blocked" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "daily_stats_date_app_unique" UNIQUE("date","app_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."fingerprints" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"ip_hash" text NOT NULL,
|
||||
"ip_type" text DEFAULT 'unknown' NOT NULL,
|
||||
"ip_country" text,
|
||||
"ip_asn" text,
|
||||
"device_hash" text,
|
||||
"user_agent_hash" text,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"registration_count" integer DEFAULT 0 NOT NULL,
|
||||
"flagged_count" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "fingerprints_ip_device_unique" UNIQUE("ip_hash","device_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."fraud_patterns" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pattern_type" "fraud_pattern_type" NOT NULL,
|
||||
"pattern_value" text NOT NULL,
|
||||
"severity" "fraud_severity" DEFAULT 'medium' NOT NULL,
|
||||
"score_impact" integer NOT NULL,
|
||||
"description" text,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_by" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."rate_limits" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"identifier_type" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"count" integer DEFAULT 1 NOT NULL,
|
||||
"window_start" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"window_end" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."relationships" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"referrer_id" text NOT NULL,
|
||||
"referee_id" text NOT NULL,
|
||||
"code_id" uuid NOT NULL,
|
||||
"source_app_id" text,
|
||||
"status" "referral_status" DEFAULT 'registered' NOT NULL,
|
||||
"registered_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"activated_at" timestamp with time zone,
|
||||
"qualified_at" timestamp with time zone,
|
||||
"retained_at" timestamp with time zone,
|
||||
"fraud_score" integer DEFAULT 0 NOT NULL,
|
||||
"fraud_signals" text,
|
||||
"is_flagged" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "relationships_referee_id_unique" UNIQUE("referee_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."review_queue" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"relationship_id" uuid NOT NULL,
|
||||
"fraud_score" integer NOT NULL,
|
||||
"fraud_signals" text NOT NULL,
|
||||
"priority" "fraud_severity" DEFAULT 'medium' NOT NULL,
|
||||
"status" "review_status" DEFAULT 'pending' NOT NULL,
|
||||
"assigned_to" text,
|
||||
"notes" text,
|
||||
"reviewed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."user_fingerprints" (
|
||||
"user_id" text NOT NULL,
|
||||
"fingerprint_id" uuid NOT NULL,
|
||||
"seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"context" text,
|
||||
CONSTRAINT "user_fingerprints_pk" UNIQUE("user_id","fingerprint_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "referrals"."user_tiers" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"tier" "referral_tier" DEFAULT 'bronze' NOT NULL,
|
||||
"qualified_count" integer DEFAULT 0 NOT NULL,
|
||||
"total_earned" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "tags" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"color" varchar(7) DEFAULT '#3B82F6',
|
||||
"icon" varchar(50),
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tags_user_name_unique" UNIQUE("user_id","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "auth"."accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "auth"."passwords" ADD CONSTRAINT "passwords_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "auth"."security_events" ADD CONSTRAINT "security_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "auth"."two_factor_auth" ADD CONSTRAINT "two_factor_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "auth"."user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."balances" ADD CONSTRAINT "balances_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."credit_allocations" ADD CONSTRAINT "credit_allocations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."credit_allocations" ADD CONSTRAINT "credit_allocations_employee_id_users_id_fk" FOREIGN KEY ("employee_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."credit_allocations" ADD CONSTRAINT "credit_allocations_allocated_by_users_id_fk" FOREIGN KEY ("allocated_by") REFERENCES "auth"."users"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."organization_balances" ADD CONSTRAINT "organization_balances_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "credits"."packages"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."transactions" ADD CONSTRAINT "transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."transactions" ADD CONSTRAINT "transactions_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "credits"."usage_stats" ADD CONSTRAINT "usage_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "feedback"."feedback_votes" ADD CONSTRAINT "feedback_votes_feedback_id_user_feedback_id_fk" FOREIGN KEY ("feedback_id") REFERENCES "feedback"."user_feedback"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "feedback"."feedback_votes" ADD CONSTRAINT "feedback_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "feedback"."user_feedback" ADD CONSTRAINT "user_feedback_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "auth"."invitations" ADD CONSTRAINT "invitations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "auth"."members" ADD CONSTRAINT "members_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."bonus_events" ADD CONSTRAINT "bonus_events_relationship_id_relationships_id_fk" FOREIGN KEY ("relationship_id") REFERENCES "referrals"."relationships"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."bonus_events" ADD CONSTRAINT "bonus_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."codes" ADD CONSTRAINT "codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."cross_app_activations" ADD CONSTRAINT "cross_app_activations_relationship_id_relationships_id_fk" FOREIGN KEY ("relationship_id") REFERENCES "referrals"."relationships"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."relationships" ADD CONSTRAINT "relationships_referrer_id_users_id_fk" FOREIGN KEY ("referrer_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."relationships" ADD CONSTRAINT "relationships_referee_id_users_id_fk" FOREIGN KEY ("referee_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."relationships" ADD CONSTRAINT "relationships_code_id_codes_id_fk" FOREIGN KEY ("code_id") REFERENCES "referrals"."codes"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."review_queue" ADD CONSTRAINT "review_queue_relationship_id_relationships_id_fk" FOREIGN KEY ("relationship_id") REFERENCES "referrals"."relationships"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."user_fingerprints" ADD CONSTRAINT "user_fingerprints_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."user_fingerprints" ADD CONSTRAINT "user_fingerprints_fingerprint_id_fingerprints_id_fk" FOREIGN KEY ("fingerprint_id") REFERENCES "referrals"."fingerprints"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN ALTER TABLE "referrals"."user_tiers" ADD CONSTRAINT "user_tiers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "verification_identifier_idx" ON "auth"."verification" USING btree ("identifier");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "credit_allocations_organization_id_idx" ON "credits"."credit_allocations" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "credit_allocations_employee_id_idx" ON "credits"."credit_allocations" USING btree ("employee_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "credit_allocations_allocated_by_idx" ON "credits"."credit_allocations" USING btree ("allocated_by");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "credit_allocations_created_at_idx" ON "credits"."credit_allocations" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "purchases_user_id_idx" ON "credits"."purchases" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "purchases_stripe_payment_intent_id_idx" ON "credits"."purchases" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "transactions_user_id_idx" ON "credits"."transactions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "transactions_app_id_idx" ON "credits"."transactions" USING btree ("app_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "transactions_organization_id_idx" ON "credits"."transactions" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "transactions_created_at_idx" ON "credits"."transactions" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "transactions_idempotency_key_idx" ON "credits"."transactions" USING btree ("idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "usage_stats_user_id_date_idx" ON "credits"."usage_stats" USING btree ("user_id","date");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "usage_stats_app_id_date_idx" ON "credits"."usage_stats" USING btree ("app_id","date");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "feedback_vote_unique" ON "feedback"."feedback_votes" USING btree ("feedback_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_votes_feedback_idx" ON "feedback"."feedback_votes" USING btree ("feedback_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_user_idx" ON "feedback"."user_feedback" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_app_idx" ON "feedback"."user_feedback" USING btree ("app_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_public_idx" ON "feedback"."user_feedback" USING btree ("is_public");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_status_idx" ON "feedback"."user_feedback" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_created_at_idx" ON "feedback"."user_feedback" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "invitations_organization_id_idx" ON "auth"."invitations" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "invitations_email_idx" ON "auth"."invitations" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "invitations_status_idx" ON "auth"."invitations" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "members_organization_id_idx" ON "auth"."members" USING btree ("organization_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "members_user_id_idx" ON "auth"."members" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "members_organization_user_idx" ON "auth"."members" USING btree ("organization_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "organizations_slug_idx" ON "auth"."organizations" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "bonus_events_relationship_idx" ON "referrals"."bonus_events" USING btree ("relationship_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "bonus_events_user_idx" ON "referrals"."bonus_events" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "bonus_events_status_idx" ON "referrals"."bonus_events" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "bonus_events_event_type_idx" ON "referrals"."bonus_events" USING btree ("event_type");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "codes_lookup_idx" ON "referrals"."codes" USING btree ("code");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "codes_user_idx" ON "referrals"."codes" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "codes_active_idx" ON "referrals"."codes" USING btree ("is_active");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cross_app_relationship_idx" ON "referrals"."cross_app_activations" USING btree ("relationship_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "daily_stats_date_app_idx" ON "referrals"."daily_stats" USING btree ("date","app_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "fingerprints_ip_hash_idx" ON "referrals"."fingerprints" USING btree ("ip_hash");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "fingerprints_device_hash_idx" ON "referrals"."fingerprints" USING btree ("device_hash");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "fraud_patterns_active_idx" ON "referrals"."fraud_patterns" USING btree ("is_active");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "fraud_patterns_type_idx" ON "referrals"."fraud_patterns" USING btree ("pattern_type");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "rate_limits_lookup_idx" ON "referrals"."rate_limits" USING btree ("identifier","identifier_type","action");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "rate_limits_window_idx" ON "referrals"."rate_limits" USING btree ("window_end");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "relationships_referrer_idx" ON "referrals"."relationships" USING btree ("referrer_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "relationships_referee_idx" ON "referrals"."relationships" USING btree ("referee_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "relationships_status_idx" ON "referrals"."relationships" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "relationships_flagged_idx" ON "referrals"."relationships" USING btree ("is_flagged");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "relationships_code_idx" ON "referrals"."relationships" USING btree ("code_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "review_queue_status_priority_idx" ON "referrals"."review_queue" USING btree ("status","priority");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "review_queue_relationship_idx" ON "referrals"."review_queue" USING btree ("relationship_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_fingerprints_user_idx" ON "referrals"."user_fingerprints" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_fingerprints_fingerprint_idx" ON "referrals"."user_fingerprints" USING btree ("fingerprint_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tags_user_idx" ON "tags" USING btree ("user_id");
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
-- Migration: Add missing columns to sessions table
|
||||
-- This handles the case where the table was created by db:push before these columns were added
|
||||
|
||||
-- Add missing columns to sessions table (IF NOT EXISTS equivalent using DO block)
|
||||
DO $$
|
||||
BEGIN
|
||||
-- refresh_token column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'refresh_token') THEN
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "refresh_token" text;
|
||||
END IF;
|
||||
|
||||
-- refresh_token_expires_at column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'refresh_token_expires_at') THEN
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "refresh_token_expires_at" timestamp with time zone;
|
||||
END IF;
|
||||
|
||||
-- device_id column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'device_id') THEN
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "device_id" text;
|
||||
END IF;
|
||||
|
||||
-- device_name column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'device_name') THEN
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "device_name" text;
|
||||
END IF;
|
||||
|
||||
-- last_activity_at column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'last_activity_at') THEN
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "last_activity_at" timestamp with time zone DEFAULT now();
|
||||
END IF;
|
||||
|
||||
-- revoked_at column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'revoked_at') THEN
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "revoked_at" timestamp with time zone;
|
||||
END IF;
|
||||
|
||||
-- remember_me column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'auth' AND table_name = 'sessions' AND column_name = 'remember_me') THEN
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN "remember_me" boolean DEFAULT false;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Add unique constraint on refresh_token if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sessions_refresh_token_unique') THEN
|
||||
ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token");
|
||||
END IF;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
-- Fix missing session columns using native PostgreSQL syntax
|
||||
-- This is more reliable than DO blocks for Drizzle migrations
|
||||
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN IF NOT EXISTS "refresh_token" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp with time zone;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN IF NOT EXISTS "device_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN IF NOT EXISTS "device_name" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN IF NOT EXISTS "last_activity_at" timestamp with time zone DEFAULT now();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN IF NOT EXISTS "revoked_at" timestamp with time zone;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth"."sessions" ADD COLUMN IF NOT EXISTS "remember_me" boolean DEFAULT false;
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
-- Add error_logs schema and table for centralized error tracking
|
||||
-- This migration is safe to run on existing databases
|
||||
|
||||
-- Create error_logs schema if not exists
|
||||
CREATE SCHEMA IF NOT EXISTS "error_logs";
|
||||
|
||||
-- Create enum types if not exist (PostgreSQL 9.1+ required for IF NOT EXISTS)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "error_source_type" AS ENUM('backend', 'frontend_web', 'frontend_mobile');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "error_environment" AS ENUM('development', 'staging', 'production');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "error_severity" AS ENUM('debug', 'info', 'warning', 'error', 'critical');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Create error_logs table
|
||||
CREATE TABLE IF NOT EXISTS "error_logs"."error_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"error_code" text NOT NULL,
|
||||
"error_type" text NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"stack_trace" text,
|
||||
"app_id" text NOT NULL,
|
||||
"source_type" "error_source_type",
|
||||
"service_name" text,
|
||||
"user_id" text,
|
||||
"session_id" text,
|
||||
"request_url" text,
|
||||
"request_method" text,
|
||||
"request_headers" jsonb,
|
||||
"request_body" jsonb,
|
||||
"response_status_code" integer,
|
||||
"environment" "error_environment",
|
||||
"severity" "error_severity" DEFAULT 'error',
|
||||
"context" jsonb DEFAULT '{}'::jsonb,
|
||||
"fingerprint" text,
|
||||
"user_agent" text,
|
||||
"browser_info" jsonb,
|
||||
"device_info" jsonb,
|
||||
"occurred_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Add foreign key constraint (safe - ignores if exists)
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "error_logs"."error_logs"
|
||||
ADD CONSTRAINT "error_logs_user_id_users_id_fk"
|
||||
FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id")
|
||||
ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Create indexes (safe - ignores if exists)
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_app_id_idx" ON "error_logs"."error_logs" USING btree ("app_id");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_user_id_idx" ON "error_logs"."error_logs" USING btree ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_environment_idx" ON "error_logs"."error_logs" USING btree ("environment");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_severity_idx" ON "error_logs"."error_logs" USING btree ("severity");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_occurred_at_idx" ON "error_logs"."error_logs" USING btree ("occurred_at");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_error_code_idx" ON "error_logs"."error_logs" USING btree ("error_code");
|
||||
CREATE INDEX IF NOT EXISTS "error_logs_fingerprint_idx" ON "error_logs"."error_logs" USING btree ("fingerprint");
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"id": "0001_add_missing_session_columns",
|
||||
"prevId": "0000_naive_scorpion",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"id": "0002_fix_session_columns",
|
||||
"prevId": "0001_add_missing_session_columns",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1734500000000,
|
||||
"tag": "0000_naive_scorpion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1734550000000,
|
||||
"tag": "0001_add_missing_session_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1734560000000,
|
||||
"tag": "0002_fix_session_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1734600000000,
|
||||
"tag": "0003_add_error_logs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -47,7 +47,6 @@ export const sessions = authSchema.table('sessions', {
|
|||
deviceName: text('device_name'),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow(),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
rememberMe: boolean('remember_me').default(false),
|
||||
});
|
||||
|
||||
// Accounts table (for OAuth providers and credentials - Better Auth schema)
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import {
|
||||
pgSchema,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
integer,
|
||||
index,
|
||||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth.schema';
|
||||
|
||||
export const errorLogsSchema = pgSchema('error_logs');
|
||||
|
||||
// Source type enum
|
||||
export const errorSourceTypeEnum = pgEnum('error_source_type', [
|
||||
'backend',
|
||||
'frontend_web',
|
||||
'frontend_mobile',
|
||||
]);
|
||||
|
||||
// Environment enum
|
||||
export const errorEnvironmentEnum = pgEnum('error_environment', [
|
||||
'development',
|
||||
'staging',
|
||||
'production',
|
||||
]);
|
||||
|
||||
// Severity enum
|
||||
export const errorSeverityEnum = pgEnum('error_severity', [
|
||||
'debug',
|
||||
'info',
|
||||
'warning',
|
||||
'error',
|
||||
'critical',
|
||||
]);
|
||||
|
||||
// Error logs table
|
||||
export const errorLogs = errorLogsSchema.table(
|
||||
'error_logs',
|
||||
{
|
||||
// Primary key
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
// Error identification
|
||||
errorCode: text('error_code').notNull(),
|
||||
errorType: text('error_type').notNull(),
|
||||
message: text('message').notNull(),
|
||||
stackTrace: text('stack_trace'),
|
||||
|
||||
// Source identification
|
||||
appId: text('app_id').notNull(),
|
||||
sourceType: errorSourceTypeEnum('source_type'),
|
||||
serviceName: text('service_name'),
|
||||
|
||||
// User context (optional)
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
sessionId: text('session_id'),
|
||||
|
||||
// Request metadata (backend errors)
|
||||
requestUrl: text('request_url'),
|
||||
requestMethod: text('request_method'),
|
||||
requestHeaders: jsonb('request_headers'),
|
||||
requestBody: jsonb('request_body'),
|
||||
responseStatusCode: integer('response_status_code'),
|
||||
|
||||
// Classification
|
||||
environment: errorEnvironmentEnum('environment'),
|
||||
severity: errorSeverityEnum('severity').default('error'),
|
||||
|
||||
// Additional context
|
||||
context: jsonb('context').default({}),
|
||||
fingerprint: text('fingerprint'),
|
||||
|
||||
// Browser/device info (frontend errors)
|
||||
userAgent: text('user_agent'),
|
||||
browserInfo: jsonb('browser_info'),
|
||||
deviceInfo: jsonb('device_info'),
|
||||
|
||||
// Timestamps
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
appIdIdx: index('error_logs_app_id_idx').on(table.appId),
|
||||
userIdIdx: index('error_logs_user_id_idx').on(table.userId),
|
||||
environmentIdx: index('error_logs_environment_idx').on(table.environment),
|
||||
severityIdx: index('error_logs_severity_idx').on(table.severity),
|
||||
occurredAtIdx: index('error_logs_occurred_at_idx').on(table.occurredAt),
|
||||
errorCodeIdx: index('error_logs_error_code_idx').on(table.errorCode),
|
||||
fingerprintIdx: index('error_logs_fingerprint_idx').on(table.fingerprint),
|
||||
})
|
||||
);
|
||||
|
||||
// Type exports
|
||||
export type ErrorLog = typeof errorLogs.$inferSelect;
|
||||
export type NewErrorLog = typeof errorLogs.$inferInsert;
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
export * from './auth.schema';
|
||||
export * from './credits.schema';
|
||||
export * from './error-logs.schema';
|
||||
export * from './feedback.schema';
|
||||
export * from './organizations.schema';
|
||||
export * from './referrals.schema';
|
||||
|
|
|
|||
|
|
@ -1,252 +0,0 @@
|
|||
/**
|
||||
* Standalone Brevo Email Client
|
||||
*
|
||||
* This is a standalone email client that can be used outside of NestJS DI,
|
||||
* specifically for Better Auth email handlers which are initialized before
|
||||
* the NestJS application context is available.
|
||||
*
|
||||
* For regular application code, use the EmailService instead.
|
||||
*/
|
||||
|
||||
import * as brevo from '@getbrevo/brevo';
|
||||
|
||||
interface BrevoConfig {
|
||||
apiKey: string | undefined;
|
||||
senderEmail: string;
|
||||
senderName: string;
|
||||
}
|
||||
|
||||
interface SendEmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlContent: string;
|
||||
textContent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Brevo configuration from environment variables
|
||||
*/
|
||||
function getConfig(): BrevoConfig {
|
||||
return {
|
||||
apiKey: process.env.BREVO_API_KEY,
|
||||
senderEmail: process.env.EMAIL_SENDER_ADDRESS || 'noreply@manacore.app',
|
||||
senderName: process.env.EMAIL_SENDER_NAME || 'ManaCore',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using Brevo API
|
||||
*
|
||||
* Falls back to console logging if BREVO_API_KEY is not set
|
||||
*/
|
||||
export async function sendEmail(params: SendEmailParams): Promise<boolean> {
|
||||
const config = getConfig();
|
||||
const { to, subject, htmlContent, textContent } = params;
|
||||
|
||||
if (!config.apiKey) {
|
||||
console.log('[Email - DEV MODE] Would send email:');
|
||||
console.log(` To: ${to}`);
|
||||
console.log(` Subject: ${subject}`);
|
||||
console.log(` Content preview: ${htmlContent.substring(0, 200)}...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiInstance = new brevo.TransactionalEmailsApi();
|
||||
apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, config.apiKey);
|
||||
|
||||
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||
sendSmtpEmail.subject = subject;
|
||||
sendSmtpEmail.htmlContent = htmlContent;
|
||||
sendSmtpEmail.textContent = textContent;
|
||||
sendSmtpEmail.sender = {
|
||||
name: config.senderName,
|
||||
email: config.senderEmail,
|
||||
};
|
||||
sendSmtpEmail.to = [{ email: to }];
|
||||
|
||||
const response = await apiInstance.sendTransacEmail(sendSmtpEmail);
|
||||
console.log(`[Email] Sent to ${to}, messageId: ${response.body.messageId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Email] Failed to send to ${to}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
export async function sendPasswordResetEmail(params: {
|
||||
email: string;
|
||||
name?: string;
|
||||
resetUrl: string;
|
||||
}): Promise<boolean> {
|
||||
const { email, name, resetUrl } = params;
|
||||
const displayName = name || 'there';
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset Your Password</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 20px;">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Reset Your Password</h1>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
|
||||
Hi ${displayName},
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
|
||||
We received a request to reset the password for your ManaCore account. Click the button below to choose a new password:
|
||||
</p>
|
||||
<table role="presentation" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${resetUrl}" style="display: inline-block; padding: 14px 28px; font-size: 16px; font-weight: 600; color: #ffffff; background-color: #6366f1; text-decoration: none; border-radius: 6px;">
|
||||
Reset Password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; font-size: 14px; line-height: 22px; color: #6b6b6b;">
|
||||
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999;">
|
||||
If the button above doesn't work, copy and paste this URL into your browser:<br>
|
||||
<a href="${resetUrl}" style="color: #6366f1; word-break: break-all;">${resetUrl}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 40px 40px;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999; text-align: center;">
|
||||
© ${new Date().getFullYear()} ManaCore. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textContent = `
|
||||
Reset Your Password
|
||||
|
||||
Hi ${displayName},
|
||||
|
||||
We received a request to reset the password for your ManaCore account.
|
||||
|
||||
Reset your password by visiting this link:
|
||||
${resetUrl}
|
||||
|
||||
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
|
||||
|
||||
© ${new Date().getFullYear()} ManaCore. All rights reserved.
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: 'Reset Your Password - ManaCore',
|
||||
htmlContent,
|
||||
textContent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send organization invitation email
|
||||
*/
|
||||
export async function sendOrganizationInviteEmail(params: {
|
||||
email: string;
|
||||
organizationName: string;
|
||||
inviterName?: string;
|
||||
inviteUrl: string;
|
||||
role: string;
|
||||
}): Promise<boolean> {
|
||||
const { email, organizationName, inviterName, inviteUrl, role } = params;
|
||||
const inviterText = inviterName ? `${inviterName} has invited you` : 'You have been invited';
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Organization Invitation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 20px;">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #1a1a1a;">You're Invited!</h1>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
|
||||
${inviterText} to join <strong>${organizationName}</strong> on ManaCore as a <strong>${role}</strong>.
|
||||
</p>
|
||||
<table role="presentation" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${inviteUrl}" style="display: inline-block; padding: 14px 28px; font-size: 16px; font-weight: 600; color: #ffffff; background-color: #6366f1; text-decoration: none; border-radius: 6px;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; font-size: 14px; line-height: 22px; color: #6b6b6b;">
|
||||
This invitation will expire in 7 days. If you don't want to join this organization, you can safely ignore this email.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999;">
|
||||
If the button above doesn't work, copy and paste this URL into your browser:<br>
|
||||
<a href="${inviteUrl}" style="color: #6366f1; word-break: break-all;">${inviteUrl}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 40px 40px;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999; text-align: center;">
|
||||
© ${new Date().getFullYear()} ManaCore. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textContent = `
|
||||
You're Invited!
|
||||
|
||||
${inviterText} to join ${organizationName} on ManaCore as a ${role}.
|
||||
|
||||
Accept your invitation by visiting this link:
|
||||
${inviteUrl}
|
||||
|
||||
This invitation will expire in 7 days. If you don't want to join this organization, you can safely ignore this email.
|
||||
|
||||
© ${new Date().getFullYear()} ManaCore. All rights reserved.
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: `You're invited to join ${organizationName} - ManaCore`,
|
||||
htmlContent,
|
||||
textContent,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
/**
|
||||
* Email Module
|
||||
*
|
||||
* Provides transactional email functionality using Brevo.
|
||||
* This module is marked as Global so the EmailService can be
|
||||
* injected anywhere without importing the module.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
/**
|
||||
* Email Service using Brevo API
|
||||
*
|
||||
* Handles transactional emails for:
|
||||
* - Password reset
|
||||
* - Organization invitations
|
||||
* - Email verification (future)
|
||||
*
|
||||
* @see https://developers.brevo.com/reference/sendtransacemail
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as brevo from '@getbrevo/brevo';
|
||||
|
||||
export interface SendEmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlContent: string;
|
||||
textContent?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetEmailOptions {
|
||||
email: string;
|
||||
name?: string;
|
||||
resetUrl: string;
|
||||
}
|
||||
|
||||
export interface OrganizationInviteEmailOptions {
|
||||
email: string;
|
||||
organizationName: string;
|
||||
inviterName?: string;
|
||||
inviteUrl: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
private readonly apiInstance: brevo.TransactionalEmailsApi;
|
||||
private readonly senderEmail: string;
|
||||
private readonly senderName: string;
|
||||
private readonly isEnabled: boolean;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('BREVO_API_KEY');
|
||||
this.senderEmail =
|
||||
this.configService.get<string>('EMAIL_SENDER_ADDRESS') || 'noreply@manacore.app';
|
||||
this.senderName = this.configService.get<string>('EMAIL_SENDER_NAME') || 'ManaCore';
|
||||
this.isEnabled = !!apiKey;
|
||||
|
||||
this.apiInstance = new brevo.TransactionalEmailsApi();
|
||||
|
||||
if (apiKey) {
|
||||
this.apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, apiKey);
|
||||
this.logger.log('Brevo email service initialized');
|
||||
} else {
|
||||
this.logger.warn('BREVO_API_KEY not set - emails will be logged to console only');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a transactional email
|
||||
*/
|
||||
async sendEmail(options: SendEmailOptions): Promise<boolean> {
|
||||
const { to, subject, htmlContent, textContent } = options;
|
||||
|
||||
if (!this.isEnabled) {
|
||||
this.logger.log('[DEV MODE] Email would be sent:');
|
||||
this.logger.log(` To: ${to}`);
|
||||
this.logger.log(` Subject: ${subject}`);
|
||||
this.logger.log(` Content: ${htmlContent.substring(0, 200)}...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||
sendSmtpEmail.subject = subject;
|
||||
sendSmtpEmail.htmlContent = htmlContent;
|
||||
sendSmtpEmail.textContent = textContent;
|
||||
sendSmtpEmail.sender = {
|
||||
name: this.senderName,
|
||||
email: this.senderEmail,
|
||||
};
|
||||
sendSmtpEmail.to = [{ email: to }];
|
||||
|
||||
const response = await this.apiInstance.sendTransacEmail(sendSmtpEmail);
|
||||
this.logger.log(`Email sent successfully to ${to}, messageId: ${response.body.messageId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send email to ${to}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async sendPasswordResetEmail(options: PasswordResetEmailOptions): Promise<boolean> {
|
||||
const { email, name, resetUrl } = options;
|
||||
const displayName = name || 'there';
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset Your Password</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 20px;">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Reset Your Password</h1>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
|
||||
Hi ${displayName},
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
|
||||
We received a request to reset the password for your ManaCore account. Click the button below to choose a new password:
|
||||
</p>
|
||||
<table role="presentation" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${resetUrl}" style="display: inline-block; padding: 14px 28px; font-size: 16px; font-weight: 600; color: #ffffff; background-color: #6366f1; text-decoration: none; border-radius: 6px;">
|
||||
Reset Password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; font-size: 14px; line-height: 22px; color: #6b6b6b;">
|
||||
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999;">
|
||||
If the button above doesn't work, copy and paste this URL into your browser:<br>
|
||||
<a href="${resetUrl}" style="color: #6366f1; word-break: break-all;">${resetUrl}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 40px 40px;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999; text-align: center;">
|
||||
© ${new Date().getFullYear()} ManaCore. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textContent = `
|
||||
Reset Your Password
|
||||
|
||||
Hi ${displayName},
|
||||
|
||||
We received a request to reset the password for your ManaCore account.
|
||||
|
||||
Reset your password by visiting this link:
|
||||
${resetUrl}
|
||||
|
||||
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
|
||||
|
||||
© ${new Date().getFullYear()} ManaCore. All rights reserved.
|
||||
`;
|
||||
|
||||
return this.sendEmail({
|
||||
to: email,
|
||||
subject: 'Reset Your Password - ManaCore',
|
||||
htmlContent,
|
||||
textContent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send organization invitation email
|
||||
*/
|
||||
async sendOrganizationInviteEmail(options: OrganizationInviteEmailOptions): Promise<boolean> {
|
||||
const { email, organizationName, inviterName, inviteUrl, role } = options;
|
||||
const inviterText = inviterName ? `${inviterName} has invited you` : 'You have been invited';
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Organization Invitation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 20px;">
|
||||
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #1a1a1a;">You're Invited!</h1>
|
||||
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
|
||||
${inviterText} to join <strong>${organizationName}</strong> on ManaCore as a <strong>${role}</strong>.
|
||||
</p>
|
||||
<table role="presentation" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${inviteUrl}" style="display: inline-block; padding: 14px 28px; font-size: 16px; font-weight: 600; color: #ffffff; background-color: #6366f1; text-decoration: none; border-radius: 6px;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 20px; font-size: 14px; line-height: 22px; color: #6b6b6b;">
|
||||
This invitation will expire in 7 days. If you don't want to join this organization, you can safely ignore this email.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999;">
|
||||
If the button above doesn't work, copy and paste this URL into your browser:<br>
|
||||
<a href="${inviteUrl}" style="color: #6366f1; word-break: break-all;">${inviteUrl}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 40px 40px;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999; text-align: center;">
|
||||
© ${new Date().getFullYear()} ManaCore. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textContent = `
|
||||
You're Invited!
|
||||
|
||||
${inviterText} to join ${organizationName} on ManaCore as a ${role}.
|
||||
|
||||
Accept your invitation by visiting this link:
|
||||
${inviteUrl}
|
||||
|
||||
This invitation will expire in 7 days. If you don't want to join this organization, you can safely ignore this email.
|
||||
|
||||
© ${new Date().getFullYear()} ManaCore. All rights reserved.
|
||||
`;
|
||||
|
||||
return this.sendEmail({
|
||||
to: email,
|
||||
subject: `You're invited to join ${organizationName} - ManaCore`,
|
||||
htmlContent,
|
||||
textContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export { EmailModule } from './email.module';
|
||||
export { EmailService } from './email.service';
|
||||
export type {
|
||||
SendEmailOptions,
|
||||
PasswordResetEmailOptions,
|
||||
OrganizationInviteEmailOptions,
|
||||
} from './email.service';
|
||||
|
||||
// Standalone email client for use outside NestJS DI (e.g., Better Auth config)
|
||||
export { sendEmail, sendPasswordResetEmail, sendOrganizationInviteEmail } from './brevo-client';
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Type } from 'class-transformer';
|
||||
import { ValidateNested, IsArray, ArrayMaxSize, ArrayMinSize } from 'class-validator';
|
||||
import { CreateErrorLogDto } from './create-error-log.dto';
|
||||
|
||||
export class BatchErrorLogDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(100)
|
||||
@Type(() => CreateErrorLogDto)
|
||||
errors: CreateErrorLogDto[];
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
MaxLength,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateErrorLogDto {
|
||||
// Required fields
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
errorCode: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
errorType: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
|
||||
// Optional fields
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50000)
|
||||
stackTrace?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
appId?: string;
|
||||
|
||||
@IsEnum(['backend', 'frontend_web', 'frontend_mobile'])
|
||||
@IsOptional()
|
||||
sourceType?: 'backend' | 'frontend_web' | 'frontend_mobile';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
serviceName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
userId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
sessionId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(2000)
|
||||
requestUrl?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(10)
|
||||
requestMethod?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
requestHeaders?: Record<string, unknown>;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
requestBody?: Record<string, unknown>;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(100)
|
||||
@Max(599)
|
||||
responseStatusCode?: number;
|
||||
|
||||
@IsEnum(['development', 'staging', 'production'])
|
||||
@IsOptional()
|
||||
environment?: 'development' | 'staging' | 'production';
|
||||
|
||||
@IsEnum(['debug', 'info', 'warning', 'error', 'critical'])
|
||||
@IsOptional()
|
||||
severity?: 'debug' | 'info' | 'warning' | 'error' | 'critical';
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
context?: Record<string, unknown>;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(256)
|
||||
fingerprint?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
browserInfo?: Record<string, unknown>;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
deviceInfo?: Record<string, unknown>;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
userAgent?: string;
|
||||
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
occurredAt?: string;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './create-error-log.dto';
|
||||
export * from './batch-error-log.dto';
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { Controller, Post, Body, Headers, UseGuards } from '@nestjs/common';
|
||||
import { ErrorLogsService } from './error-logs.service';
|
||||
import { OptionalAuthGuard } from '../common/guards/optional-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { CreateErrorLogDto, BatchErrorLogDto } from './dto';
|
||||
|
||||
@Controller('api/v1/errors')
|
||||
export class ErrorLogsController {
|
||||
constructor(private readonly errorLogsService: ErrorLogsService) {}
|
||||
|
||||
/**
|
||||
* Create a single error log entry
|
||||
* Authentication is optional - uses user context if available
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
async createErrorLog(
|
||||
@CurrentUser() user: CurrentUserData | null,
|
||||
@Body() dto: CreateErrorLogDto,
|
||||
@Headers('x-app-id') appIdHeader?: string
|
||||
) {
|
||||
return this.errorLogsService.createErrorLog(dto, appIdHeader, user?.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple error log entries in batch
|
||||
* Useful for batch reporting of errors (e.g., on app startup or periodic sync)
|
||||
*/
|
||||
@Post('batch')
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
async createErrorLogsBatch(
|
||||
@CurrentUser() user: CurrentUserData | null,
|
||||
@Body() dto: BatchErrorLogDto,
|
||||
@Headers('x-app-id') appIdHeader?: string
|
||||
) {
|
||||
return this.errorLogsService.createErrorLogsBatch(dto.errors, appIdHeader, user?.userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ErrorLogsController } from './error-logs.controller';
|
||||
import { ErrorLogsService } from './error-logs.service';
|
||||
import { OptionalAuthGuard } from '../common/guards/optional-auth.guard';
|
||||
|
||||
@Module({
|
||||
controllers: [ErrorLogsController],
|
||||
providers: [ErrorLogsService, OptionalAuthGuard],
|
||||
exports: [ErrorLogsService],
|
||||
})
|
||||
export class ErrorLogsModule {}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from '../db/connection';
|
||||
import { errorLogs } from '../db/schema';
|
||||
import type { CreateErrorLogDto } from './dto';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// Sensitive header keys to sanitize
|
||||
const SENSITIVE_HEADERS = ['authorization', 'cookie', 'x-api-key', 'api-key'];
|
||||
|
||||
// Sensitive body field keys to sanitize
|
||||
const SENSITIVE_BODY_FIELDS = ['password', 'token', 'secret', 'apikey', 'api_key'];
|
||||
|
||||
@Injectable()
|
||||
export class ErrorLogsService {
|
||||
private readonly logger = new Logger(ErrorLogsService.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single error log entry
|
||||
*/
|
||||
async createErrorLog(
|
||||
dto: CreateErrorLogDto,
|
||||
appIdHeader?: string,
|
||||
userId?: string
|
||||
): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
try {
|
||||
const db = this.getDb();
|
||||
|
||||
const appId = dto.appId || appIdHeader || 'unknown';
|
||||
const sanitizedHeaders = this.sanitizeHeaders(dto.requestHeaders);
|
||||
const sanitizedBody = this.sanitizeBody(dto.requestBody);
|
||||
const fingerprint = dto.fingerprint || this.generateFingerprint(dto, appId);
|
||||
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
|
||||
|
||||
const [errorLog] = await db
|
||||
.insert(errorLogs)
|
||||
.values({
|
||||
errorCode: dto.errorCode,
|
||||
errorType: dto.errorType,
|
||||
message: dto.message,
|
||||
stackTrace: dto.stackTrace,
|
||||
appId,
|
||||
sourceType: dto.sourceType,
|
||||
serviceName: dto.serviceName,
|
||||
userId: dto.userId || userId,
|
||||
sessionId: dto.sessionId,
|
||||
requestUrl: dto.requestUrl,
|
||||
requestMethod: dto.requestMethod,
|
||||
requestHeaders: sanitizedHeaders,
|
||||
requestBody: sanitizedBody,
|
||||
responseStatusCode: dto.responseStatusCode,
|
||||
environment: dto.environment,
|
||||
severity: dto.severity || 'error',
|
||||
context: dto.context || {},
|
||||
fingerprint,
|
||||
userAgent: dto.userAgent,
|
||||
browserInfo: dto.browserInfo,
|
||||
deviceInfo: dto.deviceInfo,
|
||||
occurredAt,
|
||||
})
|
||||
.returning({ id: errorLogs.id });
|
||||
|
||||
return { success: true, id: errorLog.id };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create error log', error);
|
||||
return { success: false, error: 'Failed to create error log' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple error log entries in batch
|
||||
*/
|
||||
async createErrorLogsBatch(
|
||||
errors: CreateErrorLogDto[],
|
||||
appIdHeader?: string,
|
||||
userId?: string
|
||||
): Promise<{ success: boolean; total: number; succeeded: number; failed: number }> {
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const errorDto of errors) {
|
||||
const result = await this.createErrorLog(errorDto, appIdHeader, userId);
|
||||
if (result.success) {
|
||||
succeeded++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: failed === 0,
|
||||
total: errors.length,
|
||||
succeeded,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize headers to remove sensitive information
|
||||
*/
|
||||
private sanitizeHeaders(headers?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (SENSITIVE_HEADERS.includes(key.toLowerCase())) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize body to remove sensitive information
|
||||
*/
|
||||
private sanitizeBody(body?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!body) return undefined;
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (SENSITIVE_BODY_FIELDS.includes(key.toLowerCase())) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
sanitized[key] = this.sanitizeBody(value as Record<string, unknown>);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fingerprint for error grouping/deduplication
|
||||
*/
|
||||
private generateFingerprint(dto: CreateErrorLogDto, appId: string): string {
|
||||
const parts = [
|
||||
dto.errorCode,
|
||||
dto.errorType,
|
||||
appId,
|
||||
dto.requestMethod || '',
|
||||
this.extractPathFromUrl(dto.requestUrl),
|
||||
];
|
||||
|
||||
const hash = crypto.createHash('sha256').update(parts.join('|')).digest('hex');
|
||||
return hash.substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path from URL (without query parameters)
|
||||
*/
|
||||
private extractPathFromUrl(url?: string): string {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url, 'http://placeholder');
|
||||
return parsed.pathname;
|
||||
} catch {
|
||||
// If URL parsing fails, try to extract path manually
|
||||
const queryStart = url.indexOf('?');
|
||||
return queryStart > -1 ? url.substring(0, queryStart) : url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import { ValidationPipe } from '@nestjs/common';
|
|||
import { ConfigService } from '@nestjs/config';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { createCorsConfig } from '@manacore/shared-nestjs-cors';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
|
|
@ -11,76 +10,24 @@ async function bootstrap() {
|
|||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// Comprehensive security headers with OWASP best practices
|
||||
// Security middleware - configure helmet to allow CORS
|
||||
app.use(
|
||||
helmet({
|
||||
// HSTS: Force HTTPS connections
|
||||
strictTransportSecurity: {
|
||||
maxAge: 31536000, // 1 year
|
||||
includeSubDomains: true,
|
||||
preload: true,
|
||||
},
|
||||
|
||||
// Content Security Policy: XSS protection
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for NestJS
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
},
|
||||
},
|
||||
|
||||
// Clickjacking protection
|
||||
frameguard: { action: 'deny' },
|
||||
|
||||
// MIME-type sniffing protection
|
||||
noSniff: true,
|
||||
|
||||
// XSS filter
|
||||
xssFilter: true,
|
||||
|
||||
// Referrer policy
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
|
||||
// CORS headers
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
||||
|
||||
// Hide powered-by header
|
||||
hidePoweredBy: true,
|
||||
})
|
||||
);
|
||||
|
||||
// HTTPS enforcement in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use((req: any, res: any, next: any) => {
|
||||
const protocol = req.header('x-forwarded-proto') || req.protocol;
|
||||
if (protocol !== 'https') {
|
||||
return res.redirect(301, `https://${req.header('host')}${req.url}`);
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
// CORS configuration with cross-app communication
|
||||
// Auth service needs to be accessible by ALL ManaCore apps
|
||||
const corsOriginsEnv = configService.get<string>('cors.origin');
|
||||
console.log('📋 CORS Origins from env:', corsOriginsEnv);
|
||||
app.enableCors(
|
||||
createCorsConfig({
|
||||
corsOriginsEnv,
|
||||
includeAllManaApps: true, // 🎯 Enable all ManaCore apps to authenticate
|
||||
additionalOrigins: [], // Keep X-App-Id support for custom headers
|
||||
})
|
||||
);
|
||||
// CORS configuration
|
||||
const corsOrigins = configService.get<string[]>('cors.origin') || [];
|
||||
console.log('📋 CORS Origins configured:', corsOrigins);
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-App-Id'],
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from '../db/connection';
|
||||
import { securityEvents } from '../db/schema/auth.schema';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Security Event Types
|
||||
*
|
||||
* Comprehensive list of security-relevant events for audit logging
|
||||
* and compliance (GDPR, SOC 2, ISO 27001).
|
||||
*/
|
||||
export type SecurityEventType =
|
||||
| 'login_success'
|
||||
| 'login_failure'
|
||||
| 'logout'
|
||||
| 'password_change'
|
||||
| 'password_reset_requested'
|
||||
| 'password_reset_completed'
|
||||
| 'account_created'
|
||||
| 'account_deleted'
|
||||
| 'token_refresh'
|
||||
| 'token_validation_failure'
|
||||
| 'session_expired'
|
||||
| 'session_revoked'
|
||||
| 'email_verified'
|
||||
| 'organization_joined'
|
||||
| 'organization_left';
|
||||
|
||||
/**
|
||||
* Parameters for logging security events
|
||||
*/
|
||||
export interface LogSecurityEventParams {
|
||||
/** User ID (null for anonymous events like failed login) */
|
||||
userId?: string;
|
||||
|
||||
/** Type of security event */
|
||||
eventType: SecurityEventType;
|
||||
|
||||
/** IP address of the request */
|
||||
ipAddress?: string;
|
||||
|
||||
/** User agent string from the request */
|
||||
userAgent?: string;
|
||||
|
||||
/** Additional metadata (device info, error codes, etc.) */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security Events Service
|
||||
*
|
||||
* Provides centralized security event logging for compliance and audit trails.
|
||||
* All authentication and authorization events should be logged here.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* await this.securityEventsService.logEvent({
|
||||
* userId: user.id,
|
||||
* eventType: 'login_success',
|
||||
* ipAddress: req.ip,
|
||||
* userAgent: req.headers['user-agent'],
|
||||
* metadata: { deviceId: 'xyz' }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class SecurityEventsService {
|
||||
private databaseUrl: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.databaseUrl = this.configService.get<string>('database.url')!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a security event to the database
|
||||
*
|
||||
* This method never throws - if logging fails, it logs to console
|
||||
* to prevent security logging from breaking application flow.
|
||||
*
|
||||
* @param params - Event parameters
|
||||
*/
|
||||
async logEvent(params: LogSecurityEventParams): Promise<void> {
|
||||
try {
|
||||
const db = getDb(this.databaseUrl);
|
||||
|
||||
await db.insert(securityEvents).values({
|
||||
id: randomUUID(),
|
||||
userId: params.userId || null,
|
||||
eventType: params.eventType,
|
||||
ipAddress: params.ipAddress || null,
|
||||
userAgent: params.userAgent || null,
|
||||
metadata: params.metadata || null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Never throw - security logging should not break app flow
|
||||
console.error('[SecurityEventsService] Failed to log security event:', {
|
||||
eventType: params.eventType,
|
||||
userId: params.userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query recent security events for a user
|
||||
*
|
||||
* Useful for "Recent Activity" features and security dashboards.
|
||||
*
|
||||
* @param userId - User ID
|
||||
* @param limit - Max number of events to return (default: 10)
|
||||
* @returns Recent security events
|
||||
*/
|
||||
async getUserRecentEvents(userId: string, limit = 10) {
|
||||
try {
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { eq, desc } = await import('drizzle-orm');
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(securityEvents)
|
||||
.where(eq(securityEvents.userId, userId))
|
||||
.orderBy(desc(securityEvents.createdAt))
|
||||
.limit(limit);
|
||||
} catch (error) {
|
||||
console.error('[SecurityEventsService] Failed to query user events:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SecurityEventsService } from './security-events.service';
|
||||
|
||||
/**
|
||||
* Security Module
|
||||
*
|
||||
* Provides security-related services for the application:
|
||||
* - Security event logging and audit trails
|
||||
* - Compliance support (GDPR, SOC 2, ISO 27001)
|
||||
*
|
||||
* Import this module in AppModule to enable security logging across the app.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [SecurityEventsService],
|
||||
exports: [SecurityEventsService],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
|
|
@ -53,75 +53,67 @@ interface MockInvitation {
|
|||
}
|
||||
|
||||
// Mock API responses
|
||||
// Note: Better Auth API returns data directly (not wrapped in { data: ... })
|
||||
const createMockApi = () => ({
|
||||
// Auth endpoints
|
||||
signUpEmail: jest.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 'mock-user-id',
|
||||
email: 'mock@example.com',
|
||||
name: 'Mock User',
|
||||
role: 'user',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
session: {
|
||||
id: 'mock-session-id',
|
||||
token: 'mock-session-token',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
data: {
|
||||
user: {
|
||||
id: 'mock-user-id',
|
||||
email: 'mock@example.com',
|
||||
name: 'Mock User',
|
||||
role: 'user',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
session: {
|
||||
token: 'mock-session-token',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
signInEmail: jest.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 'mock-user-id',
|
||||
email: 'mock@example.com',
|
||||
name: 'Mock User',
|
||||
role: 'user',
|
||||
data: {
|
||||
user: {
|
||||
id: 'mock-user-id',
|
||||
email: 'mock@example.com',
|
||||
name: 'Mock User',
|
||||
role: 'user',
|
||||
},
|
||||
session: {
|
||||
token: 'mock-session-token',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
session: {
|
||||
id: 'mock-session-id',
|
||||
token: 'mock-session-token',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
token: 'mock-access-token',
|
||||
}),
|
||||
|
||||
signOut: jest.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
getSession: jest.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 'mock-user-id',
|
||||
email: 'mock@example.com',
|
||||
name: 'Mock User',
|
||||
role: 'user',
|
||||
// Organization endpoints
|
||||
createOrganization: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
id: 'mock-org-id',
|
||||
name: 'Mock Organization',
|
||||
slug: 'mock-organization',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
session: {
|
||||
id: 'mock-session-id',
|
||||
token: 'mock-session-token',
|
||||
}),
|
||||
|
||||
listOrganizations: jest.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
|
||||
inviteMember: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
id: 'mock-invitation-id',
|
||||
email: 'invitee@example.com',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
}),
|
||||
|
||||
// Organization endpoints
|
||||
createOrganization: jest.fn().mockResolvedValue({
|
||||
id: 'mock-org-id',
|
||||
name: 'Mock Organization',
|
||||
slug: 'mock-organization',
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
|
||||
listOrganizations: jest.fn().mockResolvedValue([]),
|
||||
|
||||
inviteMember: jest.fn().mockResolvedValue({
|
||||
id: 'mock-invitation-id',
|
||||
email: 'invitee@example.com',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
}),
|
||||
|
||||
acceptInvitation: jest.fn().mockResolvedValue({
|
||||
member: {
|
||||
data: {
|
||||
id: 'mock-member-id',
|
||||
organizationId: 'mock-org-id',
|
||||
userId: 'mock-user-id',
|
||||
|
|
@ -129,35 +121,23 @@ const createMockApi = () => ({
|
|||
},
|
||||
}),
|
||||
|
||||
getFullOrganization: jest.fn().mockResolvedValue({
|
||||
id: 'mock-org-id',
|
||||
name: 'Mock Organization',
|
||||
slug: 'mock-organization',
|
||||
members: [],
|
||||
listOrganizationMembers: jest.fn().mockResolvedValue({
|
||||
data: [],
|
||||
}),
|
||||
|
||||
listOrganizationMembers: jest.fn().mockResolvedValue([]),
|
||||
|
||||
removeMember: jest.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
setActiveOrganization: jest.fn().mockResolvedValue({
|
||||
userId: 'mock-user-id',
|
||||
activeOrganizationId: 'mock-org-id',
|
||||
session: {
|
||||
id: 'mock-session-id',
|
||||
activeOrganizationId: 'mock-org-id',
|
||||
data: {
|
||||
session: {
|
||||
activeOrganizationId: 'mock-org-id',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
getActiveOrganization: jest.fn().mockResolvedValue(null),
|
||||
|
||||
// JWT methods
|
||||
signJWT: jest.fn().mockResolvedValue({ token: 'mock-jwt-token' }),
|
||||
getJwks: jest.fn().mockResolvedValue({ keys: [] }),
|
||||
|
||||
// Password reset methods
|
||||
requestPasswordReset: jest.fn().mockResolvedValue({ status: true }),
|
||||
resetPassword: jest.fn().mockResolvedValue({ status: true }),
|
||||
getActiveOrganization: jest.fn().mockResolvedValue({
|
||||
data: null,
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock auth instance
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
/**
|
||||
* Mock implementation of jose for tests
|
||||
*
|
||||
* Provides mock implementations of JWT verification and JWKS functions
|
||||
* used by better-auth.service.ts
|
||||
*/
|
||||
|
||||
export interface JWTPayload {
|
||||
sub?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
sessionId?: string;
|
||||
sid?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
aud?: string | string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JWTVerifyResult {
|
||||
payload: JWTPayload;
|
||||
protectedHeader: {
|
||||
alg: string;
|
||||
typ?: string;
|
||||
};
|
||||
key?: any; // Optional key from ResolvedKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock JWKS implementation
|
||||
*/
|
||||
class MockKeySet {
|
||||
private url: URL;
|
||||
|
||||
constructor(url: URL) {
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock jwtVerify function
|
||||
* Returns a valid payload for testing purposes
|
||||
*/
|
||||
export const jwtVerify = jest.fn(
|
||||
async (token: string, _keySet: MockKeySet, _options?: unknown): Promise<JWTVerifyResult> => {
|
||||
// For tests, decode the token if it's a valid JWT format, otherwise return mock data
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length === 3) {
|
||||
// Try to decode the payload (middle part)
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
return {
|
||||
payload,
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If decoding fails, return mock data
|
||||
}
|
||||
|
||||
// Return mock payload for invalid/test tokens
|
||||
return {
|
||||
payload: {
|
||||
sub: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
sessionId: 'test-session-id',
|
||||
sid: 'test-session-id',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
iss: 'manacore',
|
||||
aud: 'manacore',
|
||||
},
|
||||
protectedHeader: { alg: 'EdDSA', typ: 'JWT' },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock createRemoteJWKSet function
|
||||
*/
|
||||
export const createRemoteJWKSet = jest.fn((url: URL) => {
|
||||
return new MockKeySet(url);
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock errors for jose
|
||||
*/
|
||||
export class JOSEError extends Error {
|
||||
code?: string;
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = 'JOSEError';
|
||||
}
|
||||
}
|
||||
|
||||
export class JWTExpired extends JOSEError {
|
||||
code = 'ERR_JWT_EXPIRED';
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'JWT expired');
|
||||
this.name = 'JWTExpired';
|
||||
}
|
||||
}
|
||||
|
||||
export class JWTInvalid extends JOSEError {
|
||||
code = 'ERR_JWT_INVALID';
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'JWT invalid');
|
||||
this.name = 'JWTInvalid';
|
||||
}
|
||||
}
|
||||
|
||||
export const errors = {
|
||||
JOSEError,
|
||||
JWTExpired,
|
||||
JWTInvalid,
|
||||
};
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
/**
|
||||
* Role Security Integration Tests
|
||||
*
|
||||
* Tests the security of the role field in Better Auth:
|
||||
* - input: false prevents clients from setting their own role
|
||||
* - Default role assignment works correctly
|
||||
* - JWT payload contains the correct role
|
||||
* - Zod validation rejects invalid roles (server-side)
|
||||
*
|
||||
* @see services/mana-core-auth/src/auth/better-auth.config.ts
|
||||
* @see docs/BETTER_AUTH_TYPING_IMPROVEMENTS.md
|
||||
*/
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BetterAuthService } from '../../src/auth/services/better-auth.service';
|
||||
import { SecurityEventsService } from '../../src/security-events/security-events.service';
|
||||
import { ReferralCodeService } from '../../src/referrals/referral-code.service';
|
||||
import { ReferralTierService } from '../../src/referrals/referral-tier.service';
|
||||
import { ReferralTrackingService } from '../../src/referrals/referral-tracking.service';
|
||||
import configuration from '../../src/config/configuration';
|
||||
|
||||
// Mock services that BetterAuthService depends on
|
||||
const mockSecurityEventsService = {
|
||||
logEvent: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockReferralCodeService = {
|
||||
createAutoCode: jest.fn().mockResolvedValue({ id: 'code-123', code: 'ABC123' }),
|
||||
};
|
||||
|
||||
const mockReferralTierService = {
|
||||
getTierBenefits: jest.fn().mockResolvedValue({ maxReferrals: 10 }),
|
||||
};
|
||||
|
||||
const mockReferralTrackingService = {
|
||||
trackReferral: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
describe('Role Security Integration Tests', () => {
|
||||
let betterAuthService: BetterAuthService;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
load: [configuration],
|
||||
isGlobal: true,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
BetterAuthService,
|
||||
{ provide: SecurityEventsService, useValue: mockSecurityEventsService },
|
||||
{ provide: ReferralCodeService, useValue: mockReferralCodeService },
|
||||
{ provide: ReferralTierService, useValue: mockReferralTierService },
|
||||
{ provide: ReferralTrackingService, useValue: mockReferralTrackingService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
betterAuthService = module.get<BetterAuthService>(BetterAuthService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
describe('Role Field Security (input: false)', () => {
|
||||
it('should assign default "user" role to new registrations', async () => {
|
||||
const uniqueEmail = `role-default-${Date.now()}@example.com`;
|
||||
|
||||
// Register user
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Default Role User',
|
||||
});
|
||||
|
||||
// Login to get the role (role is returned in SignInResult, not RegisterB2CResult)
|
||||
const loginResult = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
expect(loginResult.user).toBeDefined();
|
||||
expect(loginResult.user.role).toBe('user');
|
||||
});
|
||||
|
||||
it('should ignore role field in registration body (input: false security)', async () => {
|
||||
const uniqueEmail = `role-escalation-attempt-${Date.now()}@example.com`;
|
||||
|
||||
// Attempt to register with admin role (should be ignored)
|
||||
// The signUpEmail API won't accept role at all due to input: false
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Escalation Attempt User',
|
||||
// Note: If someone tries to add role: 'admin' to the request body,
|
||||
// Better Auth's input: false should ignore it
|
||||
});
|
||||
|
||||
// Login to verify the role
|
||||
const loginResult = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
expect(loginResult.user).toBeDefined();
|
||||
// Role should always be 'user' (the default), not 'admin'
|
||||
expect(loginResult.user.role).toBe('user');
|
||||
});
|
||||
|
||||
it('should include role in JWT payload after login', async () => {
|
||||
const uniqueEmail = `role-jwt-${Date.now()}@example.com`;
|
||||
|
||||
// Register
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'JWT Role User',
|
||||
});
|
||||
|
||||
// Login
|
||||
const loginResult = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
expect(loginResult.accessToken).toBeDefined();
|
||||
|
||||
// Validate token and check role in payload
|
||||
const validationResult = await betterAuthService.validateToken(loginResult.accessToken);
|
||||
|
||||
expect(validationResult.valid).toBe(true);
|
||||
expect(validationResult.payload).toBeDefined();
|
||||
expect(validationResult.payload?.role).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Validation', () => {
|
||||
it('should have valid role enum values in JWT payload', async () => {
|
||||
const uniqueEmail = `role-enum-${Date.now()}@example.com`;
|
||||
|
||||
// Register and login
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Enum Test User',
|
||||
});
|
||||
|
||||
const loginResult = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
const validationResult = await betterAuthService.validateToken(loginResult.accessToken);
|
||||
|
||||
// Role should be one of the valid enum values
|
||||
const validRoles = ['user', 'admin', 'service'];
|
||||
expect(validRoles).toContain(validationResult.payload?.role);
|
||||
});
|
||||
|
||||
// Note: This test requires a real database connection since refreshToken
|
||||
// validates the session against the database. Skipping in mock environment.
|
||||
it.skip('should preserve role across token refresh', async () => {
|
||||
const uniqueEmail = `role-refresh-${Date.now()}@example.com`;
|
||||
|
||||
// Register and login
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Refresh Role User',
|
||||
});
|
||||
|
||||
const loginResult = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
// Get initial role from token
|
||||
const initialValidation = await betterAuthService.validateToken(loginResult.accessToken);
|
||||
const initialRole = initialValidation.payload?.role;
|
||||
|
||||
// Refresh token
|
||||
const refreshResult = await betterAuthService.refreshToken(loginResult.refreshToken);
|
||||
|
||||
// Validate new token
|
||||
const refreshedValidation = await betterAuthService.validateToken(refreshResult.accessToken);
|
||||
|
||||
// Role should be preserved after refresh
|
||||
expect(refreshedValidation.payload?.role).toBe(initialRole);
|
||||
expect(refreshedValidation.payload?.role).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session and Role Consistency', () => {
|
||||
it('should maintain consistent role across multiple sessions', async () => {
|
||||
const uniqueEmail = `role-multi-session-${Date.now()}@example.com`;
|
||||
|
||||
// Register
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Multi Session Role User',
|
||||
});
|
||||
|
||||
// Create two sessions
|
||||
const session1 = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
deviceId: 'device-1',
|
||||
});
|
||||
|
||||
const session2 = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
deviceId: 'device-2',
|
||||
});
|
||||
|
||||
// Validate both sessions
|
||||
const validation1 = await betterAuthService.validateToken(session1.accessToken);
|
||||
const validation2 = await betterAuthService.validateToken(session2.accessToken);
|
||||
|
||||
// Roles should be the same
|
||||
expect(validation1.payload?.role).toBe(validation2.payload?.role);
|
||||
expect(validation1.payload?.role).toBe('user');
|
||||
});
|
||||
|
||||
it('should include user ID, email, role, and session ID in JWT payload', async () => {
|
||||
const uniqueEmail = `jwt-claims-${Date.now()}@example.com`;
|
||||
|
||||
// Register and login
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'JWT Claims User',
|
||||
});
|
||||
|
||||
const loginResult = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
const validation = await betterAuthService.validateToken(loginResult.accessToken);
|
||||
|
||||
// Check all required JWT claims are present (using structure check for mock environment)
|
||||
// Note: In mock environment, the jwtVerify mock returns test data, not the actual user data
|
||||
expect(validation.payload).toHaveProperty('sub');
|
||||
expect(validation.payload).toHaveProperty('email');
|
||||
expect(validation.payload).toHaveProperty('role');
|
||||
expect(validation.payload?.role).toBe('user');
|
||||
|
||||
// Session ID should be present
|
||||
expect(validation.payload?.sessionId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JWT Payload Minimalism', () => {
|
||||
it('should only contain minimal claims (no sensitive data)', async () => {
|
||||
const uniqueEmail = `minimal-claims-${Date.now()}@example.com`;
|
||||
|
||||
// Register and login
|
||||
await betterAuthService.registerB2C({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
name: 'Minimal Claims User',
|
||||
});
|
||||
|
||||
const loginResult = await betterAuthService.signIn({
|
||||
email: uniqueEmail,
|
||||
password: 'SecurePassword123!',
|
||||
});
|
||||
|
||||
const validation = await betterAuthService.validateToken(loginResult.accessToken);
|
||||
const payload = validation.payload;
|
||||
|
||||
// Should have these claims
|
||||
expect(payload).toHaveProperty('sub');
|
||||
expect(payload).toHaveProperty('email');
|
||||
expect(payload).toHaveProperty('role');
|
||||
|
||||
// Should NOT have these sensitive/dynamic claims
|
||||
expect(payload).not.toHaveProperty('password');
|
||||
expect(payload).not.toHaveProperty('hashedPassword');
|
||||
expect(payload).not.toHaveProperty('creditBalance');
|
||||
expect(payload).not.toHaveProperty('credit_balance');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,14 +14,13 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"transformIgnorePatterns": ["node_modules/(?!(nanoid|better-auth|jose)/)"],
|
||||
"transformIgnorePatterns": ["node_modules/(?!(nanoid|better-auth)/)"],
|
||||
"moduleNameMapper": {
|
||||
"^nanoid$": "<rootDir>/__mocks__/nanoid.ts",
|
||||
"^better-auth$": "<rootDir>/__mocks__/better-auth.ts",
|
||||
"^better-auth/plugins$": "<rootDir>/__mocks__/better-auth-plugins.ts",
|
||||
"^better-auth/plugins/(.*)$": "<rootDir>/__mocks__/better-auth-plugins.ts",
|
||||
"^better-auth/adapters/(.*)$": "<rootDir>/__mocks__/better-auth-adapters.ts",
|
||||
"^jose$": "<rootDir>/__mocks__/jose.ts"
|
||||
"^better-auth/adapters/(.*)$": "<rootDir>/__mocks__/better-auth-adapters.ts"
|
||||
},
|
||||
"testTimeout": 30000,
|
||||
"setupFilesAfterEnv": ["./setup-e2e.ts"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue