mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 02:24:39 +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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue