@@ -3,6 +3,7 @@ import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
@@ -16,22 +17,32 @@ import * as jwt from 'jsonwebtoken';
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
private readonly logger = new Logger('JwtAuthGuard');
|
||||
|
||||
constructor() {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { identity?: IdentityData }>();
|
||||
|
||||
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;
|
||||
@@ -54,8 +65,10 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -63,6 +76,7 @@ export class JwtAuthGuard implements CanActivate {
|
||||
// Fallback: verify with internal symmetric 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.');
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -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('');
|
||||
|
||||
@@ -57,9 +62,12 @@ async function bootstrap() {
|
||||
|
||||
const port = config.get<number>('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();
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user