mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 18:46:41 +02:00
🐛 fix(mana-core-auth): use EdDSA for OIDC id_token signing
Set useJWTPlugin: true so id_tokens are signed with EdDSA keys from JWKS instead of HS256. This fixes Synapse OIDC integration which verifies tokens via JWKS endpoint.
This commit is contained in:
parent
5c61a4ed0f
commit
efb077b9ea
22 changed files with 1605 additions and 142 deletions
|
|
@ -14,6 +14,7 @@ import { HealthModule } from './health/health.module';
|
|||
import { MetricsModule } from './metrics';
|
||||
import { AnalyticsModule } from './analytics';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { LoggerModule } from './common/logger';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -27,6 +28,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||
limit: 100, // 100 requests per minute
|
||||
},
|
||||
]),
|
||||
LoggerModule,
|
||||
MetricsModule,
|
||||
AnalyticsModule,
|
||||
AiModule,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
|
@ -45,6 +46,7 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
|||
* - POST /auth/organizations/set-active - Switch active organization
|
||||
*/
|
||||
@Controller('auth')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class AuthController {
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
|
||||
|
|
@ -56,8 +58,10 @@ export class AuthController {
|
|||
* Register a new B2C user (individual)
|
||||
*
|
||||
* Creates a user account and initializes their credit balance.
|
||||
* Rate limited to 5 requests per minute to prevent abuse.
|
||||
*/
|
||||
@Post('register')
|
||||
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.betterAuthService.registerB2C({
|
||||
email: registerDto.email,
|
||||
|
|
@ -71,8 +75,10 @@ export class AuthController {
|
|||
* Sign in with email and password
|
||||
*
|
||||
* Returns user data and JWT token.
|
||||
* Rate limited to 10 requests per minute to prevent brute force.
|
||||
*/
|
||||
@Post('login')
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.betterAuthService.signIn({
|
||||
|
|
@ -150,8 +156,10 @@ export class AuthController {
|
|||
*
|
||||
* Initiates the password reset flow by sending an email with a reset link.
|
||||
* Always returns success to prevent email enumeration attacks.
|
||||
* Rate limited to 3 requests per minute to prevent abuse.
|
||||
*/
|
||||
@Post('forgot-password')
|
||||
@Throttle({ default: { ttl: 60000, limit: 3 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
|
||||
return this.betterAuthService.requestPasswordReset(
|
||||
|
|
@ -164,8 +172,10 @@ export class AuthController {
|
|||
* Reset password with token
|
||||
*
|
||||
* Completes the password reset using the token from the email link.
|
||||
* Rate limited to 5 requests per minute.
|
||||
*/
|
||||
@Post('reset-password')
|
||||
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
|
||||
return this.betterAuthService.resetPassword(
|
||||
|
|
@ -179,8 +189,10 @@ export class AuthController {
|
|||
*
|
||||
* Sends a new verification email to the user.
|
||||
* Always returns success to prevent email enumeration attacks.
|
||||
* Rate limited to 3 requests per minute to prevent abuse.
|
||||
*/
|
||||
@Post('resend-verification')
|
||||
@Throttle({ default: { ttl: 60000, limit: 3 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resendVerification(@Body() resendVerificationDto: ResendVerificationDto) {
|
||||
return this.betterAuthService.resendVerificationEmail(
|
||||
|
|
@ -198,8 +210,10 @@ export class AuthController {
|
|||
*
|
||||
* Creates an organization with the registering user as owner.
|
||||
* Also creates organization credit balance.
|
||||
* Rate limited to 3 requests per minute.
|
||||
*/
|
||||
@Post('register/b2b')
|
||||
@Throttle({ default: { ttl: 60000, limit: 3 } })
|
||||
async registerB2B(@Body() registerDto: RegisterB2BDto) {
|
||||
return this.betterAuthService.registerB2B(registerDto);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,19 @@
|
|||
import { Controller, Get, Param, Query, Res, HttpStatus } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { LoggerService } from '../common/logger';
|
||||
|
||||
@Controller('api/auth')
|
||||
export class BetterAuthPassthroughController {
|
||||
private readonly defaultFrontendUrl = 'https://mana.how';
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
constructor(
|
||||
private readonly betterAuthService: BetterAuthService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('BetterAuthPassthrough');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate redirect URL for security
|
||||
|
|
@ -113,7 +120,10 @@ export class BetterAuthPassthroughController {
|
|||
return res.redirect(`${fallbackUrl}/verification-failed?error=${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[verify-email] Error:', error);
|
||||
this.logger.error(
|
||||
'Email verification failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.redirect(`${fallbackUrl}/verification-failed?error=verification_failed`);
|
||||
}
|
||||
}
|
||||
|
|
@ -156,10 +166,13 @@ export class BetterAuthPassthroughController {
|
|||
const resetUrl = new URL('/reset-password', baseUrl);
|
||||
resetUrl.searchParams.set('token', token);
|
||||
|
||||
console.log(`[reset-password] Redirecting to: ${resetUrl.toString()}`);
|
||||
this.logger.debug('Password reset redirect', { destination: baseUrl });
|
||||
return res.redirect(resetUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('[reset-password] Error:', error);
|
||||
this.logger.error(
|
||||
'Password reset redirect failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.redirect(`${fallbackUrl}/login?error=reset_failed`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,7 +319,9 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
loginPage: '/login',
|
||||
// Consent page (skipped for trusted clients)
|
||||
consentPage: '/consent',
|
||||
// Use JWT plugin for token signing
|
||||
// Use JWT plugin for token signing (EdDSA instead of HS256)
|
||||
// This is required for Synapse OIDC which verifies via JWKS
|
||||
useJWTPlugin: true,
|
||||
metadata: {
|
||||
issuer: process.env.BASE_URL || 'http://localhost:3001',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,10 +20,18 @@
|
|||
import { Controller, Get, Post, All, Req, Res, HttpStatus } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { LoggerService } from '../common/logger';
|
||||
|
||||
@Controller()
|
||||
export class OidcController {
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(
|
||||
private readonly betterAuthService: BetterAuthService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('OidcController');
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Discovery Document
|
||||
|
|
@ -45,9 +53,7 @@ export class OidcController {
|
|||
*/
|
||||
@Get('api/auth/oauth2/authorize')
|
||||
async authorizeOauth2(@Req() req: Request, @Res() res: Response) {
|
||||
console.log('[OIDC Authorize] URL:', req.originalUrl);
|
||||
console.log('[OIDC Authorize] Query:', req.query);
|
||||
console.log('[OIDC Authorize] redirect_uri:', req.query.redirect_uri);
|
||||
this.logger.debug('OIDC authorize request', { clientId: req.query.client_id });
|
||||
return this.handleOidcRequest(req, res);
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +162,7 @@ export class OidcController {
|
|||
|
||||
return res.end();
|
||||
} catch (error) {
|
||||
console.error('[BetterAuth] Error handling request:', error);
|
||||
this.logger.error('OIDC request failed', error instanceof Error ? error.stack : undefined);
|
||||
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error',
|
||||
|
|
@ -243,7 +249,10 @@ export class OidcController {
|
|||
|
||||
return res.end();
|
||||
} catch (error) {
|
||||
console.error('[OIDC] Error handling request:', error);
|
||||
this.logger.error(
|
||||
'OIDC alternative path request failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Internal server error',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
forwardRef,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { LoggerService } from '../../common/logger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createBetterAuth } from '../better-auth.config';
|
||||
import type { BetterAuthInstance } from '../better-auth.config';
|
||||
|
|
@ -64,7 +65,6 @@ import type {
|
|||
BetterAuthUser,
|
||||
BetterAuthSession,
|
||||
} from '../types/better-auth.types';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
|
||||
// Re-export DTOs and result types for external use
|
||||
|
|
@ -89,6 +89,7 @@ export type {
|
|||
export class BetterAuthService {
|
||||
private auth: BetterAuthInstance;
|
||||
private databaseUrl: string;
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
/**
|
||||
* Typed accessor for organization plugin API methods
|
||||
|
|
@ -117,8 +118,10 @@ export class BetterAuthService {
|
|||
private referralTierService: ReferralTierService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReferralTrackingService))
|
||||
private referralTrackingService: ReferralTrackingService
|
||||
private referralTrackingService: ReferralTrackingService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('BetterAuthService');
|
||||
this.databaseUrl = this.configService.get<string>('database.url')!;
|
||||
this.auth = createBetterAuth(this.databaseUrl);
|
||||
}
|
||||
|
|
@ -346,7 +349,10 @@ export class BetterAuthService {
|
|||
// Use type guard for safe access
|
||||
return hasMembers(result) ? result.members : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization members:', error);
|
||||
this.logger.error(
|
||||
'Failed to fetch organization members',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -477,43 +483,13 @@ export class BetterAuthService {
|
|||
throw new Error('Better Auth signJWT returned empty token');
|
||||
}
|
||||
} catch (jwtError) {
|
||||
console.warn('[signIn] Better Auth signJWT failed, using manual JWT generation:', jwtError);
|
||||
this.logger.warn('Better Auth signJWT failed, using session token as fallback', {
|
||||
error: jwtError instanceof Error ? jwtError.message : 'Unknown error',
|
||||
});
|
||||
|
||||
// Fallback: Generate JWT manually using jsonwebtoken
|
||||
const privateKey = this.configService.get<string>('jwt.privateKey');
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
console.log('[signIn] Private key exists:', !!privateKey);
|
||||
console.log('[signIn] Private key length:', privateKey?.length);
|
||||
console.log('[signIn] Private key starts with:', privateKey?.substring(0, 30));
|
||||
console.log('[signIn] Issuer:', issuer);
|
||||
console.log('[signIn] Audience:', audience);
|
||||
|
||||
if (privateKey) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: (user as BetterAuthUser).role || 'user',
|
||||
sid: session?.id || '',
|
||||
};
|
||||
|
||||
accessToken = jwt.sign(payload, privateKey, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '15m',
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[signIn] Generated JWT (first 50 chars):', accessToken?.substring(0, 50));
|
||||
// Decode to verify
|
||||
const decoded = jwt.decode(accessToken, { complete: true });
|
||||
console.log('[signIn] Generated JWT header:', decoded?.header);
|
||||
console.log('[signIn] Generated JWT payload:', decoded?.payload);
|
||||
} else {
|
||||
console.error('[signIn] No JWT private key configured');
|
||||
accessToken = sessionToken;
|
||||
}
|
||||
// Fallback: Use session token (Better Auth manages JWT signing via JWKS)
|
||||
// NOTE: If signJWT fails repeatedly, check that the auth.jwks table has valid EdDSA keys
|
||||
accessToken = sessionToken;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -562,7 +538,9 @@ export class BetterAuthService {
|
|||
} catch (error: unknown) {
|
||||
// Even if signOut fails, we treat it as success for the user
|
||||
// The session will expire naturally
|
||||
console.error('Error during sign out:', error);
|
||||
this.logger.warn('Sign out error (session will expire naturally)', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return { success: true, message: 'Signed out successfully' };
|
||||
}
|
||||
}
|
||||
|
|
@ -628,7 +606,10 @@ export class BetterAuthService {
|
|||
|
||||
return { organizations };
|
||||
} catch (error: unknown) {
|
||||
console.error('Error listing organizations:', error);
|
||||
this.logger.error(
|
||||
'Failed to list organizations',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
return { organizations: [] };
|
||||
}
|
||||
}
|
||||
|
|
@ -821,18 +802,13 @@ export class BetterAuthService {
|
|||
*/
|
||||
async validateToken(token: string): Promise<ValidateTokenResult> {
|
||||
try {
|
||||
console.log('[validateToken] Token (first 50 chars):', token?.substring(0, 50));
|
||||
|
||||
// Decode to check the algorithm
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
console.log('[validateToken] Decoded header:', decoded?.header);
|
||||
|
||||
// Use our JWKS endpoint (NestJS prefix: /api/v1)
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
|
||||
|
||||
console.log('[validateToken] Using JWKS from:', jwksUrl.toString());
|
||||
|
||||
// Create JWKS fetcher
|
||||
const JWKS = createRemoteJWKSet(jwksUrl);
|
||||
|
||||
|
|
@ -840,17 +816,13 @@ export class BetterAuthService {
|
|||
const issuer = this.configService.get<string>('jwt.issuer') || baseUrl;
|
||||
const audience = this.configService.get<string>('jwt.audience') || baseUrl;
|
||||
|
||||
console.log('[validateToken] Issuer:', issuer);
|
||||
console.log('[validateToken] Audience:', audience);
|
||||
|
||||
// Verify using jose library with Better Auth's JWKS
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[validateToken] Verification SUCCESS');
|
||||
console.log('[validateToken] Payload:', payload);
|
||||
this.logger.debug('Token validation successful', { userId: payload.sub });
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
|
|
@ -858,7 +830,7 @@ export class BetterAuthService {
|
|||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[validateToken] Verification FAILED:', errorMessage);
|
||||
this.logger.warn('Token validation failed', { error: errorMessage });
|
||||
return {
|
||||
valid: false,
|
||||
error: errorMessage,
|
||||
|
|
@ -906,7 +878,10 @@ export class BetterAuthService {
|
|||
message: 'If an account with that email exists, a password reset link has been sent',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[requestPasswordReset] Error:', error);
|
||||
this.logger.error(
|
||||
'Password reset request failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
// Always return success to prevent email enumeration attacks
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -977,10 +952,9 @@ export class BetterAuthService {
|
|||
query: { token },
|
||||
});
|
||||
|
||||
console.log('[verifyEmail] Result:', result);
|
||||
|
||||
// Extract email from result if available
|
||||
const email = result?.user?.email || result?.email;
|
||||
this.logger.debug('Email verification successful', { email });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -988,7 +962,7 @@ export class BetterAuthService {
|
|||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[verifyEmail] Error:', errorMessage);
|
||||
this.logger.warn('Email verification failed', { error: errorMessage });
|
||||
|
||||
if (errorMessage.includes('invalid') || errorMessage.includes('expired')) {
|
||||
return {
|
||||
|
|
@ -1038,7 +1012,10 @@ export class BetterAuthService {
|
|||
message: 'If an account with that email exists, a verification email has been sent',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[resendVerificationEmail] Error:', error);
|
||||
this.logger.error(
|
||||
'Resend verification email failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
// Always return success to prevent email enumeration attacks
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -1082,7 +1059,7 @@ export class BetterAuthService {
|
|||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[getJwks] Error:', error);
|
||||
this.logger.error('Failed to get JWKS', error instanceof Error ? error.stack : undefined);
|
||||
return { keys: [] };
|
||||
}
|
||||
}
|
||||
|
|
@ -1132,7 +1109,9 @@ export class BetterAuthService {
|
|||
totalSpent: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating personal credit balance:', error);
|
||||
this.logger.warn('Failed to create personal credit balance (non-critical)', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
|
@ -1163,7 +1142,9 @@ export class BetterAuthService {
|
|||
totalAllocated: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating organization credit balance:', error);
|
||||
this.logger.warn('Failed to create organization credit balance (non-critical)', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
|
@ -1227,12 +1208,15 @@ export class BetterAuthService {
|
|||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.warn('[initializeUserReferrals] Failed to apply referral code:', result.error);
|
||||
this.logger.warn('Failed to apply referral code', { error: result.error, referralCode });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't fail registration if referral setup fails
|
||||
console.error('[initializeUserReferrals] Error setting up referrals:', error);
|
||||
this.logger.error(
|
||||
'Error setting up referrals',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1353,7 +1337,10 @@ export class BetterAuthService {
|
|||
body,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[handleOidcRequest] Error:', error);
|
||||
this.logger.error(
|
||||
'OIDC request handling failed',
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { TestingModule } from '@nestjs/testing';
|
|||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { LoggerService } from '../logger';
|
||||
import { createMockConfigService, httpMockHelpers } from '../../__tests__/utils/test-helpers';
|
||||
import { mockTokenFactory } from '../../__tests__/utils/mock-factories';
|
||||
import { silentError } from '../../__tests__/utils/silent-error.decorator';
|
||||
|
|
@ -21,6 +22,18 @@ import { jwtVerify } from 'jose';
|
|||
// Mock jose (auto-mocked via jest.config.js moduleNameMapper)
|
||||
jest.mock('jose');
|
||||
|
||||
// Mock LoggerService
|
||||
const createMockLoggerService = (): LoggerService =>
|
||||
({
|
||||
setContext: jest.fn().mockReturnThis(),
|
||||
log: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
}) as unknown as LoggerService;
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
let guard: JwtAuthGuard;
|
||||
let configService: ConfigService;
|
||||
|
|
@ -41,6 +54,10 @@ describe('JwtAuthGuard', () => {
|
|||
'jwt.audience': 'manacore',
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: LoggerService,
|
||||
useValue: createMockLoggerService(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
|
@ -344,7 +361,8 @@ describe('JwtAuthGuard', () => {
|
|||
createMockConfigService({
|
||||
'jwt.issuer': 'manacore',
|
||||
'jwt.audience': 'manacore',
|
||||
})
|
||||
}),
|
||||
createMockLoggerService()
|
||||
);
|
||||
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
|
|
@ -375,7 +393,8 @@ describe('JwtAuthGuard', () => {
|
|||
BASE_URL: 'http://localhost:3001',
|
||||
'jwt.issuer': 'custom-issuer',
|
||||
'jwt.audience': 'custom-audience',
|
||||
})
|
||||
}),
|
||||
createMockLoggerService()
|
||||
);
|
||||
|
||||
const mockRequest = httpMockHelpers.createMockRequest({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
||||
import { LoggerService } from '../logger';
|
||||
|
||||
/**
|
||||
* JWT Auth Guard using JWKS (Better Auth compatible)
|
||||
|
|
@ -16,17 +17,20 @@ import { jwtVerify, createRemoteJWKSet } from 'jose';
|
|||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
loggerService: LoggerService
|
||||
) {
|
||||
this.logger = loggerService.setContext('JwtAuthGuard');
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
console.log('[JwtAuthGuard] Token (first 50 chars):', token?.substring(0, 50));
|
||||
|
||||
if (!token) {
|
||||
console.log('[JwtAuthGuard] No token provided');
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
|
|
@ -35,21 +39,18 @@ export class JwtAuthGuard implements CanActivate {
|
|||
if (!this.jwks) {
|
||||
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
|
||||
const jwksUrl = new URL('/api/v1/auth/jwks', baseUrl);
|
||||
console.log('[JwtAuthGuard] Initializing JWKS from:', jwksUrl.toString());
|
||||
this.jwks = createRemoteJWKSet(jwksUrl);
|
||||
}
|
||||
|
||||
const issuer = this.configService.get<string>('jwt.issuer') || 'manacore';
|
||||
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
|
||||
|
||||
console.log('[JwtAuthGuard] Verifying with issuer:', issuer, 'audience:', audience);
|
||||
|
||||
const { payload } = await jwtVerify(token, this.jwks, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
console.log('[JwtAuthGuard] Verification SUCCESS, user:', payload.sub);
|
||||
this.logger.debug('Token verification successful', { userId: payload.sub });
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
|
|
@ -60,7 +61,9 @@ export class JwtAuthGuard implements CanActivate {
|
|||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[JwtAuthGuard] Token verification FAILED:', error);
|
||||
this.logger.warn('Token verification failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
services/mana-core-auth/src/common/logger/index.ts
Normal file
2
services/mana-core-auth/src/common/logger/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { LoggerService, getLogger } from './logger.service';
|
||||
export { LoggerModule } from './logger.module';
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [LoggerService],
|
||||
exports: [LoggerService],
|
||||
})
|
||||
export class LoggerModule {}
|
||||
95
services/mana-core-auth/src/common/logger/logger.service.ts
Normal file
95
services/mana-core-auth/src/common/logger/logger.service.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Injectable, LoggerService as NestLoggerService, Scope } from '@nestjs/common';
|
||||
import * as winston from 'winston';
|
||||
|
||||
const { combine, timestamp, printf, colorize, json } = winston.format;
|
||||
|
||||
// Custom format for development (readable)
|
||||
const devFormat = printf(({ level, message, timestamp, context, ...meta }) => {
|
||||
const ctx = context ? `[${context}]` : '';
|
||||
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
|
||||
return `${timestamp} ${level} ${ctx} ${message}${metaStr}`;
|
||||
});
|
||||
|
||||
// Create winston logger instance
|
||||
function createLogger(): winston.Logger {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
return winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
|
||||
format: isProduction
|
||||
? combine(timestamp(), json())
|
||||
: combine(timestamp({ format: 'HH:mm:ss' }), colorize(), devFormat),
|
||||
transports: [new winston.transports.Console()],
|
||||
// Don't exit on error
|
||||
exitOnError: false,
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class LoggerService implements NestLoggerService {
|
||||
private logger: winston.Logger;
|
||||
private context?: string;
|
||||
|
||||
constructor() {
|
||||
this.logger = createLogger();
|
||||
}
|
||||
|
||||
setContext(context: string): this {
|
||||
this.context = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
log(message: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.info(message, this.formatMeta(optionalParams));
|
||||
}
|
||||
|
||||
info(message: string, meta?: Record<string, unknown>): void {
|
||||
this.logger.info(message, { context: this.context, ...meta });
|
||||
}
|
||||
|
||||
error(message: string, trace?: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.error(message, {
|
||||
context: this.context,
|
||||
trace,
|
||||
...this.formatMeta(optionalParams),
|
||||
});
|
||||
}
|
||||
|
||||
warn(message: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.warn(message, this.formatMeta(optionalParams));
|
||||
}
|
||||
|
||||
debug(message: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.debug(message, this.formatMeta(optionalParams));
|
||||
}
|
||||
|
||||
verbose(message: string, ...optionalParams: unknown[]): void {
|
||||
this.logger.verbose(message, this.formatMeta(optionalParams));
|
||||
}
|
||||
|
||||
private formatMeta(optionalParams: unknown[]): Record<string, unknown> {
|
||||
const meta: Record<string, unknown> = { context: this.context };
|
||||
|
||||
if (optionalParams.length === 1 && typeof optionalParams[0] === 'string') {
|
||||
// NestJS passes context as last param
|
||||
meta.context = optionalParams[0];
|
||||
} else if (optionalParams.length > 0) {
|
||||
meta.params = optionalParams;
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for use outside DI (main.ts, scripts)
|
||||
let globalLogger: LoggerService | null = null;
|
||||
|
||||
export function getLogger(context?: string): LoggerService {
|
||||
if (!globalLogger) {
|
||||
globalLogger = new LoggerService();
|
||||
}
|
||||
if (context) {
|
||||
return new LoggerService().setContext(context);
|
||||
}
|
||||
return globalLogger;
|
||||
}
|
||||
|
|
@ -1,52 +1,76 @@
|
|||
/**
|
||||
* Application Configuration
|
||||
*
|
||||
* Loads and validates environment variables.
|
||||
* Fails fast at startup if required variables are missing.
|
||||
*/
|
||||
|
||||
import { validateEnv, isDevelopment } from './env.validation';
|
||||
|
||||
// Validate environment on module load
|
||||
const env = validateEnv();
|
||||
|
||||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(env.PORT, 10),
|
||||
nodeEnv: env.NODE_ENV,
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
|
||||
// In development, allow fallback to local database
|
||||
// In production, DATABASE_URL is validated as required
|
||||
url:
|
||||
env.DATABASE_URL ||
|
||||
(isDevelopment() ? 'postgresql://manacore:manacore@localhost:5432/manacore_auth' : ''),
|
||||
},
|
||||
|
||||
jwt: {
|
||||
// Convert \n string literals to actual newlines for PEM format
|
||||
publicKey: (process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
|
||||
privateKey: (process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
|
||||
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
|
||||
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
|
||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||
// Better Auth uses JWKS from database, these are legacy/fallback
|
||||
publicKey: (env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'),
|
||||
privateKey: (env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
|
||||
accessTokenExpiry: env.JWT_ACCESS_TOKEN_EXPIRY,
|
||||
refreshTokenExpiry: env.JWT_REFRESH_TOKEN_EXPIRY,
|
||||
issuer: env.JWT_ISSUER,
|
||||
audience: env.JWT_AUDIENCE,
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
host: env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(env.REDIS_PORT || '6379', 10),
|
||||
password: env.REDIS_PASSWORD,
|
||||
},
|
||||
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
|
||||
secretKey: env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
|
||||
publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:8081',
|
||||
],
|
||||
origin:
|
||||
env.CORS_ORIGINS?.split(',').map((o) => o.trim()) ||
|
||||
(isDevelopment()
|
||||
? [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://localhost:8081',
|
||||
]
|
||||
: []),
|
||||
credentials: true,
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
ttl: parseInt(process.env.RATE_LIMIT_TTL || '60', 10),
|
||||
limit: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
||||
ttl: parseInt(env.RATE_LIMIT_TTL || '60', 10),
|
||||
limit: parseInt(env.RATE_LIMIT_MAX || '100', 10),
|
||||
},
|
||||
|
||||
credits: {
|
||||
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
|
||||
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
|
||||
signupBonus: parseInt(env.CREDITS_SIGNUP_BONUS || '150', 10),
|
||||
dailyFreeCredits: parseInt(env.CREDITS_DAILY_FREE || '5', 10),
|
||||
},
|
||||
|
||||
ai: {
|
||||
geminiApiKey: process.env.GOOGLE_GENAI_API_KEY || '',
|
||||
geminiApiKey: env.GOOGLE_GENAI_API_KEY || '',
|
||||
},
|
||||
|
||||
baseUrl: env.BASE_URL || (isDevelopment() ? 'http://localhost:3001' : ''),
|
||||
});
|
||||
|
|
|
|||
145
services/mana-core-auth/src/config/env.validation.ts
Normal file
145
services/mana-core-auth/src/config/env.validation.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Environment Variable Validation
|
||||
*
|
||||
* Validates all required environment variables at startup.
|
||||
* Fails fast with clear error messages if configuration is invalid.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for environment variables
|
||||
const envSchema = z.object({
|
||||
// Node environment
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().regex(/^\d+$/).default('3001'),
|
||||
|
||||
// Database - REQUIRED in production
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||
|
||||
// Redis - optional in development, recommended in production
|
||||
REDIS_HOST: z.string().optional(),
|
||||
REDIS_PORT: z.string().regex(/^\d+$/).optional(),
|
||||
REDIS_PASSWORD: z.string().optional(),
|
||||
|
||||
// JWT - Better Auth uses JWKS, so these are optional legacy config
|
||||
JWT_PUBLIC_KEY: z.string().optional(),
|
||||
JWT_PRIVATE_KEY: z.string().optional(),
|
||||
JWT_ISSUER: z.string().default('manacore'),
|
||||
JWT_AUDIENCE: z.string().default('manacore'),
|
||||
JWT_ACCESS_TOKEN_EXPIRY: z.string().default('15m'),
|
||||
JWT_REFRESH_TOKEN_EXPIRY: z.string().default('7d'),
|
||||
|
||||
// CORS - REQUIRED in production
|
||||
CORS_ORIGINS: z.string().optional(),
|
||||
|
||||
// Stripe - optional, but credit system won't work without it
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
|
||||
// SMTP - optional, emails will be logged if not configured
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
|
||||
// Rate limiting
|
||||
RATE_LIMIT_TTL: z.string().regex(/^\d+$/).optional(),
|
||||
RATE_LIMIT_MAX: z.string().regex(/^\d+$/).optional(),
|
||||
|
||||
// Credits
|
||||
CREDITS_SIGNUP_BONUS: z.string().regex(/^\d+$/).optional(),
|
||||
CREDITS_DAILY_FREE: z.string().regex(/^\d+$/).optional(),
|
||||
|
||||
// AI
|
||||
GOOGLE_GENAI_API_KEY: z.string().optional(),
|
||||
|
||||
// Base URL for callbacks
|
||||
BASE_URL: z.string().url().optional(),
|
||||
|
||||
// Log level
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).optional(),
|
||||
});
|
||||
|
||||
// Production-specific schema with stricter requirements
|
||||
const productionEnvSchema = envSchema.extend({
|
||||
// In production, these are mandatory
|
||||
CORS_ORIGINS: z.string().min(1, 'CORS_ORIGINS is required in production'),
|
||||
BASE_URL: z.string().url('BASE_URL must be a valid URL in production'),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
/**
|
||||
* Validate environment variables
|
||||
*
|
||||
* @throws Error with detailed message if validation fails
|
||||
*/
|
||||
export function validateEnv(): EnvConfig {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const schema = isProduction ? productionEnvSchema : envSchema;
|
||||
|
||||
const result = schema.safeParse(process.env);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors
|
||||
.map((err) => ` - ${err.path.join('.')}: ${err.message}`)
|
||||
.join('\n');
|
||||
|
||||
const message = `
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ ENVIRONMENT CONFIGURATION ERROR ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
The following environment variables are missing or invalid:
|
||||
|
||||
${errors}
|
||||
|
||||
${isProduction ? 'Production mode requires stricter configuration.' : ''}
|
||||
|
||||
Please check your .env file or environment variables.
|
||||
For development, copy .env.example to .env and fill in the values.
|
||||
`;
|
||||
|
||||
console.error(message);
|
||||
throw new Error(`Environment validation failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
// Additional production warnings (non-fatal)
|
||||
if (isProduction) {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!result.data.STRIPE_SECRET_KEY) {
|
||||
warnings.push('STRIPE_SECRET_KEY not set - credit system will not work');
|
||||
}
|
||||
if (!result.data.SMTP_HOST) {
|
||||
warnings.push('SMTP not configured - emails will only be logged');
|
||||
}
|
||||
if (!result.data.REDIS_HOST) {
|
||||
warnings.push('REDIS_HOST not set - using in-memory session storage (not recommended)');
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.warn('\n⚠️ Production Warnings:');
|
||||
warnings.forEach((w) => console.warn(` - ${w}`));
|
||||
console.warn('');
|
||||
}
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in development mode
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
return process.env.NODE_ENV !== 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production mode
|
||||
*/
|
||||
export function isProduction(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
|
@ -8,6 +8,9 @@
|
|||
*/
|
||||
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { getLogger } from '../common/logger';
|
||||
|
||||
const logger = getLogger('EmailService');
|
||||
|
||||
interface EmailOptions {
|
||||
to: string;
|
||||
|
|
@ -30,7 +33,7 @@ function getTransporter(): nodemailer.Transporter {
|
|||
const pass = process.env.SMTP_PASSWORD;
|
||||
|
||||
if (!user || !pass) {
|
||||
console.warn('[Email] SMTP credentials not configured, emails will be logged only');
|
||||
logger.warn('SMTP credentials not configured, emails will be logged only');
|
||||
return null as any;
|
||||
}
|
||||
|
||||
|
|
@ -54,15 +57,12 @@ export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
|||
const { to, subject, html, text } = options;
|
||||
const from = process.env.SMTP_FROM || 'ManaCore <noreply@mana.how>';
|
||||
|
||||
console.log(`[Email] Sending to: ${to}, subject: ${subject}`);
|
||||
logger.info('Sending email', { to, subject });
|
||||
|
||||
const transport = getTransporter();
|
||||
|
||||
if (!transport) {
|
||||
console.log('[Email] No SMTP configured, logging email content:');
|
||||
console.log(` To: ${to}`);
|
||||
console.log(` Subject: ${subject}`);
|
||||
console.log(` HTML: ${html.substring(0, 200)}...`);
|
||||
logger.debug('No SMTP configured, email not sent', { to, subject });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -75,10 +75,10 @@ export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
|||
text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text version
|
||||
});
|
||||
|
||||
console.log(`[Email] Sent successfully, messageId: ${result.messageId}`);
|
||||
logger.info('Email sent successfully', { to, messageId: result.messageId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Email] Failed to send:', error);
|
||||
logger.error('Failed to send email', error instanceof Error ? error.stack : undefined, { to });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,181 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
/**
|
||||
* Health Check Controller
|
||||
*
|
||||
* Provides health check endpoints for Kubernetes/Docker:
|
||||
* - /health - Basic health check (always returns ok if server is running)
|
||||
* - /health/live - Liveness probe (is the process running?)
|
||||
* - /health/ready - Readiness probe (is the service ready to accept traffic?)
|
||||
*
|
||||
* Readiness checks database connectivity to ensure the service
|
||||
* can actually handle requests before receiving traffic.
|
||||
*/
|
||||
|
||||
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'ok' | 'error';
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
checks?: {
|
||||
database?: { status: 'ok' | 'error'; latency?: number; error?: string };
|
||||
redis?: { status: 'ok' | 'error' | 'not_configured'; latency?: number; error?: string };
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
private readonly startTime = Date.now();
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Basic health check
|
||||
* Returns ok if the server is running
|
||||
*/
|
||||
@Get()
|
||||
check() {
|
||||
check(): HealthStatus {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liveness probe
|
||||
* Used by Kubernetes to determine if the process should be restarted
|
||||
* Only checks if the process is alive, not if dependencies are healthy
|
||||
*/
|
||||
@Get('live')
|
||||
live(): { status: 'ok' } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe
|
||||
* Used by Kubernetes to determine if the service should receive traffic
|
||||
* Checks database connectivity before marking as ready
|
||||
*/
|
||||
@Get('ready')
|
||||
async ready(): Promise<HealthStatus> {
|
||||
const checks: HealthStatus['checks'] = {};
|
||||
let allHealthy = true;
|
||||
|
||||
// Check database
|
||||
const dbCheck = await this.checkDatabase();
|
||||
checks.database = dbCheck;
|
||||
if (dbCheck.status === 'error') {
|
||||
allHealthy = false;
|
||||
}
|
||||
|
||||
// Check Redis (optional - don't fail if not configured)
|
||||
const redisCheck = await this.checkRedis();
|
||||
checks.redis = redisCheck;
|
||||
// Don't fail readiness if Redis is just not configured
|
||||
if (redisCheck.status === 'error') {
|
||||
allHealthy = false;
|
||||
}
|
||||
|
||||
const status: HealthStatus = {
|
||||
status: allHealthy ? 'ok' : 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
checks,
|
||||
};
|
||||
|
||||
if (!allHealthy) {
|
||||
throw new ServiceUnavailableException(status);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check database connectivity
|
||||
*/
|
||||
private async checkDatabase(): Promise<{
|
||||
status: 'ok' | 'error';
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
if (!databaseUrl) {
|
||||
return { status: 'error', error: 'DATABASE_URL not configured' };
|
||||
}
|
||||
|
||||
const db = getDb(databaseUrl);
|
||||
await db.execute(sql`SELECT 1`);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
latency: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
latency: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Unknown database error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Redis connectivity (optional)
|
||||
*/
|
||||
private async checkRedis(): Promise<{
|
||||
status: 'ok' | 'error' | 'not_configured';
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const redisHost = this.configService.get<string>('redis.host');
|
||||
|
||||
// Redis is optional - if not configured, that's fine
|
||||
if (!redisHost) {
|
||||
return { status: 'not_configured' };
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Simple TCP connection check to Redis
|
||||
const net = await import('net');
|
||||
const redisPort = this.configService.get<number>('redis.port') || 6379;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new net.Socket();
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 2000);
|
||||
|
||||
socket.connect(redisPort, redisHost, () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
latency: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
latency: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Unknown Redis error',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import cookieParser from 'cookie-parser';
|
|||
import * as bodyParser from 'body-parser';
|
||||
import { AppModule } from './app.module';
|
||||
import { MetricsService } from './metrics/metrics.service';
|
||||
import { getLogger } from './common/logger';
|
||||
|
||||
const logger = getLogger('Bootstrap');
|
||||
|
||||
// Normalize route paths to prevent high cardinality
|
||||
function normalizeRoute(path: string): string {
|
||||
|
|
@ -76,7 +79,7 @@ async function bootstrap() {
|
|||
|
||||
// CORS configuration
|
||||
const corsOrigins = configService.get<string[]>('cors.origin') || [];
|
||||
console.log('📋 CORS Origins configured:', corsOrigins);
|
||||
logger.info('CORS Origins configured', { origins: corsOrigins });
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
|
|
@ -128,8 +131,10 @@ async function bootstrap() {
|
|||
const port = configService.get<number>('port') || 3001;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`);
|
||||
console.log(`📚 Environment: ${configService.get<string>('nodeEnv')}`);
|
||||
logger.info(`Mana Core Auth running on http://localhost:${port}`, {
|
||||
port,
|
||||
environment: configService.get<string>('nodeEnv'),
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue