Merge pull request #996 from Tria-plc/freight/fix/auth-separation

feat(auth): add login audience middleware (EDRFREIGHT-415)
This commit is contained in:
Nathnael Wondisha
2026-07-28 13:52:42 +03:00
committed by GitHub
12 changed files with 216 additions and 26 deletions

View File

@@ -2,6 +2,7 @@ import {
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
RequestMethod,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
@@ -101,6 +102,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
@Module({
imports: [
@@ -240,6 +242,7 @@ import { LoggerMiddleware } from "./logger.middleware";
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -328,5 +331,9 @@ export class AppModule implements OnApplicationBootstrap {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
consumer.apply(LoginAudienceMiddleware).forRoutes(
{ path: "auth/login", method: RequestMethod.POST },
{ path: "auth/mfa-verify", method: RequestMethod.POST },
);
}
}

View File

@@ -46,6 +46,9 @@ async function bootstrap() {
"Accept",
"Authorization",
"X-Requested-With",
// Which freight frontend is calling — /auth/login uses this to reject
// cross-audience credentials (EDRFREIGHT-415).
"X-Client-App",
// IAM context headers required by @tria-plc/api-common's JwtGuard
"organization-unit-id",
"delegator-position-id",

View File

@@ -0,0 +1,92 @@
import { ForbiddenException } from '@nestjs/common';
import { LoginAudienceMiddleware } from './login-audience.middleware';
/**
* Touches only the DataSource, so build off the prototype rather than
* standing up a full Nest module — same pattern as
* warehouses/receive-export-paid.spec.ts.
*/
function makeMiddleware(userType: string | undefined) {
const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []);
const middleware = Object.create(
LoginAudienceMiddleware.prototype,
) as LoginAudienceMiddleware;
(middleware as unknown as { dataSource: unknown }).dataSource = { query };
return middleware;
}
function makeReq(clientApp: string | undefined, email = 'someone@example.com') {
return {
header: (name: string) =>
name.toLowerCase() === 'x-client-app' ? clientApp : undefined,
body: { email },
} as any;
}
describe('LoginAudienceMiddleware', () => {
it('rejects when the client app header is missing', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq(undefined), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects an unrecognized client app header', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('mobile'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects an employee account signing in through the portal client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('portal'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects a customer account signing in through the backoffice client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await expect(
middleware.use(makeReq('backoffice'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows an employee account through the backoffice client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await middleware.use(makeReq('backoffice'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('allows a customer account through the portal client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('lets an unknown identifier fall through to the login handler', async () => {
const middleware = makeMiddleware(undefined);
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,58 @@
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { NextFunction, Request, Response } from 'express';
export const CLIENT_APP_HEADER = 'x-client-app';
// EUserType values from @tria-plc/api-common, duplicated here to avoid
// pulling in the full enum just for this string comparison.
const ALLOWED_USER_TYPES_BY_CLIENT: Record<string, string[]> = {
backoffice: ['employee'],
portal: ['individual', 'external_organization'],
};
/**
* Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials
* against email/username/phone_number only (see vendor
* findUserForLogin), with no check that the account's userType belongs on
* the app that's asking. A backoffice (employee) client presenting a
* customer's credentials — or vice versa — must not get a session.
*/
@Injectable()
export class LoginAudienceMiddleware implements NestMiddleware {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async use(req: Request, _res: Response, next: NextFunction) {
const clientApp = req.header(CLIENT_APP_HEADER);
const allowedUserTypes = clientApp
? ALLOWED_USER_TYPES_BY_CLIENT[clientApp]
: undefined;
if (!allowedUserTypes) {
throw new ForbiddenException(
`Missing or unrecognized ${CLIENT_APP_HEADER} header`,
);
}
const identifier: unknown = req.body?.email;
if (typeof identifier !== 'string' || !identifier) {
// No identifier to look up — the vendor DTO validation rejects the
// request on its own.
return next();
}
const [user] = await this.dataSource.query(
`SELECT user_type AS "userType" FROM iam.users
WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`,
[identifier],
);
if (user && !allowedUserTypes.includes(user.userType)) {
throw new ForbiddenException(
`This account cannot sign in through the ${clientApp} application`,
);
}
next();
}
}

View File

@@ -76,6 +76,10 @@ api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`;
}
// Tells the backend which app is asking, so /auth/login can reject
// cross-audience credentials (EDRFREIGHT-415).
config.headers["X-Client-App"] = "backoffice";
return config;
});

View File

@@ -25,6 +25,9 @@ axiosInstance.interceptors.request.use((config) => {
}
// X-Requested-With prevents CSRF via browser-native form/fetch without custom headers
config.headers["X-Requested-With"] = "XMLHttpRequest";
// Tells the backend which app is asking, so /auth/login can reject
// cross-audience credentials (EDRFREIGHT-415).
config.headers["X-Client-App"] = "backoffice";
return config;
});

View File

@@ -39,6 +39,9 @@ client.interceptors.request.use((config) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Tells the backend which app is asking, so /auth/login can reject
// cross-audience credentials (EDRFREIGHT-415).
config.headers["X-Client-App"] = "portal";
return config;
});