Skip to content

Commit c24e6e7

Browse files
authored
Merge pull request #1859 from rocket-admin/backend_company_id_in_jwt
feat(saas): implement user logout and token validation endpoints with corresponding tests
2 parents 5cb9ed0 + 52ea010 commit c24e6e7

3 files changed

Lines changed: 214 additions & 0 deletions

File tree

backend/src/microservices/saas-microservice/saas.controller.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ import { CreatedConnectionDTO } from '../../entities/connection/application/dto/
2121
import { SaasUsualUserRegisterDS } from '../../entities/user/application/data-structures/usual-register-user.ds.js';
2222
import { FoundUserDto } from '../../entities/user/dto/found-user.dto.js';
2323
import { ExternalRegistrationProviderEnum } from '../../entities/user/enums/external-registration-provider.enum.js';
24+
import { ILogOut } from '../../entities/user/use-cases/user-use-cases.interfaces.js';
2425
import { UserEntity } from '../../entities/user/user.entity.js';
2526
import { InTransactionEnum } from '../../enums/in-transaction.enum.js';
2627
import { Messages } from '../../exceptions/text/messages.js';
2728
import { ValidationHelper } from '../../helpers/validators/validation-helper.js';
2829
import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js';
30+
import { ValidatedUserTokenRO } from '../agents-microservice/data-structures/agents-responses.ds.js';
31+
import { ValidateUserTokenDto } from '../agents-microservice/dto/agents-auth.dtos.js';
32+
import { IValidateUserToken } from '../agents-microservice/use-cases/agents-use-cases.interface.js';
2933
import { CreatedConnectionResponse, SuccessResponse } from './data-structures/common-responce.ds.js';
3034
import { CreateConnectionForHostedDbDto } from './data-structures/create-connecttion-for-selfhosted-db.dto.js';
3135
import { DeleteConnectionForHostedDbDto } from './data-structures/delete-connection-for-hosted-db.dto.js';
@@ -72,6 +76,10 @@ import {
7276
@Injectable()
7377
export class SaasController {
7478
constructor(
79+
@Inject(UseCaseType.LOG_OUT)
80+
private readonly logOutUseCase: ILogOut,
81+
@Inject(UseCaseType.AGENTS_VALIDATE_USER_TOKEN)
82+
private readonly validateUserTokenUseCase: IValidateUserToken,
7583
@Inject(UseCaseType.SAAS_COMPANY_REGISTRATION)
7684
private readonly companyRegistrationUseCase: ICompanyRegistration,
7785
@Inject(UseCaseType.SAAS_GET_USER_INFO)
@@ -178,6 +186,33 @@ export class SaasController {
178186
return await this.usualRegisterUserUseCase.execute({ email, password, gclidValue, name, companyId, companyName });
179187
}
180188

189+
@ApiOperation({ summary: 'Validate an end-user JWT on behalf of the SaaS service' })
190+
@ApiResponse({
191+
status: 201,
192+
description: 'Token is valid (signature, logout blacklist, user existence, suspension, 2FA scope).',
193+
type: ValidatedUserTokenRO,
194+
})
195+
@ApiBody({ type: ValidateUserTokenDto })
196+
@Post('user/validate-token')
197+
async validateUserToken(@Body() body: ValidateUserTokenDto): Promise<ValidatedUserTokenRO> {
198+
return await this.validateUserTokenUseCase.execute(body.token, InTransactionEnum.OFF);
199+
}
200+
201+
@ApiOperation({ summary: 'User logout webhook — blacklist an end-user JWT issued by the SaaS service' })
202+
@ApiResponse({
203+
status: 201,
204+
description: 'Token added to the logout blacklist; every later validation of it fails.',
205+
type: SuccessResponse,
206+
})
207+
@Post('user/logout')
208+
async usualUserLogout(@Body('token') token: string): Promise<SuccessResponse> {
209+
if (!token || typeof token !== 'string') {
210+
throw new BadRequestException(Messages.TOKEN_MISSING);
211+
}
212+
const success = await this.logOutUseCase.execute(token, InTransactionEnum.ON);
213+
return { success };
214+
}
215+
181216
@ApiOperation({ summary: 'User login webhook' })
182217
@ApiResponse({
183218
status: 200,

backend/src/microservices/saas-microservice/saas.module.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
33
import { SaaSAuthMiddleware } from '../../authorization/saas-auth.middleware.js';
44
import { GlobalDatabaseContext } from '../../common/application/global-database-context.js';
55
import { BaseType, UseCaseType } from '../../common/data-injection.tokens.js';
6+
import { LogOutUseCase } from '../../entities/user/use-cases/log-out.use.case.js';
67
import { UserEntity } from '../../entities/user/user.entity.js';
78
import { SignInAuditEntity } from '../../entities/user-sign-in-audit/sign-in-audit.entity.js';
89
import { SignInAuditService } from '../../entities/user-sign-in-audit/sign-in-audit.service.js';
10+
import { ValidateUserTokenUseCase } from '../agents-microservice/use-cases/validate-user-token.use.case.js';
911
import { SaasController } from './saas.controller.js';
1012
import { CreateConnectionForHostedDbUseCase } from './use-cases/create-connection-for-hosted-db.use.case.js';
1113
import { DeleteConnectionForHostedDbUseCase } from './use-cases/delete-connection-for-hosted-db.use.case.js';
@@ -36,6 +38,14 @@ import { UpdateHostedConnectionPasswordUseCase } from './use-cases/update-hosted
3638
provide: BaseType.GLOBAL_DB_CONTEXT,
3739
useClass: GlobalDatabaseContext,
3840
},
41+
{
42+
provide: UseCaseType.LOG_OUT,
43+
useClass: LogOutUseCase,
44+
},
45+
{
46+
provide: UseCaseType.AGENTS_VALIDATE_USER_TOKEN,
47+
useClass: ValidateUserTokenUseCase,
48+
},
3949
{
4050
provide: UseCaseType.SAAS_COMPANY_REGISTRATION,
4151
useClass: RegisteredCompanyWebhookUseCase,
@@ -135,6 +145,8 @@ export class SaasModule {
135145
{ path: 'saas/users/email/:userEmail', method: RequestMethod.GET },
136146
{ path: 'saas/user/register', method: RequestMethod.POST },
137147
{ path: 'saas/user/login', method: RequestMethod.POST },
148+
{ path: 'saas/user/logout', method: RequestMethod.POST },
149+
{ path: 'saas/user/validate-token', method: RequestMethod.POST },
138150
{ path: 'saas/company/my/email/:email', method: RequestMethod.GET },
139151
{ path: 'saas/user/demo/register', method: RequestMethod.POST },
140152
{ path: 'saas/user/google/login', method: RequestMethod.POST },
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/* eslint-disable @typescript-eslint/no-unused-vars */
2+
import { faker } from '@faker-js/faker';
3+
import { INestApplication, ValidationPipe } from '@nestjs/common';
4+
import { Test } from '@nestjs/testing';
5+
import test from 'ava';
6+
import { ValidationError } from 'class-validator';
7+
import cookieParser from 'cookie-parser';
8+
import jwt from 'jsonwebtoken';
9+
import request from 'supertest';
10+
import { ApplicationModule } from '../../../src/app.module.js';
11+
import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js';
12+
import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js';
13+
import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js';
14+
import { Cacher } from '../../../src/helpers/cache/cacher.js';
15+
import { appConfig } from '../../../src/shared/config/app-config.js';
16+
import { DatabaseModule } from '../../../src/shared/database/database.module.js';
17+
import { DatabaseService } from '../../../src/shared/database/database.service.js';
18+
import {
19+
createInitialTestUser,
20+
registerUserAndReturnUserInfo,
21+
} from '../../utils/register-user-and-return-user-info.js';
22+
import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js';
23+
import { TestUtils } from '../../utils/test.utils.js';
24+
25+
let app: INestApplication;
26+
let _testUtils: TestUtils;
27+
let currentTest;
28+
29+
// Microservice JWT (request_id claim) for the internal saas bridge controller.
30+
function microserviceAuthHeader(): string {
31+
const secret = appConfig.auth.microserviceJwtSecret as string;
32+
return `Bearer ${jwt.sign({ request_id: faker.string.uuid() }, secret, { expiresIn: '1h' })}`;
33+
}
34+
35+
test.before(async () => {
36+
setSaasEnvVariable();
37+
const moduleFixture = await Test.createTestingModule({
38+
imports: [ApplicationModule, DatabaseModule],
39+
providers: [DatabaseService, TestUtils],
40+
}).compile();
41+
app = moduleFixture.createNestApplication();
42+
_testUtils = moduleFixture.get<TestUtils>(TestUtils);
43+
44+
app.use(cookieParser());
45+
app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger)));
46+
app.useGlobalPipes(
47+
new ValidationPipe({
48+
exceptionFactory(validationErrors: ValidationError[] = []) {
49+
return new ValidationException(validationErrors);
50+
},
51+
}),
52+
);
53+
await app.init();
54+
await createInitialTestUser(app);
55+
app.getHttpServer().listen(0);
56+
});
57+
58+
test.after(async () => {
59+
try {
60+
await Cacher.clearAllCache();
61+
await app.close();
62+
} catch (e) {
63+
console.error('After tests error ' + e);
64+
}
65+
});
66+
67+
async function loginAndReturnCookieToken(): Promise<string> {
68+
const { email, password } = await registerUserAndReturnUserInfo(app);
69+
const loginUserResult = await request(app.getHttpServer())
70+
.post('/user/login/')
71+
.send({ email, password })
72+
.set('Content-Type', 'application/json')
73+
.set('Accept', 'application/json');
74+
return loginUserResult.headers['set-cookie'][0].split(';')[0].replace('rocketadmin_cookie=', '');
75+
}
76+
77+
function logoutRequest(body: Record<string, unknown>, withAuth = true): request.Test {
78+
const req = request(app.getHttpServer())
79+
.post('/saas/user/logout')
80+
.send(body)
81+
.set('Content-Type', 'application/json')
82+
.set('Accept', 'application/json');
83+
return withAuth ? req.set('Authorization', microserviceAuthHeader()) : req;
84+
}
85+
86+
currentTest = 'POST /saas/user/logout (internal, microservice JWT)';
87+
88+
test.serial(`${currentTest} blacklists the token so later validations fail`, async (t) => {
89+
const cookieToken = await loginAndReturnCookieToken();
90+
91+
// The token is valid before logout.
92+
const userBefore = await request(app.getHttpServer())
93+
.get('/user')
94+
.set('Cookie', `rocketadmin_cookie=${cookieToken}`)
95+
.set('Accept', 'application/json');
96+
t.is(userBefore.status, 200);
97+
98+
const logoutResult = await logoutRequest({ token: cookieToken });
99+
t.is(logoutResult.status, 201);
100+
t.deepEqual(JSON.parse(logoutResult.text), { success: true });
101+
102+
// The core's own auth middleware rejects the logged-out token.
103+
const userAfter = await request(app.getHttpServer())
104+
.get('/user')
105+
.set('Cookie', `rocketadmin_cookie=${cookieToken}`)
106+
.set('Accept', 'application/json');
107+
t.is(userAfter.status, 401);
108+
109+
// The agents-microservice validation chain rejects it too.
110+
const validateResult = await request(app.getHttpServer())
111+
.post('/internal/agents/auth/validate-user-token')
112+
.send({ token: cookieToken })
113+
.set('Authorization', microserviceAuthHeader())
114+
.set('Content-Type', 'application/json')
115+
.set('Accept', 'application/json');
116+
t.is(validateResult.status, 401);
117+
118+
// And the saas-microservice validation bridge (used by the saas AuthMiddleware).
119+
const saasValidateResult = await request(app.getHttpServer())
120+
.post('/saas/user/validate-token')
121+
.send({ token: cookieToken })
122+
.set('Authorization', microserviceAuthHeader())
123+
.set('Content-Type', 'application/json')
124+
.set('Accept', 'application/json');
125+
t.is(saasValidateResult.status, 401);
126+
});
127+
128+
test.serial(`${currentTest} rejects calls without a token in the body (400)`, async (t) => {
129+
const response = await logoutRequest({});
130+
t.is(response.status, 400);
131+
});
132+
133+
test.serial(`${currentTest} rejects calls without the microservice JWT (401)`, async (t) => {
134+
const cookieToken = await loginAndReturnCookieToken();
135+
const response = await logoutRequest({ token: cookieToken }, false);
136+
t.is(response.status, 401);
137+
});
138+
139+
currentTest = 'POST /saas/user/validate-token (internal, microservice JWT)';
140+
141+
test.serial(`${currentTest} returns the token claims (incl. companyId) for a live token`, async (t) => {
142+
const cookieToken = await loginAndReturnCookieToken();
143+
144+
const response = await request(app.getHttpServer())
145+
.post('/saas/user/validate-token')
146+
.send({ token: cookieToken })
147+
.set('Authorization', microserviceAuthHeader())
148+
.set('Content-Type', 'application/json')
149+
.set('Accept', 'application/json');
150+
t.is(response.status, 201);
151+
const ro = JSON.parse(response.text);
152+
const decoded = jwt.decode(cookieToken) as jwt.JwtPayload;
153+
t.is(ro.sub, decoded.id);
154+
t.is(ro.companyId, decoded.companyId);
155+
t.truthy(ro.companyId);
156+
});
157+
158+
test.serial(`${currentTest} rejects unauthenticated calls (401)`, async (t) => {
159+
const cookieToken = await loginAndReturnCookieToken();
160+
161+
const response = await request(app.getHttpServer())
162+
.post('/saas/user/validate-token')
163+
.send({ token: cookieToken })
164+
.set('Content-Type', 'application/json')
165+
.set('Accept', 'application/json');
166+
t.is(response.status, 401);
167+
});

0 commit comments

Comments
 (0)