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
9 changes: 2 additions & 7 deletions backend/src/authorization/auth-with-api.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ 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 { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.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';
Expand Down Expand Up @@ -81,12 +81,7 @@ export class AuthWithApiMiddleware implements NestMiddleware {
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();
}
}
assertTokenScopeAllowed(data.scope as Array<JwtScopesEnum>);

const payload = {
sub: userId,
Expand Down
9 changes: 2 additions & 7 deletions backend/src/authorization/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Repository } from 'typeorm';
import { LogOutEntity } from '../entities/log-out/log-out.entity.js';
import { JwtScopesEnum } from '../entities/user/enums/jwt-scopes.enum.js';
import { UserEntity } from '../entities/user/user.entity.js';
import { TwoFaRequiredException } from '../exceptions/custom-exceptions/two-fa-required-exception.js';
import { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.js';
import { Messages } from '../exceptions/text/messages.js';
import { isTest } from '../helpers/app/is-test.js';
import { Constants } from '../helpers/constants/constants.js';
Expand Down Expand Up @@ -69,12 +69,7 @@ export class AuthMiddleware implements NestMiddleware {
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();
}
}
assertTokenScopeAllowed(data.scope as Array<JwtScopesEnum>);

const payload = {
sub: userId,
Expand Down
9 changes: 2 additions & 7 deletions backend/src/authorization/public-or-auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ 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 { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.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';
Expand Down Expand Up @@ -86,12 +86,7 @@ export class PublicOrAuthMiddleware implements NestMiddleware {
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();
}
}
assertTokenScopeAllowed(data.scope as Array<JwtScopesEnum>);

const payload = {
sub: userId,
Expand Down
4 changes: 4 additions & 0 deletions backend/src/entities/user/enums/jwt-scopes.enum.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export enum JwtScopesEnum {
TWO_FA_ENABLE = '2fa_enable',
// Session restricted to finishing email confirmation. Registration (and login of an
// unconfirmed user) in rocketadmin-saas issues a token carrying this, and it is refused
// everywhere except the routes that complete the flow — see assertTokenScopeAllowed.
EMAIL_VERIFY = 'email_verify',
}
25 changes: 25 additions & 0 deletions backend/src/entities/user/utils/assert-token-scope-allowed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { EmailVerificationRequiredException } from '../../../exceptions/custom-exceptions/email-verification-required-exception.js';
import { TwoFaRequiredException } from '../../../exceptions/custom-exceptions/two-fa-required-exception.js';
import { JwtScopesEnum } from '../enums/jwt-scopes.enum.js';

// A scope on an end-user token means the session is restricted to the flow that finishes it:
// '2fa_enable' until the user enrols in company-mandated 2FA, 'email_verify' until the user
// confirms their email. Every auth path funnels through here so a restricted token is refused
// everywhere except the routes that complete its flow — those pass the scope in `allowScopes`.
//
// An unrecognized scope is ignored, preserving the behavior of the `includes(TWO_FA_ENABLE)`
// checks this replaced: a token minted by a newer service must not lock a user out of an older one.
export function assertTokenScopeAllowed(
tokenScope: Array<JwtScopesEnum> | null | undefined,
allowScopes: Array<JwtScopesEnum | string> = [],
): void {
if (!tokenScope || tokenScope.length === 0) {
return;
}
if (tokenScope.includes(JwtScopesEnum.TWO_FA_ENABLE) && !allowScopes.includes(JwtScopesEnum.TWO_FA_ENABLE)) {
throw new TwoFaRequiredException();
}
if (tokenScope.includes(JwtScopesEnum.EMAIL_VERIFY) && !allowScopes.includes(JwtScopesEnum.EMAIL_VERIFY)) {
throw new EmailVerificationRequiredException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export enum ExceptionsInternalCodes {
MASTER_PASSWORD_INCORRECT = 1201,
/** Two-factor authentication is required to continue. */
TWO_FA_REQUIRED = 1202,
/** The account's email address has not been confirmed yet. */
EMAIL_VERIFICATION_REQUIRED = 1203,

// --- 1300–1399: Not found ---
/** Connection entity was not found. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HttpStatus } from '@nestjs/common';
import { Messages } from '../text/messages.js';
import { BaseRocketAdminException } from './base-rocketadmin.exception.js';
import { ExceptionsInternalCodes } from './custom-exceptions-internal-codes/exceptions-internal-codes.js';

export class EmailVerificationRequiredException extends BaseRocketAdminException {
constructor() {
super(Messages.EMAIL_VERIFICATION_REQUIRED, HttpStatus.BAD_REQUEST, {
internalCode: ExceptionsInternalCodes.EMAIL_VERIFICATION_REQUIRED,
});
}
}
1 change: 1 addition & 0 deletions backend/src/exceptions/text/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ export const Messages = {
EMAIL_SYNTAX_INVALID: 'Email syntax is invalid',
EMAIL_NOT_CONFIRMED: 'Email is not confirmed',
EMAIL_VERIFICATION_FAILED: 'Email verification failed',
EMAIL_VERIFICATION_REQUIRED: `Please confirm your email address to continue. Enter the code we emailed you.`,
EMAIL_VERIFIED_SUCCESSFULLY: 'Email verified successfully',
EMAIL_CHANGE_REQUESTED_SUCCESSFULLY: `Email change request was requested successfully`,
EMAIL_CHANGE_REQUESTED: `Email change request was requested`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import AbstractUseCase from '../../../common/abstract-use.case.js';
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
import { BaseType } from '../../../common/data-injection.tokens.js';
import { JwtScopesEnum } from '../../../entities/user/enums/jwt-scopes.enum.js';
import { TwoFaRequiredException } from '../../../exceptions/custom-exceptions/two-fa-required-exception.js';
import { assertTokenScopeAllowed } from '../../../entities/user/utils/assert-token-scope-allowed.js';
import { Messages } from '../../../exceptions/text/messages.js';
import { appConfig } from '../../../shared/config/app-config.js';
import { ValidateUserTokenDs } from '../data-structures/agents.ds.js';
Expand Down Expand Up @@ -63,12 +63,7 @@ export class ValidateUserTokenUseCase
throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED);
}

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

return {
sub: userId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { HttpStatus } from '@nestjs/common';
import test from 'ava';
import { JwtScopesEnum } from '../../../src/entities/user/enums/jwt-scopes.enum.js';
import { assertTokenScopeAllowed } from '../../../src/entities/user/utils/assert-token-scope-allowed.js';
import { ExceptionsInternalCodes } from '../../../src/exceptions/custom-exceptions/custom-exceptions-internal-codes/exceptions-internal-codes.js';
import { EmailVerificationRequiredException } from '../../../src/exceptions/custom-exceptions/email-verification-required-exception.js';
import { TwoFaRequiredException } from '../../../src/exceptions/custom-exceptions/two-fa-required-exception.js';

// The single place every auth path asks "is this restricted token allowed here?".
// A scope on a token means the session is limited to the flow that finishes it —
// '2fa_enable' until the user enrols in 2FA, 'email_verify' until the user confirms
// their email — so it must be refused everywhere except the routes that complete it.

test('a token with no scope claim is allowed', (t) => {
t.notThrows(() => assertTokenScopeAllowed(undefined));
t.notThrows(() => assertTokenScopeAllowed(null));
t.notThrows(() => assertTokenScopeAllowed([]));
});

test("'2fa_enable' is refused when the caller allows no scopes", (t) => {
const error = t.throws(() => assertTokenScopeAllowed([JwtScopesEnum.TWO_FA_ENABLE]), {
instanceOf: TwoFaRequiredException,
});
t.is(error.getStatus(), HttpStatus.BAD_REQUEST);
t.is(error.internalCode, ExceptionsInternalCodes.TWO_FA_REQUIRED);
});

test("'2fa_enable' is allowed when the caller allows it (2FA-enrolment routes)", (t) => {
t.notThrows(() => assertTokenScopeAllowed([JwtScopesEnum.TWO_FA_ENABLE], [JwtScopesEnum.TWO_FA_ENABLE]));
});

test("'email_verify' is refused when the caller allows no scopes", (t) => {
const error = t.throws(() => assertTokenScopeAllowed([JwtScopesEnum.EMAIL_VERIFY]), {
instanceOf: EmailVerificationRequiredException,
});
t.is(error.getStatus(), HttpStatus.BAD_REQUEST);
t.is(error.internalCode, ExceptionsInternalCodes.EMAIL_VERIFICATION_REQUIRED);
});

test("'email_verify' is allowed when the caller allows it (the verify-code routes)", (t) => {
t.notThrows(() => assertTokenScopeAllowed([JwtScopesEnum.EMAIL_VERIFY], [JwtScopesEnum.EMAIL_VERIFY]));
});

test('allowing one scope does not allow a different one', (t) => {
t.throws(() => assertTokenScopeAllowed([JwtScopesEnum.EMAIL_VERIFY], [JwtScopesEnum.TWO_FA_ENABLE]), {
instanceOf: EmailVerificationRequiredException,
});
t.throws(() => assertTokenScopeAllowed([JwtScopesEnum.TWO_FA_ENABLE], [JwtScopesEnum.EMAIL_VERIFY]), {
instanceOf: TwoFaRequiredException,
});
});

test('a token carrying both scopes is refused until both are allowed', (t) => {
const scopes = [JwtScopesEnum.TWO_FA_ENABLE, JwtScopesEnum.EMAIL_VERIFY];
t.throws(() => assertTokenScopeAllowed(scopes, [JwtScopesEnum.TWO_FA_ENABLE]), {
instanceOf: EmailVerificationRequiredException,
});
t.notThrows(() => assertTokenScopeAllowed(scopes, scopes));
});

// Forward compatibility: an unrecognized scope keeps the pre-existing behavior of
// every call site (the old `includes(TWO_FA_ENABLE)` check ignored anything else),
// so a token minted by a newer service never locks a user out of an older one.
test('an unrecognized scope is ignored', (t) => {
t.notThrows(() => assertTokenScopeAllowed(['some_future_scope' as JwtScopesEnum]));
});
43 changes: 43 additions & 0 deletions backend/test/ava-tests/unit-tests/validate-user-token-dto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import test from 'ava';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { JwtScopesEnum } from '../../../src/entities/user/enums/jwt-scopes.enum.js';
import { ValidateUserTokenDto } from '../../../src/microservices/agents-microservice/dto/agents-auth.dtos.js';

// `allowScopes` is enum-validated, which makes this DTO a CROSS-REPO CONTRACT: a satellite that
// asks to accept a scope this core has never heard of gets a 400 from the ValidationPipe before
// any handler runs. That is the correct failure — a core that cannot name a scope cannot enforce
// it either — but it means every scope a satellite may send has to exist here first.
//
// This exact break cost a CI run: rocketadmin-saas' email-verification middleware sent
// 'email_verify' and every request through it 400'd.

function errorsFor(body: Record<string, unknown>): Array<string> {
return validateSync(plainToInstance(ValidateUserTokenDto, body)).flatMap((e) => Object.values(e.constraints ?? {}));
}

test('accepts a token on its own (the strict case)', (t) => {
t.deepEqual(errorsFor({ token: 'a-jwt' }), []);
});

test("accepts allowScopes: ['2fa_enable'] — the OTP-enrolment routes", (t) => {
t.deepEqual(errorsFor({ token: 'a-jwt', allowScopes: [JwtScopesEnum.TWO_FA_ENABLE] }), []);
});

test("accepts allowScopes: ['email_verify'] — the email-confirmation routes", (t) => {
t.deepEqual(errorsFor({ token: 'a-jwt', allowScopes: ['email_verify'] }), []);
});

test('accepts every scope this core defines, so no satellite can be refused a scope we enforce', (t) => {
t.deepEqual(errorsFor({ token: 'a-jwt', allowScopes: Object.values(JwtScopesEnum) }), []);
});

test('refuses a scope this core does not know', (t) => {
// Deliberate: accepting it would let a caller wave through a restriction we cannot apply.
t.true(errorsFor({ token: 'a-jwt', allowScopes: ['not_a_scope'] }).length > 0);
});

test('refuses a missing token and a non-array allowScopes', (t) => {
t.true(errorsFor({}).length > 0);
t.true(errorsFor({ token: 'a-jwt', allowScopes: 'email_verify' }).length > 0);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import test from 'ava';
import jwt from 'jsonwebtoken';
import { IGlobalDatabaseContext } from '../../../src/common/application/global-database-context.interface.js';
import { JwtScopesEnum } from '../../../src/entities/user/enums/jwt-scopes.enum.js';
import { InTransactionEnum } from '../../../src/enums/in-transaction.enum.js';
import { EmailVerificationRequiredException } from '../../../src/exceptions/custom-exceptions/email-verification-required-exception.js';
import { TwoFaRequiredException } from '../../../src/exceptions/custom-exceptions/two-fa-required-exception.js';
import { ValidateUserTokenUseCase } from '../../../src/microservices/agents-microservice/use-cases/validate-user-token.use.case.js';
import { appConfig } from '../../../src/shared/config/app-config.js';

// The satellite services (rocketadmin-saas, agents-core) validate end-user cookies through this
// use case. `allowScopes` is how a caller says "this route is the one that finishes the restricted
// flow" — without it a scoped token must be refused, which is what makes the email-verification
// gate real rather than cosmetic.

const USER_ID = 'a3f1c9d2-4b5e-4c7a-8d9e-0f1a2b3c4d5e';

function makeUseCase(): ValidateUserTokenUseCase {
const dbContext = {
logOutRepository: { isLoggedOut: async () => false },
userRepository: { findOneUserById: async () => ({ id: USER_ID, suspended: false }) },
} as unknown as IGlobalDatabaseContext;
return new ValidateUserTokenUseCase(dbContext);
}

function signToken(scope?: Array<JwtScopesEnum>): string {
return jwt.sign({ id: USER_ID, email: 'user@example.com', scope }, appConfig.auth.jwtSecret, { expiresIn: '1h' });
}

test('an unscoped token validates and returns the identity', async (t) => {
const result = await makeUseCase().execute({ token: signToken() }, InTransactionEnum.OFF);
t.is(result.sub, USER_ID);
t.is(result.email, 'user@example.com');
});

test("an 'email_verify' token is refused when the caller allows no scopes", async (t) => {
await t.throwsAsync(
makeUseCase().execute({ token: signToken([JwtScopesEnum.EMAIL_VERIFY]) }, InTransactionEnum.OFF),
{
instanceOf: EmailVerificationRequiredException,
},
);
});

test("an 'email_verify' token validates when the caller allows that scope", async (t) => {
const result = await makeUseCase().execute(
{ token: signToken([JwtScopesEnum.EMAIL_VERIFY]), allowScopes: [JwtScopesEnum.EMAIL_VERIFY] },
InTransactionEnum.OFF,
);
t.is(result.sub, USER_ID);
});

test("a '2fa_enable' token is still refused by default and accepted when allowed", async (t) => {
await t.throwsAsync(
makeUseCase().execute({ token: signToken([JwtScopesEnum.TWO_FA_ENABLE]) }, InTransactionEnum.OFF),
{ instanceOf: TwoFaRequiredException },
);
const result = await makeUseCase().execute(
{ token: signToken([JwtScopesEnum.TWO_FA_ENABLE]), allowScopes: [JwtScopesEnum.TWO_FA_ENABLE] },
InTransactionEnum.OFF,
);
t.is(result.sub, USER_ID);
});
Loading