diff --git a/src/core/auth/guards/jwt-auth.guard.ts b/src/core/auth/guards/jwt-auth.guard.ts index 7b657bb..0283ee1 100644 --- a/src/core/auth/guards/jwt-auth.guard.ts +++ b/src/core/auth/guards/jwt-auth.guard.ts @@ -3,6 +3,7 @@ import { CanActivate, ExecutionContext, UnauthorizedException, + Logger, } from '@nestjs/common'; import { Request } from 'express'; import { createRemoteJWKSet, jwtVerify } from 'jose'; @@ -16,27 +17,37 @@ import * as jwt from 'jsonwebtoken'; @Injectable() export class JwtAuthGuard implements CanActivate { private jwks: ReturnType | null = null; + private readonly logger = new Logger('JwtAuthGuard'); constructor() {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const authHeader = request.headers.authorization; + let token: string | undefined; - if (!authHeader) { - throw new UnauthorizedException('Authorization header is missing.'); + const authHeader = request.headers.authorization; + if (authHeader) { + const [type, t] = authHeader.split(' '); + if (type === 'Bearer' && t) token = t; } - const [type, token] = authHeader.split(' '); + // fallback to cookie if no Authorization header + if (!token) { + token = (request as any).cookies?.raylab_jwt; + this.logger.debug(`No Authorization header. Trying cookie. cookiePresent=${!!(request as any).cookies} tokenFromCookie=${!!token}`); + } else { + this.logger.debug('Authorization header found. Using Bearer token.'); + } - if (type !== 'Bearer' || !token) { - throw new UnauthorizedException('Invalid authorization header.'); + if (!token) { + this.logger.debug('No token found in Authorization header or cookie.'); + throw new UnauthorizedException('Authorization token is missing.'); } const jwksUri = process.env.AUTHENTIK_JWKS_URI; - // First try verifying with external JWKS (Authentik) + // First try verifying with external JWKS (Authentik) if (jwksUri) { try { if (!this.jwks) this.jwks = createRemoteJWKSet(new URL(jwksUri)); @@ -54,15 +65,18 @@ export class JwtAuthGuard implements CanActivate { ); request.identity = identity; + this.logger.debug(`Verified token using external JWKS. sub=${payload.sub}`); return true; } catch (err) { + this.logger.debug(`External JWKS verification failed: ${(err as Error).message}`); // ignore and try internal verification } } // Fallback: verify with internal symmetric secret - const secret = process.env.RAYLAB_JWT_SECRET; + const secret = process.env.RAYLAB_JWT_SECRET; if (!secret) { + this.logger.error('RAYLAB_JWT_SECRET is not configured.'); throw new UnauthorizedException('Invalid or expired token.'); } @@ -77,8 +91,10 @@ export class JwtAuthGuard implements CanActivate { ); request.identity = identity; + this.logger.debug(`Verified token using internal secret. sub=${payload.sub}`); return true; } catch (err: any) { + this.logger.debug(`Internal token verification failed: ${(err as Error).message}`); throw new UnauthorizedException('Invalid or expired token.'); } } diff --git a/src/main.ts b/src/main.ts index 71d5c0c..0255dd3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ -import { ValidationPipe } from '@nestjs/common'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import cookieParser from 'cookie-parser'; import { NestFactory } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -9,6 +10,10 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); const config = app.get(ConfigService); + const logger = new Logger('Bootstrap'); + + // enable cookie parser so req.cookies is populated + app.use(cookieParser()); app.setGlobalPrefix(''); @@ -55,11 +60,14 @@ async function bootstrap() { SwaggerModule.setup('ApiList', app, document); } - const port = config.get('PORT') || 3000; + const port = config.get('PORT') || 3000; + + logger.log(`Allowed CORS origins: ${JSON.stringify(allowedOrigins)}`); + logger.log(`Server listening on port ${port}`); await app.listen(port); - console.log(`Server running on http://localhost:${port}`); + logger.log(`Server running on http://localhost:${port}`); } bootstrap(); \ No newline at end of file diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 79df924..8efcb12 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Post, Query, Res, Req, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Post, Query, Res, Req, Body, HttpCode, HttpStatus, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { Response, Request } from 'express'; @@ -8,6 +8,7 @@ import { ConfigService } from '@nestjs/config'; @ApiTags('Auth') @Controller('auth') export class AuthController { + private readonly logger = new Logger('AuthController'); constructor(private readonly authService: AuthService, private readonly config: ConfigService) {} @Get('login') @@ -35,11 +36,13 @@ export class AuthController { if (cookieDomain) cookieOptions.domain = cookieDomain; // access token cookie (internal JWT) + this.logger.log(`Setting access & refresh cookies. cookieOptions=${JSON.stringify(cookieOptions)}`); res.cookie('raylab_jwt', result.accessToken, { ...cookieOptions, maxAge: result.expiresIn * 1000 }); // refresh token cookie res.cookie('raylab_refresh', result.refreshToken, { ...cookieOptions, maxAge: result.refreshTtl * 1000 }); + this.logger.debug(`Callback complete. returnTo=${result.returnTo} user=${JSON.stringify(result.user)}`); return res.redirect(302, result.returnTo || '/'); } @@ -56,7 +59,9 @@ export class AuthController { @ApiOperation({ summary: 'Refresh internal JWT using internal refresh token' }) async refresh(@Req() req: Request) { const refreshToken = req.cookies?.raylab_refresh || req.body?.refreshToken; + this.logger.debug(`Refresh called. cookies present=${!!req.cookies} refreshTokenProvided=${!!refreshToken}`); const result = await this.authService.refresh(refreshToken); + this.logger.debug(`Refresh result for user. expiresIn=${result.expiresIn}`); return { success: true, data: result }; } @@ -64,7 +69,9 @@ export class AuthController { @ApiOperation({ summary: 'Get current user from internal JWT (cookie or Authorization header)' }) async me(@Req() req: Request) { const token = (req.cookies?.raylab_jwt) || (req.headers.authorization && (req.headers.authorization as string).replace(/^Bearer\s+/i, '')); + this.logger.debug(`Me called. cookies present=${!!req.cookies} tokenProvided=${!!token}`); const user = await this.authService.me(token); + this.logger.debug(`Me returning user id=${user?.id}`); return { success: true, data: user }; } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 717d289..5f83179 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -140,11 +140,14 @@ export class AuthService { const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600); const access = this.jwtService.sign(jwtPayload, { expiresIn }); + logger.debug(`Created internal access token for user=${domainUser.id} expiresIn=${expiresIn}`); + // create internal refresh token const refreshToken = crypto.randomUUID(); const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); // default 30 days await this.refreshStore.set(refreshToken, { userId: domainUser.id }, refreshTtl); + logger.debug(`Stored refresh token for user=${domainUser.id} ttl=${refreshTtl}`); return { accessToken: access, @@ -162,9 +165,16 @@ export class AuthService { } async refresh(refreshToken?: string) { - if (!refreshToken) throw new UnauthorizedException('Missing refresh token'); + if (!refreshToken) { + logger.debug('Refresh called without refresh token'); + throw new UnauthorizedException('Missing refresh token'); + } const data = await this.refreshStore.get(refreshToken); - if (!data) throw new UnauthorizedException('Invalid refresh token'); + if (!data) { + logger.debug(`Refresh token not found or expired: ${refreshToken}`); + throw new UnauthorizedException('Invalid refresh token'); + } + logger.debug(`Refresh token validated for userId=${data.userId}`); const userId = data.userId; // load user @@ -177,9 +187,11 @@ export class AuthService { const newRefresh = crypto.randomUUID(); const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); await this.refreshStore.set(newRefresh, { userId }, refreshTtl); + logger.debug(`Rotated refresh token for userId=${userId} newRefresh=${newRefresh} ttl=${refreshTtl}`); const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600); const access = this.jwtService.sign({ sub: domainUser.id, preferred_username: domainUser.username, email: domainUser.email }, { expiresIn }); + logger.debug(`Issued new access token for user=${userId} expiresIn=${expiresIn}`); return { accessToken: access, refreshToken: newRefresh, expiresIn, refreshTtl }; } @@ -204,13 +216,18 @@ export class AuthService { } async me(token?: string) { - if (!token) throw new UnauthorizedException('Missing token'); + if (!token) { + logger.debug('Me called without token'); + throw new UnauthorizedException('Missing token'); + } try { const payload: any = this.jwtService.verify(token); + logger.debug(`Token verified successfully. payload.sub=${payload.sub}`); const user = await this.userRepository.getById(payload.sub); if (!user) throw new UnauthorizedException('User not found'); return { id: user.id, username: user.username, email: user.email, roles: user.roles || [] }; } catch (e) { + logger.debug(`Token verification failed: ${(e as Error).message}`); throw new UnauthorizedException('Invalid token'); } }