@@ -3,6 +3,7 @@ import {
|
|||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
@@ -16,27 +17,37 @@ import * as jwt from 'jsonwebtoken';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard implements CanActivate {
|
export class JwtAuthGuard implements CanActivate {
|
||||||
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||||
|
private readonly logger = new Logger('JwtAuthGuard');
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request & { identity?: IdentityData }>();
|
const request = context.switchToHttp().getRequest<Request & { identity?: IdentityData }>();
|
||||||
|
|
||||||
const authHeader = request.headers.authorization;
|
let token: string | undefined;
|
||||||
|
|
||||||
if (!authHeader) {
|
const authHeader = request.headers.authorization;
|
||||||
throw new UnauthorizedException('Authorization header is missing.');
|
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) {
|
if (!token) {
|
||||||
throw new UnauthorizedException('Invalid authorization header.');
|
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;
|
const jwksUri = process.env.AUTHENTIK_JWKS_URI;
|
||||||
|
|
||||||
// First try verifying with external JWKS (Authentik)
|
// First try verifying with external JWKS (Authentik)
|
||||||
if (jwksUri) {
|
if (jwksUri) {
|
||||||
try {
|
try {
|
||||||
if (!this.jwks) this.jwks = createRemoteJWKSet(new URL(jwksUri));
|
if (!this.jwks) this.jwks = createRemoteJWKSet(new URL(jwksUri));
|
||||||
@@ -54,15 +65,18 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
request.identity = identity;
|
request.identity = identity;
|
||||||
|
this.logger.debug(`Verified token using external JWKS. sub=${payload.sub}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.logger.debug(`External JWKS verification failed: ${(err as Error).message}`);
|
||||||
// ignore and try internal verification
|
// ignore and try internal verification
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: verify with internal symmetric secret
|
// Fallback: verify with internal symmetric secret
|
||||||
const secret = process.env.RAYLAB_JWT_SECRET;
|
const secret = process.env.RAYLAB_JWT_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
|
this.logger.error('RAYLAB_JWT_SECRET is not configured.');
|
||||||
throw new UnauthorizedException('Invalid or expired token.');
|
throw new UnauthorizedException('Invalid or expired token.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +91,10 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
request.identity = identity;
|
request.identity = identity;
|
||||||
|
this.logger.debug(`Verified token using internal secret. sub=${payload.sub}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
this.logger.debug(`Internal token verification failed: ${(err as Error).message}`);
|
||||||
throw new UnauthorizedException('Invalid or expired token.');
|
throw new UnauthorizedException('Invalid or expired token.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -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 { NestFactory } from '@nestjs/core';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
@@ -9,6 +10,10 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
const config = app.get(ConfigService);
|
const config = app.get(ConfigService);
|
||||||
|
const logger = new Logger('Bootstrap');
|
||||||
|
|
||||||
|
// enable cookie parser so req.cookies is populated
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
app.setGlobalPrefix('');
|
app.setGlobalPrefix('');
|
||||||
|
|
||||||
@@ -55,11 +60,14 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup('ApiList', app, document);
|
SwaggerModule.setup('ApiList', app, document);
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = config.get<number>('PORT') || 3000;
|
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);
|
await app.listen(port);
|
||||||
|
|
||||||
console.log(`Server running on http://localhost:${port}`);
|
logger.log(`Server running on http://localhost:${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
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 { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Response, Request } from 'express';
|
import { Response, Request } from 'express';
|
||||||
@@ -8,6 +8,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
private readonly logger = new Logger('AuthController');
|
||||||
constructor(private readonly authService: AuthService, private readonly config: ConfigService) {}
|
constructor(private readonly authService: AuthService, private readonly config: ConfigService) {}
|
||||||
|
|
||||||
@Get('login')
|
@Get('login')
|
||||||
@@ -35,11 +36,13 @@ export class AuthController {
|
|||||||
if (cookieDomain) cookieOptions.domain = cookieDomain;
|
if (cookieDomain) cookieOptions.domain = cookieDomain;
|
||||||
|
|
||||||
// access token cookie (internal JWT)
|
// 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 });
|
res.cookie('raylab_jwt', result.accessToken, { ...cookieOptions, maxAge: result.expiresIn * 1000 });
|
||||||
|
|
||||||
// refresh token cookie
|
// refresh token cookie
|
||||||
res.cookie('raylab_refresh', result.refreshToken, { ...cookieOptions, maxAge: result.refreshTtl * 1000 });
|
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 || '/');
|
return res.redirect(302, result.returnTo || '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +59,9 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Refresh internal JWT using internal refresh token' })
|
@ApiOperation({ summary: 'Refresh internal JWT using internal refresh token' })
|
||||||
async refresh(@Req() req: Request) {
|
async refresh(@Req() req: Request) {
|
||||||
const refreshToken = req.cookies?.raylab_refresh || req.body?.refreshToken;
|
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);
|
const result = await this.authService.refresh(refreshToken);
|
||||||
|
this.logger.debug(`Refresh result for user. expiresIn=${result.expiresIn}`);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +69,9 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Get current user from internal JWT (cookie or Authorization header)' })
|
@ApiOperation({ summary: 'Get current user from internal JWT (cookie or Authorization header)' })
|
||||||
async me(@Req() req: Request) {
|
async me(@Req() req: Request) {
|
||||||
const token = (req.cookies?.raylab_jwt) || (req.headers.authorization && (req.headers.authorization as string).replace(/^Bearer\s+/i, ''));
|
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);
|
const user = await this.authService.me(token);
|
||||||
|
this.logger.debug(`Me returning user id=${user?.id}`);
|
||||||
return { success: true, data: user };
|
return { success: true, data: user };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,11 +140,14 @@ export class AuthService {
|
|||||||
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
|
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
|
||||||
const access = this.jwtService.sign(jwtPayload, { expiresIn });
|
const access = this.jwtService.sign(jwtPayload, { expiresIn });
|
||||||
|
|
||||||
|
logger.debug(`Created internal access token for user=${domainUser.id} expiresIn=${expiresIn}`);
|
||||||
|
|
||||||
// create internal refresh token
|
// create internal refresh token
|
||||||
const refreshToken = crypto.randomUUID();
|
const refreshToken = crypto.randomUUID();
|
||||||
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); // default 30 days
|
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);
|
await this.refreshStore.set(refreshToken, { userId: domainUser.id }, refreshTtl);
|
||||||
|
logger.debug(`Stored refresh token for user=${domainUser.id} ttl=${refreshTtl}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: access,
|
accessToken: access,
|
||||||
@@ -162,9 +165,16 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh(refreshToken?: string) {
|
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);
|
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;
|
const userId = data.userId;
|
||||||
// load user
|
// load user
|
||||||
@@ -177,9 +187,11 @@ export class AuthService {
|
|||||||
const newRefresh = crypto.randomUUID();
|
const newRefresh = crypto.randomUUID();
|
||||||
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600);
|
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600);
|
||||||
await this.refreshStore.set(newRefresh, { userId }, refreshTtl);
|
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 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 });
|
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 };
|
return { accessToken: access, refreshToken: newRefresh, expiresIn, refreshTtl };
|
||||||
}
|
}
|
||||||
@@ -204,13 +216,18 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async me(token?: string) {
|
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 {
|
try {
|
||||||
const payload: any = this.jwtService.verify(token);
|
const payload: any = this.jwtService.verify(token);
|
||||||
|
logger.debug(`Token verified successfully. payload.sub=${payload.sub}`);
|
||||||
const user = await this.userRepository.getById(payload.sub);
|
const user = await this.userRepository.getById(payload.sub);
|
||||||
if (!user) throw new UnauthorizedException('User not found');
|
if (!user) throw new UnauthorizedException('User not found');
|
||||||
return { id: user.id, username: user.username, email: user.email, roles: user.roles || [] };
|
return { id: user.id, username: user.username, email: user.email, roles: user.roles || [] };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.debug(`Token verification failed: ${(e as Error).message}`);
|
||||||
throw new UnauthorizedException('Invalid token');
|
throw new UnauthorizedException('Invalid token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user