🐛 fix(mana-core-auth): use EdDSA for OIDC id_token signing

Set useJWTPlugin: true so id_tokens are signed with EdDSA keys
from JWKS instead of HS256. This fixes Synapse OIDC integration
which verifies tokens via JWKS endpoint.
This commit is contained in:
Till-JS 2026-02-01 13:24:55 +01:00
parent 5c61a4ed0f
commit efb077b9ea
22 changed files with 1605 additions and 142 deletions

View file

@ -14,6 +14,7 @@ import { HealthModule } from './health/health.module';
import { MetricsModule } from './metrics';
import { AnalyticsModule } from './analytics';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { LoggerModule } from './common/logger';
@Module({
imports: [
@ -27,6 +28,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
limit: 100, // 100 requests per minute
},
]),
LoggerModule,
MetricsModule,
AnalyticsModule,
AiModule,

View file

@ -10,6 +10,7 @@ import {
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { BetterAuthService } from './services/better-auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@ -45,6 +46,7 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
* - POST /auth/organizations/set-active - Switch active organization
*/
@Controller('auth')
@UseGuards(ThrottlerGuard)
export class AuthController {
constructor(private readonly betterAuthService: BetterAuthService) {}
@ -56,8 +58,10 @@ export class AuthController {
* Register a new B2C user (individual)
*
* Creates a user account and initializes their credit balance.
* Rate limited to 5 requests per minute to prevent abuse.
*/
@Post('register')
@Throttle({ default: { ttl: 60000, limit: 5 } })
async register(@Body() registerDto: RegisterDto) {
return this.betterAuthService.registerB2C({
email: registerDto.email,
@ -71,8 +75,10 @@ export class AuthController {
* Sign in with email and password
*
* Returns user data and JWT token.
* Rate limited to 10 requests per minute to prevent brute force.
*/
@Post('login')
@Throttle({ default: { ttl: 60000, limit: 10 } })
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto) {
return this.betterAuthService.signIn({
@ -150,8 +156,10 @@ export class AuthController {
*
* Initiates the password reset flow by sending an email with a reset link.
* Always returns success to prevent email enumeration attacks.
* Rate limited to 3 requests per minute to prevent abuse.
*/
@Post('forgot-password')
@Throttle({ default: { ttl: 60000, limit: 3 } })
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
return this.betterAuthService.requestPasswordReset(
@ -164,8 +172,10 @@ export class AuthController {
* Reset password with token
*
* Completes the password reset using the token from the email link.
* Rate limited to 5 requests per minute.
*/
@Post('reset-password')
@Throttle({ default: { ttl: 60000, limit: 5 } })
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
return this.betterAuthService.resetPassword(
@ -179,8 +189,10 @@ export class AuthController {
*
* Sends a new verification email to the user.
* Always returns success to prevent email enumeration attacks.
* Rate limited to 3 requests per minute to prevent abuse.
*/
@Post('resend-verification')
@Throttle({ default: { ttl: 60000, limit: 3 } })
@HttpCode(HttpStatus.OK)
async resendVerification(@Body() resendVerificationDto: ResendVerificationDto) {
return this.betterAuthService.resendVerificationEmail(
@ -198,8 +210,10 @@ export class AuthController {
*
* Creates an organization with the registering user as owner.
* Also creates organization credit balance.
* Rate limited to 3 requests per minute.
*/
@Post('register/b2b')
@Throttle({ default: { ttl: 60000, limit: 3 } })
async registerB2B(@Body() registerDto: RegisterB2BDto) {
return this.betterAuthService.registerB2B(registerDto);
}

View file

@ -15,12 +15,19 @@
import { Controller, Get, Param, Query, Res, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { BetterAuthService } from './services/better-auth.service';
import { LoggerService } from '../common/logger';
@Controller('api/auth')
export class BetterAuthPassthroughController {
private readonly defaultFrontendUrl = 'https://mana.how';
private readonly logger: LoggerService;
constructor(private readonly betterAuthService: BetterAuthService) {}
constructor(
private readonly betterAuthService: BetterAuthService,
loggerService: LoggerService
) {
this.logger = loggerService.setContext('BetterAuthPassthrough');
}
/**
* Validate redirect URL for security
@ -113,7 +120,10 @@ export class BetterAuthPassthroughController {
return res.redirect(`${fallbackUrl}/verification-failed?error=${result.error}`);
}
} catch (error) {
console.error('[verify-email] Error:', error);
this.logger.error(
'Email verification failed',
error instanceof Error ? error.stack : undefined
);
return res.redirect(`${fallbackUrl}/verification-failed?error=verification_failed`);
}
}
@ -156,10 +166,13 @@ export class BetterAuthPassthroughController {
const resetUrl = new URL('/reset-password', baseUrl);
resetUrl.searchParams.set('token', token);
console.log(`[reset-password] Redirecting to: ${resetUrl.toString()}`);
this.logger.debug('Password reset redirect', { destination: baseUrl });
return res.redirect(resetUrl.toString());
} catch (error) {
console.error('[reset-password] Error:', error);
this.logger.error(
'Password reset redirect failed',
error instanceof Error ? error.stack : undefined
);
return res.redirect(`${fallbackUrl}/login?error=reset_failed`);
}
}

View file

@ -319,7 +319,9 @@ export function createBetterAuth(databaseUrl: string) {
loginPage: '/login',
// Consent page (skipped for trusted clients)
consentPage: '/consent',
// Use JWT plugin for token signing
// Use JWT plugin for token signing (EdDSA instead of HS256)
// This is required for Synapse OIDC which verifies via JWKS
useJWTPlugin: true,
metadata: {
issuer: process.env.BASE_URL || 'http://localhost:3001',
},

View file

@ -20,10 +20,18 @@
import { Controller, Get, Post, All, Req, Res, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
import { BetterAuthService } from './services/better-auth.service';
import { LoggerService } from '../common/logger';
@Controller()
export class OidcController {
constructor(private readonly betterAuthService: BetterAuthService) {}
private readonly logger: LoggerService;
constructor(
private readonly betterAuthService: BetterAuthService,
loggerService: LoggerService
) {
this.logger = loggerService.setContext('OidcController');
}
/**
* OIDC Discovery Document
@ -45,9 +53,7 @@ export class OidcController {
*/
@Get('api/auth/oauth2/authorize')
async authorizeOauth2(@Req() req: Request, @Res() res: Response) {
console.log('[OIDC Authorize] URL:', req.originalUrl);
console.log('[OIDC Authorize] Query:', req.query);
console.log('[OIDC Authorize] redirect_uri:', req.query.redirect_uri);
this.logger.debug('OIDC authorize request', { clientId: req.query.client_id });
return this.handleOidcRequest(req, res);
}
@ -156,7 +162,7 @@ export class OidcController {
return res.end();
} catch (error) {
console.error('[BetterAuth] Error handling request:', error);
this.logger.error('OIDC request failed', error instanceof Error ? error.stack : undefined);
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
error: 'server_error',
error_description: 'Internal server error',
@ -243,7 +249,10 @@ export class OidcController {
return res.end();
} catch (error) {
console.error('[OIDC] Error handling request:', error);
this.logger.error(
'OIDC alternative path request failed',
error instanceof Error ? error.stack : undefined
);
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
error: 'server_error',
error_description: 'Internal server error',

View file

@ -23,6 +23,7 @@ import {
forwardRef,
Optional,
} from '@nestjs/common';
import { LoggerService } from '../../common/logger';
import { ConfigService } from '@nestjs/config';
import { createBetterAuth } from '../better-auth.config';
import type { BetterAuthInstance } from '../better-auth.config';
@ -64,7 +65,6 @@ import type {
BetterAuthUser,
BetterAuthSession,
} from '../types/better-auth.types';
import * as jwt from 'jsonwebtoken';
import { jwtVerify, createRemoteJWKSet } from 'jose';
// Re-export DTOs and result types for external use
@ -89,6 +89,7 @@ export type {
export class BetterAuthService {
private auth: BetterAuthInstance;
private databaseUrl: string;
private readonly logger: LoggerService;
/**
* Typed accessor for organization plugin API methods
@ -117,8 +118,10 @@ export class BetterAuthService {
private referralTierService: ReferralTierService,
@Optional()
@Inject(forwardRef(() => ReferralTrackingService))
private referralTrackingService: ReferralTrackingService
private referralTrackingService: ReferralTrackingService,
loggerService: LoggerService
) {
this.logger = loggerService.setContext('BetterAuthService');
this.databaseUrl = this.configService.get<string>('database.url')!;
this.auth = createBetterAuth(this.databaseUrl);
}
@ -346,7 +349,10 @@ export class BetterAuthService {
// Use type guard for safe access
return hasMembers(result) ? result.members : [];
} catch (error) {
console.error('Error fetching organization members:', error);
this.logger.error(
'Failed to fetch organization members',
error instanceof Error ? error.stack : undefined
);
return [];
}
}
@ -477,43 +483,13 @@ export class BetterAuthService {
throw new Error('Better Auth signJWT returned empty token');
}
} catch (jwtError) {
console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError);
this.logger.warn('Better Auth signJWT failed, using session token as fallback', {
error: jwtError instanceof Error ? jwtError.message : 'Unknown error',
});
// Fallback: Generate JWT manually using jsonwebtoken
const privateKey = this.configService.get<string>('jwt.privateKey');
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
console.log('[signIn] Private key exists:', !!privateKey);
console.log('[signIn] Private key length:', privateKey?.length);
console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30));
console.log('[signIn] Issuer:', issuer);
console.log('[signIn] Audience:', audience);
if (privateKey) {
const payload = {
sub: user.id,
email: user.email,
role: (user as BetterAuthUser).role || 'user',
sid: session?.id || '',
};
accessToken = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '15m',
issuer,
audience,
});
console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50));
// Decode to verify
const decoded = jwt.decode(accessToken, { complete: true });
console.log('[signIn] Generated JWT header:', decoded?.header);
console.log('[signIn] Generated JWT payload:', decoded?.payload);
} else {
console.error('[signIn] No JWT private key configured');
accessToken = sessionToken;
}
// Fallback: Use session token (Better Auth manages JWT signing via JWKS)
// NOTE: If signJWT fails repeatedly, check that the auth.jwks table has valid EdDSA keys
accessToken = sessionToken;
}
return {
@ -562,7 +538,9 @@ export class BetterAuthService {
} catch (error: unknown) {
// Even if signOut fails, we treat it as success for the user
// The session will expire naturally
console.error('Error during sign out:', error);
this.logger.warn('Sign out error (session will expire naturally)', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return { success: true, message: 'Signed out successfully' };
}
}
@ -628,7 +606,10 @@ export class BetterAuthService {
return { organizations };
} catch (error: unknown) {
console.error('Error listing organizations:', error);
this.logger.error(
'Failed to list organizations',
error instanceof Error ? error.stack : undefined
);
return { organizations: [] };
}
}
@ -821,18 +802,13 @@ export class BetterAuthService {
*/
async validateToken(token: string): Promise<ValidateTokenResult> {
try {
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
// Decode to check the algorithm
const decoded = jwt.decode(token, { complete: true });
console.log('[validateToken] Decoded header:', decoded?.header);
// Use our JWKS endpoint (NestJS prefix: /api/v1)
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
console.log('[validateToken] Using JWKS from:', jwksUrl.toString());
// Create JWKS fetcher
const JWKS = createRemoteJWKSet(jwksUrl);
@ -840,17 +816,13 @@ export class BetterAuthService {
const issuer = this.configService.get<string>('jwt.issuer') || baseUrl;
const audience = this.configService.get<string>('jwt.audience') || baseUrl;
console.log('[validateToken] Issuer:', issuer);
console.log('[validateToken] Audience:', audience);
// Verify using jose library with Better Auth's JWKS
const { payload } = await jwtVerify(token, JWKS, {
issuer,
audience,
});
console.log('[validateToken] Verification SUCCESS');
console.log('[validateToken] Payload:', payload);
this.logger.debug('Token validation successful', { userId: payload.sub });
return {
valid: true,
@ -858,7 +830,7 @@ export class BetterAuthService {
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[validateToken] Verification FAILED:', errorMessage);
this.logger.warn('Token validation failed', { error: errorMessage });
return {
valid: false,
error: errorMessage,
@ -906,7 +878,10 @@ export class BetterAuthService {
message: 'If an account with that email exists, a password reset link has been sent',
};
} catch (error) {
console.error('[requestPasswordReset] Error:', error);
this.logger.error(
'Password reset request failed',
error instanceof Error ? error.stack : undefined
);
// Always return success to prevent email enumeration attacks
return {
success: true,
@ -977,10 +952,9 @@ export class BetterAuthService {
query: { token },
});
console.log('[verifyEmail] Result:', result);
// Extract email from result if available
const email = result?.user?.email || result?.email;
this.logger.debug('Email verification successful', { email });
return {
success: true,
@ -988,7 +962,7 @@ export class BetterAuthService {
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[verifyEmail] Error:', errorMessage);
this.logger.warn('Email verification failed', { error: errorMessage });
if (errorMessage.includes('invalid') || errorMessage.includes('expired')) {
return {
@ -1038,7 +1012,10 @@ export class BetterAuthService {
message: 'If an account with that email exists, a verification email has been sent',
};
} catch (error) {
console.error('[resendVerificationEmail] Error:', error);
this.logger.error(
'Resend verification email failed',
error instanceof Error ? error.stack : undefined
);
// Always return success to prevent email enumeration attacks
return {
success: true,
@ -1082,7 +1059,7 @@ export class BetterAuthService {
}),
};
} catch (error) {
console.error('[getJwks] Error:', error);
this.logger.error('Failed to get JWKS', error instanceof Error ? error.stack : undefined);
return { keys: [] };
}
}
@ -1132,7 +1109,9 @@ export class BetterAuthService {
totalSpent: 0,
});
} catch (error) {
console.error('Error creating personal credit balance:', error);
this.logger.warn('Failed to create personal credit balance (non-critical)', {
error: error instanceof Error ? error.message : 'Unknown error',
});
// Don't throw - this is a non-critical operation
}
}
@ -1163,7 +1142,9 @@ export class BetterAuthService {
totalAllocated: 0,
});
} catch (error) {
console.error('Error creating organization credit balance:', error);
this.logger.warn('Failed to create organization credit balance (non-critical)', {
error: error instanceof Error ? error.message : 'Unknown error',
});
// Don't throw - this is a non-critical operation
}
}
@ -1227,12 +1208,15 @@ export class BetterAuthService {
});
if (!result.success) {
console.warn('[initializeUserReferrals] Failed to apply referral code:', result.error);
this.logger.warn('Failed to apply referral code', { error: result.error, referralCode });
}
}
} catch (error) {
// Log but don't fail registration if referral setup fails
console.error('[initializeUserReferrals] Error setting up referrals:', error);
this.logger.error(
'Error setting up referrals',
error instanceof Error ? error.stack : undefined
);
}
}
@ -1353,7 +1337,10 @@ export class BetterAuthService {
body,
};
} catch (error) {
console.error('[handleOidcRequest] Error:', error);
this.logger.error(
'OIDC request handling failed',
error instanceof Error ? error.stack : undefined
);
throw error;
}
}

View file

@ -13,6 +13,7 @@ import type { TestingModule } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from './jwt-auth.guard';
import { LoggerService } from '../logger';
import { createMockConfigService, httpMockHelpers } from '../../__tests__/utils/test-helpers';
import { mockTokenFactory } from '../../__tests__/utils/mock-factories';
import { silentError } from '../../__tests__/utils/silent-error.decorator';
@ -21,6 +22,18 @@ import { jwtVerify } from 'jose';
// Mock jose (auto-mocked via jest.config.js moduleNameMapper)
jest.mock('jose');
// Mock LoggerService
const createMockLoggerService = (): LoggerService =>
({
setContext: jest.fn().mockReturnThis(),
log: jest.fn(),
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
verbose: jest.fn(),
}) as unknown as LoggerService;
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let configService: ConfigService;
@ -41,6 +54,10 @@ describe('JwtAuthGuard', () => {
'jwt.audience': 'manacore',
}),
},
{
provide: LoggerService,
useValue: createMockLoggerService(),
},
],
}).compile();
@ -344,7 +361,8 @@ describe('JwtAuthGuard', () => {
createMockConfigService({
'jwt.issuer': 'manacore',
'jwt.audience': 'manacore',
})
}),
createMockLoggerService()
);
const mockRequest = httpMockHelpers.createMockRequest({
@ -375,7 +393,8 @@ describe('JwtAuthGuard', () => {
BASE_URL: 'http://localhost:3001',
'jwt.issuer': 'custom-issuer',
'jwt.audience': 'custom-audience',
})
}),
createMockLoggerService()
);
const mockRequest = httpMockHelpers.createMockRequest({

View file

@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { LoggerService } from '../logger';
/**
* JWT Auth Guard using JWKS (Better Auth compatible)
@ -16,17 +17,20 @@ import { jwtVerify, createRemoteJWKSet } from 'jose';
@Injectable()
export class JwtAuthGuard implements CanActivate {
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
private readonly logger: LoggerService;
constructor(private configService: ConfigService) {}
constructor(
private configService: ConfigService,
loggerService: LoggerService
) {
this.logger = loggerService.setContext('JwtAuthGuard');
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
console.log('[JwtAuthGuard] Token (first 50 chars):', token?.substring(0, 50));
if (!token) {
console.log('[JwtAuthGuard] No token provided');
throw new UnauthorizedException('No token provided');
}
@ -35,21 +39,18 @@ export class JwtAuthGuard implements CanActivate {
if (!this.jwks) {
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
console.log('[JwtAuthGuard] Initializing JWKS from:', jwksUrl.toString());
this.jwks = createRemoteJWKSet(jwksUrl);
}
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
console.log('[JwtAuthGuard] Verifying with issuer:', issuer, 'audience:', audience);
const { payload } = await jwtVerify(token, this.jwks, {
issuer,
audience,
});
console.log('[JwtAuthGuard] Verification SUCCESS, user:', payload.sub);
this.logger.debug('Token verification successful', { userId: payload.sub });
// Attach user to request
request.user = {
@ -60,7 +61,9 @@ export class JwtAuthGuard implements CanActivate {
return true;
} catch (error) {
console.error('[JwtAuthGuard] Token verification FAILED:', error);
this.logger.warn('Token verification failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new UnauthorizedException('Invalid token');
}
}

View file

@ -0,0 +1,2 @@
export { LoggerService, getLogger } from './logger.service';
export { LoggerModule } from './logger.module';

View file

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

View file

@ -0,0 +1,95 @@
import { Injectable, LoggerService as NestLoggerService, Scope } from '@nestjs/common';
import * as winston from 'winston';
const { combine, timestamp, printf, colorize, json } = winston.format;
// Custom format for development (readable)
const devFormat = printf(({ level, message, timestamp, context, ...meta }) => {
const ctx = context ? `[${context}]` : '';
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
return `${timestamp} ${level} ${ctx} ${message}${metaStr}`;
});
// Create winston logger instance
function createLogger(): winston.Logger {
const isProduction = process.env.NODE_ENV === 'production';
return winston.createLogger({
level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
format: isProduction
? combine(timestamp(), json())
: combine(timestamp({ format: 'HH:mm:ss' }), colorize(), devFormat),
transports: [new winston.transports.Console()],
// Don't exit on error
exitOnError: false,
});
}
@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService implements NestLoggerService {
private logger: winston.Logger;
private context?: string;
constructor() {
this.logger = createLogger();
}
setContext(context: string): this {
this.context = context;
return this;
}
log(message: string, ...optionalParams: unknown[]): void {
this.logger.info(message, this.formatMeta(optionalParams));
}
info(message: string, meta?: Record<string, unknown>): void {
this.logger.info(message, { context: this.context, ...meta });
}
error(message: string, trace?: string, ...optionalParams: unknown[]): void {
this.logger.error(message, {
context: this.context,
trace,
...this.formatMeta(optionalParams),
});
}
warn(message: string, ...optionalParams: unknown[]): void {
this.logger.warn(message, this.formatMeta(optionalParams));
}
debug(message: string, ...optionalParams: unknown[]): void {
this.logger.debug(message, this.formatMeta(optionalParams));
}
verbose(message: string, ...optionalParams: unknown[]): void {
this.logger.verbose(message, this.formatMeta(optionalParams));
}
private formatMeta(optionalParams: unknown[]): Record<string, unknown> {
const meta: Record<string, unknown> = { context: this.context };
if (optionalParams.length === 1 && typeof optionalParams[0] === 'string') {
// NestJS passes context as last param
meta.context = optionalParams[0];
} else if (optionalParams.length > 0) {
meta.params = optionalParams;
}
return meta;
}
}
// Singleton instance for use outside DI (main.ts, scripts)
let globalLogger: LoggerService | null = null;
export function getLogger(context?: string): LoggerService {
if (!globalLogger) {
globalLogger = new LoggerService();
}
if (context) {
return new LoggerService().setContext(context);
}
return globalLogger;
}

View file

@ -1,52 +1,76 @@
/**
* Application Configuration
*
* Loads and validates environment variables.
* Fails fast at startup if required variables are missing.
*/
import { validateEnv, isDevelopment } from './env.validation';
// Validate environment on module load
const env = validateEnv();
export default () => ({
port: parseInt(process.env.PORT || '3001', 10),
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(env.PORT, 10),
nodeEnv: env.NODE_ENV,
database: {
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
// In development, allow fallback to local database
// In production, DATABASE_URL is validated as required
url:
env.DATABASE_URL ||
(isDevelopment() ? 'postgresql://manacore:manacore@localhost:5432/manacore_auth' : ''),
},
jwt: {
// Convert \n string literals to actual newlines for PEM format
publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
issuer: process.env.JWT_ISSUER || 'manacore',
audience: process.env.JWT_AUDIENCE || 'manacore',
// Better Auth uses JWKS from database, these are legacy/fallback
publicKey: (env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
privateKey: (env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
accessTokenExpiry: env.JWT_ACCESS_TOKEN_EXPIRY,
refreshTokenExpiry: env.JWT_REFRESH_TOKEN_EXPIRY,
issuer: env.JWT_ISSUER,
audience: env.JWT_AUDIENCE,
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
host: env.REDIS_HOST || 'localhost',
port: parseInt(env.REDIS_PORT || '6379', 10),
password: env.REDIS_PASSWORD,
},
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
secretKey: env.STRIPE_SECRET_KEY || '',
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
},
cors: {
origin: process.env.CORS_ORIGINS?.split(',') || [
'http://localhost:3000',
'http://localhost:8081',
],
origin:
env.CORS_ORIGINS?.split(',').map((o) => o.trim()) ||
(isDevelopment()
? [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:8081',
]
: []),
credentials: true,
},
rateLimit: {
ttl: parseInt(process.env.RATE_LIMIT_TTL || '60', 10),
limit: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
ttl: parseInt(env.RATE_LIMIT_TTL || '60', 10),
limit: parseInt(env.RATE_LIMIT_MAX || '100', 10),
},
credits: {
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
signupBonus: parseInt(env.CREDITS_SIGNUP_BONUS || '150', 10),
dailyFreeCredits: parseInt(env.CREDITS_DAILY_FREE || '5', 10),
},
ai: {
geminiApiKey: process.env.GOOGLE_GENAI_API_KEY || '',
geminiApiKey: env.GOOGLE_GENAI_API_KEY || '',
},
baseUrl: env.BASE_URL || (isDevelopment() ? 'http://localhost:3001' : ''),
});

View file

@ -0,0 +1,145 @@
/**
* Environment Variable Validation
*
* Validates all required environment variables at startup.
* Fails fast with clear error messages if configuration is invalid.
*/
import { z } from 'zod';
// Schema for environment variables
const envSchema = z.object({
// Node environment
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().regex(/^\d+$/).default('3001'),
// Database - REQUIRED in production
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
// Redis - optional in development, recommended in production
REDIS_HOST: z.string().optional(),
REDIS_PORT: z.string().regex(/^\d+$/).optional(),
REDIS_PASSWORD: z.string().optional(),
// JWT - Better Auth uses JWKS, so these are optional legacy config
JWT_PUBLIC_KEY: z.string().optional(),
JWT_PRIVATE_KEY: z.string().optional(),
JWT_ISSUER: z.string().default('manacore'),
JWT_AUDIENCE: z.string().default('manacore'),
JWT_ACCESS_TOKEN_EXPIRY: z.string().default('15m'),
JWT_REFRESH_TOKEN_EXPIRY: z.string().default('7d'),
// CORS - REQUIRED in production
CORS_ORIGINS: z.string().optional(),
// Stripe - optional, but credit system won't work without it
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
// SMTP - optional, emails will be logged if not configured
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_FROM: z.string().optional(),
// Rate limiting
RATE_LIMIT_TTL: z.string().regex(/^\d+$/).optional(),
RATE_LIMIT_MAX: z.string().regex(/^\d+$/).optional(),
// Credits
CREDITS_SIGNUP_BONUS: z.string().regex(/^\d+$/).optional(),
CREDITS_DAILY_FREE: z.string().regex(/^\d+$/).optional(),
// AI
GOOGLE_GENAI_API_KEY: z.string().optional(),
// Base URL for callbacks
BASE_URL: z.string().url().optional(),
// Log level
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).optional(),
});
// Production-specific schema with stricter requirements
const productionEnvSchema = envSchema.extend({
// In production, these are mandatory
CORS_ORIGINS: z.string().min(1, 'CORS_ORIGINS is required in production'),
BASE_URL: z.string().url('BASE_URL must be a valid URL in production'),
});
export type EnvConfig = z.infer<typeof envSchema>;
/**
* Validate environment variables
*
* @throws Error with detailed message if validation fails
*/
export function validateEnv(): EnvConfig {
const isProduction = process.env.NODE_ENV === 'production';
const schema = isProduction ? productionEnvSchema : envSchema;
const result = schema.safeParse(process.env);
if (!result.success) {
const errors = result.error.errors
.map((err) => ` - ${err.path.join('.')}: ${err.message}`)
.join('\n');
const message = `
ENVIRONMENT CONFIGURATION ERROR
The following environment variables are missing or invalid:
${errors}
${isProduction ? 'Production mode requires stricter configuration.' : ''}
Please check your .env file or environment variables.
For development, copy .env.example to .env and fill in the values.
`;
console.error(message);
throw new Error(`Environment validation failed: ${result.error.message}`);
}
// Additional production warnings (non-fatal)
if (isProduction) {
const warnings: string[] = [];
if (!result.data.STRIPE_SECRET_KEY) {
warnings.push('STRIPE_SECRET_KEY not set - credit system will not work');
}
if (!result.data.SMTP_HOST) {
warnings.push('SMTP not configured - emails will only be logged');
}
if (!result.data.REDIS_HOST) {
warnings.push('REDIS_HOST not set - using in-memory session storage (not recommended)');
}
if (warnings.length > 0) {
console.warn('\n⚠ Production Warnings:');
warnings.forEach((w) => console.warn(` - ${w}`));
console.warn('');
}
}
return result.data;
}
/**
* Check if running in development mode
*/
export function isDevelopment(): boolean {
return process.env.NODE_ENV !== 'production';
}
/**
* Check if running in production mode
*/
export function isProduction(): boolean {
return process.env.NODE_ENV === 'production';
}

View file

@ -8,6 +8,9 @@
*/
import * as nodemailer from 'nodemailer';
import { getLogger } from '../common/logger';
const logger = getLogger('EmailService');
interface EmailOptions {
to: string;
@ -30,7 +33,7 @@ function getTransporter(): nodemailer.Transporter {
const pass = process.env.SMTP_PASSWORD;
if (!user || !pass) {
console.warn('[Email] SMTP credentials not configured, emails will be logged only');
logger.warn('SMTP credentials not configured, emails will be logged only');
return null as any;
}
@ -54,15 +57,12 @@ export async function sendEmail(options: EmailOptions): Promise<boolean> {
const { to, subject, html, text } = options;
const from = process.env.SMTP_FROM || 'ManaCore <noreply@mana.how>';
console.log(`[Email] Sending to: ${to}, subject: ${subject}`);
logger.info('Sending email', { to, subject });
const transport = getTransporter();
if (!transport) {
console.log('[Email] No SMTP configured, logging email content:');
console.log(` To: ${to}`);
console.log(` Subject: ${subject}`);
console.log(` HTML: ${html.substring(0, 200)}...`);
logger.debug('No SMTP configured, email not sent', { to, subject });
return false;
}
@ -75,10 +75,10 @@ export async function sendEmail(options: EmailOptions): Promise<boolean> {
text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text version
});
console.log(`[Email] Sent successfully, messageId: ${result.messageId}`);
logger.info('Email sent successfully', { to, messageId: result.messageId });
return true;
} catch (error) {
console.error('[Email] Failed to send:', error);
logger.error('Failed to send email', error instanceof Error ? error.stack : undefined, { to });
return false;
}
}

View file

@ -1,12 +1,181 @@
import { Controller, Get } from '@nestjs/common';
/**
* Health Check Controller
*
* Provides health check endpoints for Kubernetes/Docker:
* - /health - Basic health check (always returns ok if server is running)
* - /health/live - Liveness probe (is the process running?)
* - /health/ready - Readiness probe (is the service ready to accept traffic?)
*
* Readiness checks database connectivity to ensure the service
* can actually handle requests before receiving traffic.
*/
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { sql } from 'drizzle-orm';
import { getDb } from '../db/connection';
interface HealthStatus {
status: 'ok' | 'error';
timestamp: string;
uptime: number;
checks?: {
database?: { status: 'ok' | 'error'; latency?: number; error?: string };
redis?: { status: 'ok' | 'error' | 'not_configured'; latency?: number; error?: string };
};
}
@Controller('health')
export class HealthController {
private readonly startTime = Date.now();
constructor(private configService: ConfigService) {}
/**
* Basic health check
* Returns ok if the server is running
*/
@Get()
check() {
check(): HealthStatus {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - this.startTime) / 1000),
};
}
/**
* Liveness probe
* Used by Kubernetes to determine if the process should be restarted
* Only checks if the process is alive, not if dependencies are healthy
*/
@Get('live')
live(): { status: 'ok' } {
return { status: 'ok' };
}
/**
* Readiness probe
* Used by Kubernetes to determine if the service should receive traffic
* Checks database connectivity before marking as ready
*/
@Get('ready')
async ready(): Promise<HealthStatus> {
const checks: HealthStatus['checks'] = {};
let allHealthy = true;
// Check database
const dbCheck = await this.checkDatabase();
checks.database = dbCheck;
if (dbCheck.status === 'error') {
allHealthy = false;
}
// Check Redis (optional - don't fail if not configured)
const redisCheck = await this.checkRedis();
checks.redis = redisCheck;
// Don't fail readiness if Redis is just not configured
if (redisCheck.status === 'error') {
allHealthy = false;
}
const status: HealthStatus = {
status: allHealthy ? 'ok' : 'error',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - this.startTime) / 1000),
checks,
};
if (!allHealthy) {
throw new ServiceUnavailableException(status);
}
return status;
}
/**
* Check database connectivity
*/
private async checkDatabase(): Promise<{
status: 'ok' | 'error';
latency?: number;
error?: string;
}> {
const start = Date.now();
try {
const databaseUrl = this.configService.get<string>('database.url');
if (!databaseUrl) {
return { status: 'error', error: 'DATABASE_URL not configured' };
}
const db = getDb(databaseUrl);
await db.execute(sql`SELECT 1`);
return {
status: 'ok',
latency: Date.now() - start,
};
} catch (error) {
return {
status: 'error',
latency: Date.now() - start,
error: error instanceof Error ? error.message : 'Unknown database error',
};
}
}
/**
* Check Redis connectivity (optional)
*/
private async checkRedis(): Promise<{
status: 'ok' | 'error' | 'not_configured';
latency?: number;
error?: string;
}> {
const redisHost = this.configService.get<string>('redis.host');
// Redis is optional - if not configured, that's fine
if (!redisHost) {
return { status: 'not_configured' };
}
const start = Date.now();
try {
// Simple TCP connection check to Redis
const net = await import('net');
const redisPort = this.configService.get<number>('redis.port') || 6379;
await new Promise<void>((resolve, reject) => {
const socket = new net.Socket();
const timeout = setTimeout(() => {
socket.destroy();
reject(new Error('Connection timeout'));
}, 2000);
socket.connect(redisPort, redisHost, () => {
clearTimeout(timeout);
socket.destroy();
resolve();
});
socket.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
return {
status: 'ok',
latency: Date.now() - start,
};
} catch (error) {
return {
status: 'error',
latency: Date.now() - start,
error: error instanceof Error ? error.message : 'Unknown Redis error',
};
}
}
}

View file

@ -7,6 +7,9 @@ import cookieParser from 'cookie-parser';
import * as bodyParser from 'body-parser';
import { AppModule } from './app.module';
import { MetricsService } from './metrics/metrics.service';
import { getLogger } from './common/logger';
const logger = getLogger('Bootstrap');
// Normalize route paths to prevent high cardinality
function normalizeRoute(path: string): string {
@ -76,7 +79,7 @@ async function bootstrap() {
// CORS configuration
const corsOrigins = configService.get<string[]>('cors.origin') || [];
console.log('📋 CORS Origins configured:', corsOrigins);
logger.info('CORS Origins configured', { origins: corsOrigins });
app.enableCors({
origin: corsOrigins,
credentials: true,
@ -128,8 +131,10 @@ async function bootstrap() {
const port = configService.get<number>('port') || 3001;
await app.listen(port);
console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`);
console.log(`📚 Environment: ${configService.get<string>('nodeEnv')}`);
logger.info(`Mana Core Auth running on http://localhost:${port}`, {
port,
environment: configService.get<string>('nodeEnv'),
});
}
bootstrap();