From b564db80a78b06d69773d12773a2e50aa84fd9f1 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 25 Aug 2026 16:03:52 +0300 Subject: [PATCH] feat(auth): refuse an 'email_verify'-scoped token everywhere it is not the point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rocketadmin-saas is growing an email-confirmation gate: registration (and login of an unconfirmed user) issues a session carrying a new 'email_verify' scope, and the account must be unusable until the emailed code is entered. That only holds if THIS service refuses the scope — a satellite cannot enforce a restriction on the core's own API, and neither can agents-core. - JwtScopesEnum gains EMAIL_VERIFY, so `allowScopes` (which is enum-validated) can name it. Until it exists here, every satellite request asking to accept it is rejected by the ValidationPipe with a 400 before any handler runs — correct, but it makes this a cross-repo contract: the scope has to land here first. `validate-user-token-dto.test.ts` pins that contract. - new EmailVerificationRequiredException (400, internal code 1203), mirroring TwoFaRequiredException, so a satellite can route on the reason rather than parse a message. - the four `includes(TWO_FA_ENABLE)` checks (three request middlewares plus ValidateUserTokenUseCase) become one `assertTokenScopeAllowed()`. Behavior for existing tokens is unchanged, including that an UNRECOGNIZED scope is still ignored — a token minted by a newer service must not lock a user out of an older one, and it is what keeps IMPERSONATED tokens working. The use case's allowScopes/suspension semantics are untouched. Deploy this before (or with) the rocketadmin-saas change that starts issuing the scope, or unverified users sail straight through. Co-Authored-By: Claude Opus 5 (1M context) --- .../authorization/auth-with-api.middleware.ts | 9 +-- backend/src/authorization/auth.middleware.ts | 9 +-- .../public-or-auth.middleware.ts | 9 +-- .../entities/user/enums/jwt-scopes.enum.ts | 4 ++ .../user/utils/assert-token-scope-allowed.ts | 25 +++++++ .../exceptions-internal-codes.ts | 2 + .../email-verification-required-exception.ts | 12 ++++ backend/src/exceptions/text/messages.ts | 1 + .../use-cases/validate-user-token.use.case.ts | 9 +-- .../assert-token-scope-allowed.test.ts | 66 +++++++++++++++++++ .../validate-user-token-dto.test.ts | 43 ++++++++++++ .../validate-user-token.use.case.test.ts | 63 ++++++++++++++++++ 12 files changed, 224 insertions(+), 28 deletions(-) create mode 100644 backend/src/entities/user/utils/assert-token-scope-allowed.ts create mode 100644 backend/src/exceptions/custom-exceptions/email-verification-required-exception.ts create mode 100644 backend/test/ava-tests/unit-tests/assert-token-scope-allowed.test.ts create mode 100644 backend/test/ava-tests/unit-tests/validate-user-token-dto.test.ts create mode 100644 backend/test/ava-tests/unit-tests/validate-user-token.use.case.test.ts diff --git a/backend/src/authorization/auth-with-api.middleware.ts b/backend/src/authorization/auth-with-api.middleware.ts index 54636c499..c2b9113db 100644 --- a/backend/src/authorization/auth-with-api.middleware.ts +++ b/backend/src/authorization/auth-with-api.middleware.ts @@ -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'; @@ -81,12 +81,7 @@ export class AuthWithApiMiddleware implements NestMiddleware { throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED); } - const addedScope: Array = data.scope; - if (addedScope && addedScope.length > 0) { - if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) { - throw new TwoFaRequiredException(); - } - } + assertTokenScopeAllowed(data.scope as Array); const payload = { sub: userId, diff --git a/backend/src/authorization/auth.middleware.ts b/backend/src/authorization/auth.middleware.ts index 1b7dffc91..8a1f7eea1 100644 --- a/backend/src/authorization/auth.middleware.ts +++ b/backend/src/authorization/auth.middleware.ts @@ -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'; @@ -69,12 +69,7 @@ export class AuthMiddleware implements NestMiddleware { throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED); } - const addedScope: Array = data.scope; - if (addedScope && addedScope.length > 0) { - if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) { - throw new TwoFaRequiredException(); - } - } + assertTokenScopeAllowed(data.scope as Array); const payload = { sub: userId, diff --git a/backend/src/authorization/public-or-auth.middleware.ts b/backend/src/authorization/public-or-auth.middleware.ts index 5304afb54..5d66ee01f 100644 --- a/backend/src/authorization/public-or-auth.middleware.ts +++ b/backend/src/authorization/public-or-auth.middleware.ts @@ -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'; @@ -86,12 +86,7 @@ export class PublicOrAuthMiddleware implements NestMiddleware { throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED); } - const addedScope: Array = data.scope; - if (addedScope && addedScope.length > 0) { - if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) { - throw new TwoFaRequiredException(); - } - } + assertTokenScopeAllowed(data.scope as Array); const payload = { sub: userId, diff --git a/backend/src/entities/user/enums/jwt-scopes.enum.ts b/backend/src/entities/user/enums/jwt-scopes.enum.ts index 6d92ab326..4ee7d5a3e 100644 --- a/backend/src/entities/user/enums/jwt-scopes.enum.ts +++ b/backend/src/entities/user/enums/jwt-scopes.enum.ts @@ -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', } diff --git a/backend/src/entities/user/utils/assert-token-scope-allowed.ts b/backend/src/entities/user/utils/assert-token-scope-allowed.ts new file mode 100644 index 000000000..0bb29d824 --- /dev/null +++ b/backend/src/entities/user/utils/assert-token-scope-allowed.ts @@ -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 | null | undefined, + allowScopes: Array = [], +): 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(); + } +} diff --git a/backend/src/exceptions/custom-exceptions/custom-exceptions-internal-codes/exceptions-internal-codes.ts b/backend/src/exceptions/custom-exceptions/custom-exceptions-internal-codes/exceptions-internal-codes.ts index a3afa4733..c83629f14 100644 --- a/backend/src/exceptions/custom-exceptions/custom-exceptions-internal-codes/exceptions-internal-codes.ts +++ b/backend/src/exceptions/custom-exceptions/custom-exceptions-internal-codes/exceptions-internal-codes.ts @@ -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. */ diff --git a/backend/src/exceptions/custom-exceptions/email-verification-required-exception.ts b/backend/src/exceptions/custom-exceptions/email-verification-required-exception.ts new file mode 100644 index 000000000..71223d750 --- /dev/null +++ b/backend/src/exceptions/custom-exceptions/email-verification-required-exception.ts @@ -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, + }); + } +} diff --git a/backend/src/exceptions/text/messages.ts b/backend/src/exceptions/text/messages.ts index e88e7ac02..9ac4cf227 100644 --- a/backend/src/exceptions/text/messages.ts +++ b/backend/src/exceptions/text/messages.ts @@ -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`, diff --git a/backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts index 346d7bdd0..7e0eddb41 100644 --- a/backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts +++ b/backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts @@ -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'; @@ -63,12 +63,7 @@ export class ValidateUserTokenUseCase throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED); } - const addedScope: Array = data.scope; - if (addedScope && addedScope.length > 0) { - if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE) && !allow2faEnableScope) { - throw new TwoFaRequiredException(); - } - } + assertTokenScopeAllowed(data.scope as Array, allowScopes); return { sub: userId, diff --git a/backend/test/ava-tests/unit-tests/assert-token-scope-allowed.test.ts b/backend/test/ava-tests/unit-tests/assert-token-scope-allowed.test.ts new file mode 100644 index 000000000..93462749a --- /dev/null +++ b/backend/test/ava-tests/unit-tests/assert-token-scope-allowed.test.ts @@ -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])); +}); diff --git a/backend/test/ava-tests/unit-tests/validate-user-token-dto.test.ts b/backend/test/ava-tests/unit-tests/validate-user-token-dto.test.ts new file mode 100644 index 000000000..dc2549a42 --- /dev/null +++ b/backend/test/ava-tests/unit-tests/validate-user-token-dto.test.ts @@ -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): Array { + 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); +}); diff --git a/backend/test/ava-tests/unit-tests/validate-user-token.use.case.test.ts b/backend/test/ava-tests/unit-tests/validate-user-token.use.case.test.ts new file mode 100644 index 000000000..aeb209e57 --- /dev/null +++ b/backend/test/ava-tests/unit-tests/validate-user-token.use.case.test.ts @@ -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): 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); +});