Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions backend/src/authorization/public-or-auth.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {
HttpException,
Injectable,
InternalServerErrorException,
NestMiddleware,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Sentry from '@sentry/minimal';
import { NextFunction, Response } from 'express';
import jwt from 'jsonwebtoken';
import { Repository } from 'typeorm';
import { JwtScopesEnum } from '../entities/user/enums/jwt-scopes.enum.js';
import { UserEntity } from '../entities/user/user.entity.js';
import { EncryptionAlgorithmEnum } from '../enums/encryption-algorithm.enum.js';
import { TwoFaRequiredException } from '../exceptions/custom-exceptions/two-fa-required-exception.js';
import { Messages } from '../exceptions/text/messages.js';
import { Constants } from '../helpers/constants/constants.js';
import { Encryptor } from '../helpers/encryption/encryptor.js';
import { isObjectEmpty } from '../helpers/is-object-empty.js';
import { appConfig } from '../shared/config/app-config.js';
import { IRequestWithCognitoInfo } from './cognito-decoded.interface.js';

/**
* Authentication middleware that ALSO allows anonymous ("public") requests through.
*
* - A JWT cookie or an `x-api-key` header is authenticated exactly like AuthWithApiMiddleware and
* populates `req.decoded`.
* - When neither is present, the request is treated as public: `req.decoded` is left empty and the
* request continues. Downstream guards then decide whether the connection's public policy grants
* access. An invalid/expired credential still fails fast.
*
* This is applied only to read-capable pure CRUD routes; write routes keep AuthWithApiMiddleware.
*/
Comment on lines +34 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Correct the middleware scope docblock.

Line 34 says write routes keep AuthWithApiMiddleware, but this middleware is wired to write routes too (backend/src/entities/table/table-pure-crud-operations/table-pure-crud-operations.module.ts, Lines 71-85). Please align the comment to avoid misleading maintenance guidance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/authorization/public-or-auth.middleware.ts` around lines 34 - 35,
The docblock for the PublicOrAuthMiddleware (around line 34) incorrectly states
that write routes keep AuthWithApiMiddleware, but this middleware is actually
applied to write routes as well. Update the comment to accurately reflect the
actual scope of where this middleware is applied - clarify that it is used on
both read and write routes in the pure CRUD operations, not just read-capable
routes, to prevent misleading future maintainers.

@Injectable()
export class PublicOrAuthMiddleware implements NestMiddleware {
public constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}

async use(req: IRequestWithCognitoInfo, _res: Response, next: NextFunction): Promise<void> {
try {
const tokenFromCookie = req.cookies?.[Constants.JWT_COOKIE_KEY_NAME];
let apiKey = req.headers?.['x-api-key'];
if (Array.isArray(apiKey)) {
apiKey = apiKey[0];
}

if (tokenFromCookie) {
await this.authenticateWithToken(tokenFromCookie, req);
} else if (apiKey) {
await this.authenticateWithApiKey(apiKey, req);
} else {
req.decoded = {};
}
next();
} catch (error) {
Sentry.captureException(error);
if (error instanceof HttpException || error instanceof UnauthorizedException) {
throw error;
}
throw new InternalServerErrorException(Messages.AUTHORIZATION_REJECTED);
}
Comment on lines +59 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "public-or-auth.middleware.ts" | head -5

Repository: rocket-admin/rocketadmin

Length of output: 125


🏁 Script executed:

cat -n backend/src/authorization/public-or-auth.middleware.ts

Repository: rocket-admin/rocketadmin

Length of output: 5413


🏁 Script executed:

# Check jsonwebtoken package version and available error types
cat package.json | grep -A 5 '"jsonwebtoken"'

Repository: rocket-admin/rocketadmin

Length of output: 50


🏁 Script executed:

# Search for jwt imports and usage in the middleware file
rg "import.*jwt|jsonwebtoken" backend/src/authorization/public-or-auth.middleware.ts -A 2 -B 2

Repository: rocket-admin/rocketadmin

Length of output: 444


🏁 Script executed:

# Get lines around the catch block to see the actual code
sed -n '50,70p' backend/src/authorization/public-or-auth.middleware.ts | cat -n

Repository: rocket-admin/rocketadmin

Length of output: 847


🏁 Script executed:

# Check if TwoFaRequiredException extends HttpException
rg "class TwoFaRequiredException" -A 5

Repository: rocket-admin/rocketadmin

Length of output: 703


🏁 Script executed:

# Check the module wiring to see which routes use PublicOrAuthMiddleware
find . -type f -name "table-pure-crud-operations.module.ts" -exec cat -n {} \;

Repository: rocket-admin/rocketadmin

Length of output: 4347


🏁 Script executed:

# Check BaseRocketAdminException to see if it extends HttpException
rg "class BaseRocketAdminException" -A 10

Repository: rocket-admin/rocketadmin

Length of output: 1306


🏁 Script executed:

# Check how jwt errors are typically imported and used in Node/jsonwebtoken
web_search
jsonwebtoken error types JsonWebTokenError TokenExpiredError NotBeforeError import

Repository: rocket-admin/rocketadmin

Length of output: 170


🌐 Web query:

jsonwebtoken error types JsonWebTokenError TokenExpiredError NotBeforeError

💡 Result:

The jsonwebtoken library uses three primary error classes to handle specific failures during the token verification process [1][2]. These errors are returned as the first argument in an asynchronous callback or thrown synchronously when no callback is provided [2][3]. TokenExpiredError This error is thrown when the token's exp (expiration) claim indicates that the token is no longer valid [1][2]. - Name: TokenExpiredError [1][2] - Message: 'jwt expired' [3] - Additional Property: expiredAt (the date when the token expired) [3] NotBeforeError This error is thrown when the current time is before the time specified in the nbf (not before) claim [1][3]. - Name: NotBeforeError [1][2] - Message: 'jwt not active' [1][3] - Additional Property: date (the date before which the token was not active) [1][3] JsonWebTokenError This is a general error class for various issues encountered while parsing or validating a token [1][2]. Common messages include [1][3]: - 'invalid token': The header or payload could not be parsed. - 'jwt malformed': The token does not consist of three components delimited by a dot (.). - 'jwt signature is required' - 'invalid signature' - 'jwt audience invalid', 'jwt issuer invalid', 'jwt id invalid', 'jwt subject invalid': Thrown when claims do not match the options provided to verify [1][3]. Note: When working with untrusted input, always use jwt.verify rather than jwt.decode, as the latter does not perform signature verification [4]. Additionally, jsonwebtoken depends on Node.js-specific modules (like crypto) and may not be compatible with edge runtimes or environments lacking these APIs [5].

Citations:


🏁 Script executed:

# Check how jwt.verify errors are caught in actual code or if there are other tests/examples
rg "jwt.verify" -B 5 -A 10 | head -50

Repository: rocket-admin/rocketadmin

Length of output: 3787


Return 401 for invalid/expired JWTs instead of 500.

At line 64, non-HttpException errors are caught and converted to InternalServerErrorException. When jwt.verify() fails (invalid/expired token), it throws JsonWebTokenError, TokenExpiredError, or NotBeforeError—none of which are HttpException instances. These errors fall through to the 500 response instead of returning 401. Add explicit handling for JWT errors to throw UnauthorizedException:

Suggested fix
 	} catch (error) {
 		Sentry.captureException(error);
 		if (error instanceof HttpException || error instanceof UnauthorizedException) {
 			throw error;
 		}
+		if (
+			error instanceof jwt.JsonWebTokenError ||
+			error instanceof jwt.TokenExpiredError ||
+			error instanceof jwt.NotBeforeError
+		) {
+			throw new UnauthorizedException('JWT verification failed');
+		}
 		throw new InternalServerErrorException(Messages.AUTHORIZATION_REJECTED);
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/authorization/public-or-auth.middleware.ts` around lines 59 - 65,
In the catch block of the public-or-auth.middleware.ts file, add explicit
handling for JWT verification errors before the final
InternalServerErrorException throw. After checking for HttpException and
UnauthorizedException, add a condition to detect JsonWebTokenError,
TokenExpiredError, and NotBeforeError instances and throw UnauthorizedException
instead of InternalServerErrorException. This ensures that invalid or expired
JWT tokens return a 401 Unauthorized response rather than a 500 Internal Server
Error.

}

private async authenticateWithToken(tokenFromCookie: string, req: IRequestWithCognitoInfo): Promise<void> {
const jwtSecret = appConfig.auth.jwtSecret;
if (!jwtSecret) {
throw new UnauthorizedException('JWT verification failed');
}
const data = jwt.verify(tokenFromCookie, jwtSecret) as jwt.JwtPayload;
const userId = data.id;

if (!userId) {
throw new UnauthorizedException('JWT verification failed');
}

const userExists = await this.userRepository.findOne({ where: { id: userId } });
if (!userExists) {
throw new UnauthorizedException('JWT verification failed');
}

if (userExists.suspended) {
throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED);
}

const addedScope: Array<JwtScopesEnum> = data.scope;
if (addedScope && addedScope.length > 0) {
if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) {
throw new TwoFaRequiredException();
}
}

const payload = {
sub: userId,
email: data.email,
exp: data.exp,
iat: data.iat,
};
if (!payload || isObjectEmpty(payload)) {
throw new UnauthorizedException('JWT verification failed');
}
req.decoded = payload;
}

private async authenticateWithApiKey(apiKey: string, req: IRequestWithCognitoInfo): Promise<void> {
const apiKeyHash = await Encryptor.processDataWithAlgorithm(apiKey, EncryptionAlgorithmEnum.sha256);
const foundUserByApiKey = await this.userRepository
.createQueryBuilder('user')
.innerJoinAndSelect('user.api_keys', 'api_key')
.where('api_key.hash = :hash', { hash: apiKeyHash })
.getOne();

if (!foundUserByApiKey) {
throw new NotFoundException(Messages.NO_AUTH_KEYS_FOUND);
}

if (foundUserByApiKey.suspended) {
throw new UnauthorizedException(Messages.API_KEY_SUSPENDED);
}

req.decoded = {
sub: foundUserByApiKey.id,
email: foundUserByApiKey.email,
};
}
}
22 changes: 22 additions & 0 deletions backend/src/decorators/optional-user-id.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
import { IRequestWithCognitoInfo } from '../authorization/cognito-decoded.interface.js';
import { Messages } from '../exceptions/text/messages.js';
import { ValidationHelper } from '../helpers/validators/validation-helper.js';

/**
* Like @UserId(), but tolerates an unauthenticated ("public") request: when no user is present it
* returns undefined instead of throwing. Use on endpoints that may be reached by public users
* (the guard decides whether public access is allowed); the handler then treats a missing userId
* as a public request.
*/
export const OptionalUserId = createParamDecorator((_data: unknown, ctx: ExecutionContext): string | undefined => {
const request: IRequestWithCognitoInfo = ctx.switchToHttp().getRequest();
const userId = request.decoded?.sub;
if (!userId) {
return undefined;
}
if (ValidationHelper.isValidUUID(userId)) {
return userId;
}
throw new BadRequestException(Messages.UUID_INVALID);
});
3 changes: 3 additions & 0 deletions backend/src/entities/cedar-authorization/cedar-action-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const ACTION_EVENT_PROBE_ID = '__probe__';

export const COLUMN_PROBE_ID = '__probe__';

export const PUBLIC_USER_ID = '__public__';

export interface CedarValidationRequest {
userId: string;
action: CedarAction;
Expand All @@ -50,4 +52,5 @@ export interface CedarValidationRequest {
actionEventId?: string;
dashboardId?: string;
panelId?: string;
publicAccess?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
HttpStatus,
Injectable,
Post,
Put,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
Expand All @@ -18,6 +19,7 @@ import { ConnectionReadGuard } from '../../guards/connection-read.guard.js';
import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
import { IComplexPermission } from '../permission/permission.interface.js';
import { CedarAuthorizationService } from './cedar-authorization.service.js';
import { PublicPermissionsResponseDto, SetPublicPermissionsDto } from './dto/public-permissions.dto.js';
import { SaveCedarPolicyDto } from './dto/save-cedar-policy.dto.js';
import { ValidateCedarSchemaDto } from './dto/validate-cedar-schema.dto.js';

Expand Down Expand Up @@ -87,4 +89,40 @@ export class CedarAuthorizationController {
}
return this.cedarAuthService.saveCedarPolicy(connectionId, dto.groupId, dto.cedarPolicy);
}

@ApiOperation({
summary: 'Get the public (unauthenticated) read permissions configured for a connection',
})
@ApiResponse({ status: 200, description: 'Public permissions returned.', type: PublicPermissionsResponseDto })
@ApiParam({ name: 'connectionId', required: true })
@UseGuards(ConnectionEditGuard)
@Get('/connection/public-permissions/:connectionId')
async getPublicPermissions(@SlugUuid('connectionId') connectionId: string): Promise<PublicPermissionsResponseDto> {
if (!connectionId) {
throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST);
}
return this.cedarAuthService.getPublicPermissions(connectionId);
}

@ApiOperation({
summary: 'Set the public (unauthenticated) read permissions for a connection',
description:
'Generates and stores a Cedar policy granting public users QueryTable + ColumnRead on the listed tables. ' +
'Pass an empty "tables" array to disable public access.',
})
@ApiResponse({ status: 200, description: 'Public permissions saved.', type: PublicPermissionsResponseDto })
@ApiBody({ type: SetPublicPermissionsDto })
@ApiParam({ name: 'connectionId', required: true })
@UseGuards(ConnectionEditGuard)
@Put('/connection/public-permissions/:connectionId')
async setPublicPermissions(
@SlugUuid('connectionId') connectionId: string,
@Body() dto: SetPublicPermissionsDto,
): Promise<PublicPermissionsResponseDto> {
if (!connectionId) {
throw new HttpException({ message: Messages.CONNECTION_ID_MISSING }, HttpStatus.BAD_REQUEST);
}
const { enabled, tables } = await this.cedarAuthService.savePublicPermissions(connectionId, dto.tables ?? []);
return { enabled, tables };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export class CedarAuthorizationModule implements NestModule {
{ path: '/connection/cedar-schema/:connectionId', method: RequestMethod.GET },
{ path: '/connection/cedar-schema/validate/:connectionId', method: RequestMethod.POST },
{ path: '/connection/cedar-policy/:connectionId', method: RequestMethod.POST },
{ path: '/connection/public-permissions/:connectionId', method: RequestMethod.GET },
{ path: '/connection/public-permissions/:connectionId', method: RequestMethod.PUT },
);
}
}
Loading
Loading