🐛 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

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