Skip to content
Closed
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
12 changes: 6 additions & 6 deletions lib/utils/transport-error.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ function load(pkg: '@nestjs/websockets' | '@nestjs/microservices', name: string)
* The one place a denial becomes a response. `@Can()` (in the guard) and
* `authorize()` (an `AuthorizationError` leaving a handler) both come here.
* Every transport gets the body of Nest's own `UnauthorizedException` or
* `ForbiddenException`, like any other 401 or 403:
* `ForbiddenException`, with the denial reason as `errorCode`:
*
* | Context | Error | Client sees |
* | --- | --- | --- |
* | `http`, `graphql` | `UnauthorizedException` / `ForbiddenException` | `{"message":"Forbidden","statusCode":403}`, or the `UNAUTHENTICATED` / `FORBIDDEN` code |
* | `ws` | `WsException` | `{ status: 'error', message: 'Forbidden', statusCode: 403 }` |
* | `rpc` | `RpcException` | `{ message: 'Forbidden', statusCode: 403 }` |
* | `http`, `graphql` | `UnauthorizedException` / `ForbiddenException` | `{"message":"Forbidden","statusCode":403,"errorCode":"forbidden"}`; GraphQL keeps its `UNAUTHENTICATED` / `FORBIDDEN` code |
* | `ws` | `WsException` | `{ status: 'error', message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' }` |
* | `rpc` | `RpcException` | `{ message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' }` |
*
* The ws and rpc filters report `HttpException`s as "Internal server error",
* hence their own classes, loaded only when those packages are installed.
Expand All @@ -38,8 +38,8 @@ function load(pkg: '@nestjs/websockets' | '@nestjs/microservices', name: string)
export async function toTransportError(context: ExecutionContext, denial: AuthorizationError): Promise<Error> {
const exception =
denial.reason === 'unauthenticated'
? new UnauthorizedException(undefined, { cause: denial })
: new ForbiddenException(undefined, { cause: denial });
? new UnauthorizedException(undefined, { cause: denial, errorCode: denial.reason })
: new ForbiddenException(undefined, { cause: denial, errorCode: denial.reason });
const body = exception.getResponse() as object;

switch (context.getType<string>()) {
Expand Down
4 changes: 2 additions & 2 deletions tests/authorization.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { adapters, createApp } from './support/adapters.js';
import { AuthorizationEvents, type AuthorizationDeniedEvent } from '../lib/index.js';
import { AppModule, MisconfiguredAppModule, users } from './fixtures.js';

const UNAUTHORIZED = { message: 'Unauthorized', statusCode: 401 };
const FORBIDDEN = { message: 'Forbidden', statusCode: 403 };
const UNAUTHORIZED = { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' };
const FORBIDDEN = { message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' };

describe.each(adapters.map((a) => a.name))('authorization e2e (%s)', (adapter) => {
let app: INestApplication;
Expand Down
26 changes: 22 additions & 4 deletions tests/guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,11 @@ describe('AuthorizationGuard', () => {
.canActivate(httpContext(SteppingController, SteppingController.prototype.guarded, users.alice))
.catch((e: unknown) => e)) as HttpException;

expect(error.getResponse()).toEqual({ message: 'Forbidden', statusCode: 403 });
expect(error.getResponse()).toEqual({
message: 'Forbidden',
statusCode: 403,
errorCode: 'forbidden'
});
expect(error.cause).toBeInstanceOf(AuthorizationError);
expect(error.cause).toMatchObject({ policy: 'SteppingPolicy', ability: 'second', reason: 'forbidden' });
});
Expand Down Expand Up @@ -435,7 +439,12 @@ describe('toTransportError', () => {
const error = await toTransportError(contextOf('ws'), guest);

expect(error).toBeInstanceOf(WsException);
expect((error as WsException).getError()).toEqual({ status: 'error', message: 'Unauthorized', statusCode: 401 });
expect((error as WsException).getError()).toEqual({
status: 'error',
message: 'Unauthorized',
statusCode: 401,
errorCode: 'unauthenticated',
});
expect(error.cause).toBe(guest);
});

Expand All @@ -450,7 +459,11 @@ describe('toTransportError', () => {
const error = await toTransportError(contextOf('kafka-custom'), forbidden);

expect(error).toBeInstanceOf(ForbiddenException);
expect((error as HttpException).getResponse()).toEqual({ message: 'Forbidden', statusCode: 403 });
expect((error as HttpException).getResponse()).toEqual({
message: 'Forbidden',
statusCode: 403,
errorCode: 'forbidden'
});
});
});

Expand Down Expand Up @@ -493,7 +506,12 @@ describe('AuthorizationErrorInterceptor', () => {
).catch((e: unknown) => e);

expect(mapped).toBeInstanceOf(WsException);
expect((mapped as WsException).getError()).toEqual({ status: 'error', message: 'Forbidden', statusCode: 403 });
expect((mapped as WsException).getError()).toEqual({
status: 'error',
message: 'Forbidden',
statusCode: 403,
errorCode: 'forbidden',
});
});
});

Expand Down
14 changes: 11 additions & 3 deletions tests/http.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ describe.each(adapters.map((a) => a.name))('HTTP denials and failures (%s)', (ad
});

it('answers 401 when a @Can() ability dereferences a guest, and still checks signed-in users', async () => {
await request(app.getHttpServer()).get('/reports/view').expect(401, { message: 'Unauthorized', statusCode: 401 });
await request(app.getHttpServer()).get('/reports/view').expect(401, {
message: 'Unauthorized',
statusCode: 401,
errorCode: 'unauthenticated',
});
await request(app.getHttpServer()).get('/reports/view').set('x-user', 'alice').expect(403);
await request(app.getHttpServer()).get('/reports/view').set('x-user', 'eve').expect(200, { views: 1 });
});
Expand Down Expand Up @@ -170,11 +174,15 @@ describe.each(adapters.map((a) => a.name))('HTTP denials and failures (%s)', (ad
});

it('denies a route that declares no check, and names it at startup', async () => {
await request(app.getHttpServer()).get('/reports/forgotten').expect(401, { message: 'Unauthorized', statusCode: 401 });
await request(app.getHttpServer()).get('/reports/forgotten').expect(401, {
message: 'Unauthorized',
statusCode: 401,
errorCode: 'unauthenticated',
});
await request(app.getHttpServer())
.get('/reports/forgotten')
.set('x-user', 'root')
.expect(403, { message: 'Forbidden', statusCode: 403 });
.expect(403, { message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' });

expect(logged.map(({ cause }) => cause)).toEqual([
expect.objectContaining({ reason: 'unauthenticated', policy: null, ability: null }),
Expand Down
12 changes: 10 additions & 2 deletions tests/resilience.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,16 @@ describe.each(adapters.map((a) => a.name))('with @nestjs/resilience (%s)', (adap
});

it('answers a denial once, with its own status: no retry, no fallback', async () => {
await http().get('/reports/backup').query({ user: 'alice' }).expect(403, { message: 'Forbidden', statusCode: 403 });
await http().get('/reports/backup').expect(401, { message: 'Unauthorized', statusCode: 401 });
await http().get('/reports/backup').query({ user: 'alice' }).expect(403, {
message: 'Forbidden',
statusCode: 403,
errorCode: 'forbidden',
});
await http().get('/reports/backup').expect(401, {
message: 'Unauthorized',
statusCode: 401,
errorCode: 'unauthenticated',
});

expect(app.get(ReportsService).calls).toBe(2);
expect(events).toEqual([]);
Expand Down
56 changes: 37 additions & 19 deletions tests/transports.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,30 +119,32 @@ describe('WebSocket gateway (platform-ws)', () => {

expect(await ask('eve', 'create')).toEqual({
event: 'exception',
data: { status: 'error', message: 'Forbidden', statusCode: 403 },
data: { status: 'error', message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' },
});

expect(await ask(undefined, 'create')).toEqual({
event: 'exception',
data: { status: 'error', message: 'Unauthorized', statusCode: 401 },
data: { status: 'error', message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
});
});

it('authorize() denials in a service arrive the same way', async () => {
expect(await ask('bob', 'draft')).toEqual({ event: 'draft', data: 'Bob drafts' });
expect(await ask('alice', 'draft')).toEqual({
event: 'exception',
data: { status: 'error', message: 'Forbidden', statusCode: 403 },
data: { status: 'error', message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' },
});
});

it("carries the body of Nest's own ForbiddenException, after the ws filter's status", async () => {
expect(await send('eve', 'create')).toBe(
'{"event":"exception","data":{"status":"error","message":"Forbidden","statusCode":403}}',
'{"event":"exception","data":{"status":"error","message":"Forbidden","statusCode":403,"errorCode":"forbidden"}}',
);
const { data } = (await ask('eve', 'create')) as { data: Record<string, unknown> };
const { status: _, ...body } = data;
expect(JSON.stringify(body)).toBe(JSON.stringify(new ForbiddenException().getResponse()));
expect(JSON.stringify(body)).toBe(
JSON.stringify(new ForbiddenException(undefined, { errorCode: 'forbidden' }).getResponse()),
);
});
});

Expand Down Expand Up @@ -246,7 +248,10 @@ describe('WebSocket gateway behind per-message authentication (platform-ws)', ()
const next = () => (replies.length ? Promise.resolve(replies.shift()) : new Promise((resolve) => waiting.push(resolve)));
return { send, next, ask: (event: string, data?: object) => (send(event, data), next()) };
};
const unauthenticated = { event: 'exception', data: { status: 'error', message: 'Unauthorized', statusCode: 401 } };
const unauthenticated = {
event: 'exception',
data: { status: 'error', message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
};

beforeAll(async () => {
app = await createApp('express', RoomsAppModule, { setup: (a) => void a.useWebSocketAdapter(new WsAdapter(a)) });
Expand Down Expand Up @@ -349,19 +354,28 @@ describe('GraphQL (Apollo, express)', () => {

expect((await gql('{ create }', 'eve').expect(200)).body.errors[0]).toMatchObject({
message: 'Forbidden',
extensions: { code: 'FORBIDDEN' },
extensions: {
code: 'FORBIDDEN',
originalError: { message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' },
},
});

expect((await gql('{ create }').expect(200)).body.errors[0]).toMatchObject({
message: 'Unauthorized',
extensions: { code: 'UNAUTHENTICATED' },
extensions: {
code: 'UNAUTHENTICATED',
originalError: { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
},
});
});

it('authorize() denials in a service carry the same code', async () => {
expect((await gql('{ draft }').expect(200)).body.errors[0]).toMatchObject({
message: 'Unauthorized',
extensions: { code: 'UNAUTHENTICATED' },
extensions: {
code: 'UNAUTHENTICATED',
originalError: { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
},
});
});
});
Expand Down Expand Up @@ -634,18 +648,18 @@ describe('microservice handlers (rpc)', () => {
expect(await guard.canActivate(rpcContext(users.alice))).toBe(true);
const error = await guard.canActivate(rpcContext(users.eve)).catch((e) => e);
expect(error).toBeInstanceOf(RpcException);
expect(error.getError()).toEqual({ message: 'Forbidden', statusCode: 403 });
expect(error.getError()).toEqual({ message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' });
expect(error.cause).toMatchObject({ reason: 'forbidden', policy: 'PostPolicy', ability: 'create' });
});

it("carries the bodies of Nest's own UnauthorizedException and ForbiddenException, as over HTTP", async () => {
const forbidden = (await guard.canActivate(rpcContext(users.eve)).catch((e) => e)) as RpcException;
expect(JSON.stringify(forbidden.getError())).toBe('{"message":"Forbidden","statusCode":403}');
expect(forbidden.getError()).toEqual(new ForbiddenException().getResponse());
expect(JSON.stringify(forbidden.getError())).toBe('{"message":"Forbidden","statusCode":403,"errorCode":"forbidden"}');
expect(forbidden.getError()).toEqual(new ForbiddenException(undefined, { errorCode: 'forbidden' }).getResponse());

const guest = (await guard.canActivate(rpcContext(null)).catch((e) => e)) as RpcException;
expect(JSON.stringify(guest.getError())).toBe('{"message":"Unauthorized","statusCode":401}');
expect(guest.getError()).toEqual(new UnauthorizedException().getResponse());
expect(JSON.stringify(guest.getError())).toBe('{"message":"Unauthorized","statusCode":401,"errorCode":"unauthenticated"}');
expect(guest.getError()).toEqual(new UnauthorizedException(undefined, { errorCode: 'unauthenticated' }).getResponse());
});

it('an AuthorizationError leaving the handler becomes the same RpcException', async () => {
Expand All @@ -655,7 +669,7 @@ describe('microservice handlers (rpc)', () => {
});
const error = await lastValueFrom(result).catch((e: RpcException) => e);
expect(error).toBeInstanceOf(RpcException);
expect((error as RpcException).getError()).toEqual({ message: 'Unauthorized', statusCode: 401 });
expect((error as RpcException).getError()).toEqual({ message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' });
});

it('leaves other errors alone', async () => {
Expand Down Expand Up @@ -739,19 +753,21 @@ describe('microservice over TCP (a real transport)', () => {
it('@Can() reads the user the authentication guard left on the transport context', async () => {
expect(await send('refunds.approve', { orderId: 3, token: 'finance-token' })).toEqual({ reply: { approved: 3 } });
expect(await send('refunds.approve', { orderId: 3 })).toEqual({
error: { message: 'Unauthorized', statusCode: 401 },
error: { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
});
});

it('never takes the user from the message payload, which the producer writes', async () => {
const admin = { id: 1, roles: ['admin', 'finance'] };
expect(await send('refunds.approve', { orderId: 3, user: admin })).toEqual({
error: { message: 'Unauthorized', statusCode: 401 },
error: { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
});
});

it('authorize() denials in a service reach the client in the same shape', async () => {
expect(await send('drafts.show', {})).toEqual({ error: { message: 'Unauthorized', statusCode: 401 } });
expect(await send('drafts.show', {})).toEqual({
error: { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
});
});
});

Expand Down Expand Up @@ -802,7 +818,9 @@ describe('hybrid app (HTTP plus a connected microservice)', () => {
// Nest gives a connected microservice the app's global enhancers (APP_GUARD
// included) only with inheritAppConfig: true. The README says so.
it('enforces @Can() on message handlers with inheritAppConfig: true, and only then', async () => {
expect(await guestCall(true)).toEqual({ error: { message: 'Unauthorized', statusCode: 401 } });
expect(await guestCall(true)).toEqual({
error: { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
});
expect(await guestCall(false)).toEqual({ reply: 'approved' });
});
});
12 changes: 9 additions & 3 deletions tests/transports.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ describe('hybrid app: authorize() in message handlers', () => {

it('maps a service denial to an RpcException with inheritAppConfig: true', async () => {
running = await hybrid(HybridAppModule, true);
expect(await send(running.client, 'refunds.show')).toEqual({ error: { message: 'Unauthorized', statusCode: 401 } });
expect(await send(running.client, 'refunds.show')).toEqual({
error: { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
});
});

it('leaves it to the rpc filter without inheritAppConfig, which reports "Internal server error"', async () => {
Expand Down Expand Up @@ -163,8 +165,12 @@ describe('hybrid app: a getUser that trusts a gateway-verified user in RPC paylo

it('reads the user from the message payload over RPC', async () => {
expect(await send(running.client, 'refunds.approve', { user: finance })).toEqual({ reply: 'approved' });
expect(await send(running.client, 'refunds.approve', { user: users.alice })).toEqual({ error: { message: 'Forbidden', statusCode: 403 } });
expect(await send(running.client, 'refunds.approve')).toEqual({ error: { message: 'Unauthorized', statusCode: 401 } });
expect(await send(running.client, 'refunds.approve', { user: users.alice })).toEqual({
error: { message: 'Forbidden', statusCode: 403, errorCode: 'forbidden' },
});
expect(await send(running.client, 'refunds.approve')).toEqual({
error: { message: 'Unauthorized', statusCode: 401, errorCode: 'unauthenticated' },
});
});

it('delegates HTTP to defaultGetUser, which reads request.user and never a payload', async () => {
Expand Down