diff --git a/lib/utils/transport-error.util.ts b/lib/utils/transport-error.util.ts index f594f33..0d33896 100644 --- a/lib/utils/transport-error.util.ts +++ b/lib/utils/transport-error.util.ts @@ -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. @@ -38,8 +38,8 @@ function load(pkg: '@nestjs/websockets' | '@nestjs/microservices', name: string) export async function toTransportError(context: ExecutionContext, denial: AuthorizationError): Promise { 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()) { diff --git a/tests/authorization.e2e.spec.ts b/tests/authorization.e2e.spec.ts index 70b3aee..fcc8e41 100644 --- a/tests/authorization.e2e.spec.ts +++ b/tests/authorization.e2e.spec.ts @@ -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; diff --git a/tests/guard.spec.ts b/tests/guard.spec.ts index b80e85c..4d1b8bd 100644 --- a/tests/guard.spec.ts +++ b/tests/guard.spec.ts @@ -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' }); }); @@ -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); }); @@ -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' + }); }); }); @@ -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', + }); }); }); diff --git a/tests/http.e2e.spec.ts b/tests/http.e2e.spec.ts index 37bbc5e..a9b7da0 100644 --- a/tests/http.e2e.spec.ts +++ b/tests/http.e2e.spec.ts @@ -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 }); }); @@ -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 }), diff --git a/tests/resilience.integration.spec.ts b/tests/resilience.integration.spec.ts index 75ce88b..b2d1c70 100644 --- a/tests/resilience.integration.spec.ts +++ b/tests/resilience.integration.spec.ts @@ -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([]); diff --git a/tests/transports.e2e.spec.ts b/tests/transports.e2e.spec.ts index a071109..9303335 100644 --- a/tests/transports.e2e.spec.ts +++ b/tests/transports.e2e.spec.ts @@ -119,12 +119,12 @@ 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' }, }); }); @@ -132,17 +132,19 @@ describe('WebSocket gateway (platform-ws)', () => { 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 }; 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()), + ); }); }); @@ -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)) }); @@ -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' }, + }, }); }); }); @@ -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 () => { @@ -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 () => { @@ -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' }, + }); }); }); @@ -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' }); }); }); diff --git a/tests/transports.integration.spec.ts b/tests/transports.integration.spec.ts index 86de390..56bab47 100644 --- a/tests/transports.integration.spec.ts +++ b/tests/transports.integration.spec.ts @@ -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 () => { @@ -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 () => {