Skip to content

Commit c3a3ed8

Browse files
feat(auth): refuse an 'email_verify'-scoped token everywhere it is not the point (#1871)
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) <noreply@anthropic.com>
1 parent 3426b3d commit c3a3ed8

12 files changed

Lines changed: 224 additions & 28 deletions

File tree

‎backend/src/authorization/auth-with-api.middleware.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import jwt from 'jsonwebtoken';
1313
import { Repository } from 'typeorm';
1414
import { JwtScopesEnum } from '../entities/user/enums/jwt-scopes.enum.js';
1515
import { UserEntity } from '../entities/user/user.entity.js';
16+
import { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.js';
1617
import { EncryptionAlgorithmEnum } from '../enums/encryption-algorithm.enum.js';
17-
import { TwoFaRequiredException } from '../exceptions/custom-exceptions/two-fa-required-exception.js';
1818
import { Messages } from '../exceptions/text/messages.js';
1919
import { Constants } from '../helpers/constants/constants.js';
2020
import { Encryptor } from '../helpers/encryption/encryptor.js';
@@ -81,12 +81,7 @@ export class AuthWithApiMiddleware implements NestMiddleware {
8181
throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED);
8282
}
8383

84-
const addedScope: Array<JwtScopesEnum> = data.scope;
85-
if (addedScope && addedScope.length > 0) {
86-
if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) {
87-
throw new TwoFaRequiredException();
88-
}
89-
}
84+
assertTokenScopeAllowed(data.scope as Array<JwtScopesEnum>);
9085

9186
const payload = {
9287
sub: userId,

‎backend/src/authorization/auth.middleware.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Repository } from 'typeorm';
1313
import { LogOutEntity } from '../entities/log-out/log-out.entity.js';
1414
import { JwtScopesEnum } from '../entities/user/enums/jwt-scopes.enum.js';
1515
import { UserEntity } from '../entities/user/user.entity.js';
16-
import { TwoFaRequiredException } from '../exceptions/custom-exceptions/two-fa-required-exception.js';
16+
import { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.js';
1717
import { Messages } from '../exceptions/text/messages.js';
1818
import { isTest } from '../helpers/app/is-test.js';
1919
import { Constants } from '../helpers/constants/constants.js';
@@ -69,12 +69,7 @@ export class AuthMiddleware implements NestMiddleware {
6969
throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED);
7070
}
7171

72-
const addedScope: Array<JwtScopesEnum> = data.scope;
73-
if (addedScope && addedScope.length > 0) {
74-
if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) {
75-
throw new TwoFaRequiredException();
76-
}
77-
}
72+
assertTokenScopeAllowed(data.scope as Array<JwtScopesEnum>);
7873

7974
const payload = {
8075
sub: userId,

‎backend/src/authorization/public-or-auth.middleware.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import jwt from 'jsonwebtoken';
1313
import { Repository } from 'typeorm';
1414
import { JwtScopesEnum } from '../entities/user/enums/jwt-scopes.enum.js';
1515
import { UserEntity } from '../entities/user/user.entity.js';
16+
import { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.js';
1617
import { EncryptionAlgorithmEnum } from '../enums/encryption-algorithm.enum.js';
17-
import { TwoFaRequiredException } from '../exceptions/custom-exceptions/two-fa-required-exception.js';
1818
import { Messages } from '../exceptions/text/messages.js';
1919
import { Constants } from '../helpers/constants/constants.js';
2020
import { Encryptor } from '../helpers/encryption/encryptor.js';
@@ -86,12 +86,7 @@ export class PublicOrAuthMiddleware implements NestMiddleware {
8686
throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED);
8787
}
8888

89-
const addedScope: Array<JwtScopesEnum> = data.scope;
90-
if (addedScope && addedScope.length > 0) {
91-
if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE)) {
92-
throw new TwoFaRequiredException();
93-
}
94-
}
89+
assertTokenScopeAllowed(data.scope as Array<JwtScopesEnum>);
9590

9691
const payload = {
9792
sub: userId,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
export enum JwtScopesEnum {
22
TWO_FA_ENABLE = '2fa_enable',
3+
// Session restricted to finishing email confirmation. Registration (and login of an
4+
// unconfirmed user) in rocketadmin-saas issues a token carrying this, and it is refused
5+
// everywhere except the routes that complete the flow — see assertTokenScopeAllowed.
6+
EMAIL_VERIFY = 'email_verify',
37
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { EmailVerificationRequiredException } from '../../../exceptions/custom-exceptions/email-verification-required-exception.js';
2+
import { TwoFaRequiredException } from '../../../exceptions/custom-exceptions/two-fa-required-exception.js';
3+
import { JwtScopesEnum } from '../enums/jwt-scopes.enum.js';
4+
5+
// A scope on an end-user token means the session is restricted to the flow that finishes it:
6+
// '2fa_enable' until the user enrols in company-mandated 2FA, 'email_verify' until the user
7+
// confirms their email. Every auth path funnels through here so a restricted token is refused
8+
// everywhere except the routes that complete its flow — those pass the scope in `allowScopes`.
9+
//
10+
// An unrecognized scope is ignored, preserving the behavior of the `includes(TWO_FA_ENABLE)`
11+
// checks this replaced: a token minted by a newer service must not lock a user out of an older one.
12+
export function assertTokenScopeAllowed(
13+
tokenScope: Array<JwtScopesEnum> | null | undefined,
14+
allowScopes: Array<JwtScopesEnum | string> = [],
15+
): void {
16+
if (!tokenScope || tokenScope.length === 0) {
17+
return;
18+
}
19+
if (tokenScope.includes(JwtScopesEnum.TWO_FA_ENABLE) && !allowScopes.includes(JwtScopesEnum.TWO_FA_ENABLE)) {
20+
throw new TwoFaRequiredException();
21+
}
22+
if (tokenScope.includes(JwtScopesEnum.EMAIL_VERIFY) && !allowScopes.includes(JwtScopesEnum.EMAIL_VERIFY)) {
23+
throw new EmailVerificationRequiredException();
24+
}
25+
}

‎backend/src/exceptions/custom-exceptions/custom-exceptions-internal-codes/exceptions-internal-codes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export enum ExceptionsInternalCodes {
4444
MASTER_PASSWORD_INCORRECT = 1201,
4545
/** Two-factor authentication is required to continue. */
4646
TWO_FA_REQUIRED = 1202,
47+
/** The account's email address has not been confirmed yet. */
48+
EMAIL_VERIFICATION_REQUIRED = 1203,
4749

4850
// --- 1300–1399: Not found ---
4951
/** Connection entity was not found. */
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { HttpStatus } from '@nestjs/common';
2+
import { Messages } from '../text/messages.js';
3+
import { BaseRocketAdminException } from './base-rocketadmin.exception.js';
4+
import { ExceptionsInternalCodes } from './custom-exceptions-internal-codes/exceptions-internal-codes.js';
5+
6+
export class EmailVerificationRequiredException extends BaseRocketAdminException {
7+
constructor() {
8+
super(Messages.EMAIL_VERIFICATION_REQUIRED, HttpStatus.BAD_REQUEST, {
9+
internalCode: ExceptionsInternalCodes.EMAIL_VERIFICATION_REQUIRED,
10+
});
11+
}
12+
}

‎backend/src/exceptions/text/messages.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ export const Messages = {
326326
EMAIL_SYNTAX_INVALID: 'Email syntax is invalid',
327327
EMAIL_NOT_CONFIRMED: 'Email is not confirmed',
328328
EMAIL_VERIFICATION_FAILED: 'Email verification failed',
329+
EMAIL_VERIFICATION_REQUIRED: `Please confirm your email address to continue. Enter the code we emailed you.`,
329330
EMAIL_VERIFIED_SUCCESSFULLY: 'Email verified successfully',
330331
EMAIL_CHANGE_REQUESTED_SUCCESSFULLY: `Email change request was requested successfully`,
331332
EMAIL_CHANGE_REQUESTED: `Email change request was requested`,

‎backend/src/microservices/agents-microservice/use-cases/validate-user-token.use.case.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import AbstractUseCase from '../../../common/abstract-use.case.js';
55
import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js';
66
import { BaseType } from '../../../common/data-injection.tokens.js';
77
import { JwtScopesEnum } from '../../../entities/user/enums/jwt-scopes.enum.js';
8-
import { TwoFaRequiredException } from '../../../exceptions/custom-exceptions/two-fa-required-exception.js';
8+
import { assertTokenScopeAllowed } from '../../../entities/user/utils/assert-token-scope-allowed.js';
99
import { Messages } from '../../../exceptions/text/messages.js';
1010
import { appConfig } from '../../../shared/config/app-config.js';
1111
import { ValidateUserTokenDs } from '../data-structures/agents.ds.js';
@@ -63,12 +63,7 @@ export class ValidateUserTokenUseCase
6363
throw new UnauthorizedException(Messages.ACCOUNT_SUSPENDED);
6464
}
6565

66-
const addedScope: Array<JwtScopesEnum> = data.scope;
67-
if (addedScope && addedScope.length > 0) {
68-
if (addedScope.includes(JwtScopesEnum.TWO_FA_ENABLE) && !allow2faEnableScope) {
69-
throw new TwoFaRequiredException();
70-
}
71-
}
66+
assertTokenScopeAllowed(data.scope as Array<JwtScopesEnum>, allowScopes);
7267

7368
return {
7469
sub: userId,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { HttpStatus } from '@nestjs/common';
2+
import test from 'ava';
3+
import { JwtScopesEnum } from '../../../src/entities/user/enums/jwt-scopes.enum.js';
4+
import { assertTokenScopeAllowed } from '../../../src/entities/user/utils/assert-token-scope-allowed.js';
5+
import { ExceptionsInternalCodes } from '../../../src/exceptions/custom-exceptions/custom-exceptions-internal-codes/exceptions-internal-codes.js';
6+
import { EmailVerificationRequiredException } from '../../../src/exceptions/custom-exceptions/email-verification-required-exception.js';
7+
import { TwoFaRequiredException } from '../../../src/exceptions/custom-exceptions/two-fa-required-exception.js';
8+
9+
// The single place every auth path asks "is this restricted token allowed here?".
10+
// A scope on a token means the session is limited to the flow that finishes it —
11+
// '2fa_enable' until the user enrols in 2FA, 'email_verify' until the user confirms
12+
// their email — so it must be refused everywhere except the routes that complete it.
13+
14+
test('a token with no scope claim is allowed', (t) => {
15+
t.notThrows(() => assertTokenScopeAllowed(undefined));
16+
t.notThrows(() => assertTokenScopeAllowed(null));
17+
t.notThrows(() => assertTokenScopeAllowed([]));
18+
});
19+
20+
test("'2fa_enable' is refused when the caller allows no scopes", (t) => {
21+
const error = t.throws(() => assertTokenScopeAllowed([JwtScopesEnum.TWO_FA_ENABLE]), {
22+
instanceOf: TwoFaRequiredException,
23+
});
24+
t.is(error.getStatus(), HttpStatus.BAD_REQUEST);
25+
t.is(error.internalCode, ExceptionsInternalCodes.TWO_FA_REQUIRED);
26+
});
27+
28+
test("'2fa_enable' is allowed when the caller allows it (2FA-enrolment routes)", (t) => {
29+
t.notThrows(() => assertTokenScopeAllowed([JwtScopesEnum.TWO_FA_ENABLE], [JwtScopesEnum.TWO_FA_ENABLE]));
30+
});
31+
32+
test("'email_verify' is refused when the caller allows no scopes", (t) => {
33+
const error = t.throws(() => assertTokenScopeAllowed([JwtScopesEnum.EMAIL_VERIFY]), {
34+
instanceOf: EmailVerificationRequiredException,
35+
});
36+
t.is(error.getStatus(), HttpStatus.BAD_REQUEST);
37+
t.is(error.internalCode, ExceptionsInternalCodes.EMAIL_VERIFICATION_REQUIRED);
38+
});
39+
40+
test("'email_verify' is allowed when the caller allows it (the verify-code routes)", (t) => {
41+
t.notThrows(() => assertTokenScopeAllowed([JwtScopesEnum.EMAIL_VERIFY], [JwtScopesEnum.EMAIL_VERIFY]));
42+
});
43+
44+
test('allowing one scope does not allow a different one', (t) => {
45+
t.throws(() => assertTokenScopeAllowed([JwtScopesEnum.EMAIL_VERIFY], [JwtScopesEnum.TWO_FA_ENABLE]), {
46+
instanceOf: EmailVerificationRequiredException,
47+
});
48+
t.throws(() => assertTokenScopeAllowed([JwtScopesEnum.TWO_FA_ENABLE], [JwtScopesEnum.EMAIL_VERIFY]), {
49+
instanceOf: TwoFaRequiredException,
50+
});
51+
});
52+
53+
test('a token carrying both scopes is refused until both are allowed', (t) => {
54+
const scopes = [JwtScopesEnum.TWO_FA_ENABLE, JwtScopesEnum.EMAIL_VERIFY];
55+
t.throws(() => assertTokenScopeAllowed(scopes, [JwtScopesEnum.TWO_FA_ENABLE]), {
56+
instanceOf: EmailVerificationRequiredException,
57+
});
58+
t.notThrows(() => assertTokenScopeAllowed(scopes, scopes));
59+
});
60+
61+
// Forward compatibility: an unrecognized scope keeps the pre-existing behavior of
62+
// every call site (the old `includes(TWO_FA_ENABLE)` check ignored anything else),
63+
// so a token minted by a newer service never locks a user out of an older one.
64+
test('an unrecognized scope is ignored', (t) => {
65+
t.notThrows(() => assertTokenScopeAllowed(['some_future_scope' as JwtScopesEnum]));
66+
});

0 commit comments

Comments
 (0)