Skip to content

Commit

Permalink
Add authentication for SSE endpoint (#95)
Browse files Browse the repository at this point in the history
- It can be set by `SSE_AUTH_TOKEN` environment variable. If not defined, it will be disabled
- Update tests
- Add info about endpoint to README

---------

Co-authored-by: Moisés Fernández <[email protected]>
  • Loading branch information
Uxio0 and moisses89 authored Oct 5, 2023
1 parent de772a7 commit 6f1d5dc
Show file tree
Hide file tree
Showing 7 changed files with 62 additions and 9 deletions.
3 changes: 2 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ ADMIN_EMAIL=admin@safe
ADMIN_PASSWORD=password
WEBHOOKS_CACHE_TTL=300000
NODE_ENV=dev
#URL_BASE_PATH=/test # Set a globlal url path
SSE_AUTH_TOKEN=aW5mcmFAc2FmZS5nbG9iYWw6YWJjMTIz
# URL_BASE_PATH=/test # Set a globlal url path
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ Some parameters are common to every event:
}
```

### Message created/confirmed
### Message created/confirmed

```json
{
"address": "<Ethereum checksummed address>",
Expand Down Expand Up @@ -147,11 +148,13 @@ cp .env.sample .env
```

Simple way:

```bash
bash ./scripts/run_tests.sh
```

Manual way:

```bash
docker compose down
docker compose up -d rabbitmq db db-migrations
Expand Down Expand Up @@ -182,3 +185,5 @@ Available endpoints:

- /health/ -> Check health for the service.
- /admin/ -> Admin panel to edit database models.
- /events/sse/{CHECKSUMMED_SAFE_ADDRESS} -> Server side events endpoint. If `SSE_AUTH_TOKEN` is defined then authentication
will be enabled and header `Authorization: Basic $SSE_AUTH_TOKEN` must be added to the request.
16 changes: 16 additions & 0 deletions src/auth/basic-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class BasicAuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}

canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.configService.get('SSE_AUTH_TOKEN', '');
// If token is not set, authentication is disabled
return (
token === '' || request.headers['authorization'] === `Basic ${token}`
);
}
}
4 changes: 3 additions & 1 deletion src/routes/events/events.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { QueueProvider } from '../../datasources/queue/queue.provider';
import { WebhookService } from '../webhook/webhook.service';
import { firstValueFrom } from 'rxjs';
import { TxServiceEvent, TxServiceEventType } from './event.dto';
import { BadRequestException } from '@nestjs/common';

describe('EventsController', () => {
let controller: EventsController;
let service: EventsService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
controllers: [EventsController],
providers: [EventsService, QueueProvider, WebhookService],
})
Expand Down
12 changes: 11 additions & 1 deletion src/routes/events/events.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { BadRequestException, Controller, Param, Sse } from '@nestjs/common';
import {
BadRequestException,
Controller,
Param,
Sse,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Observable } from 'rxjs';
import { EventsService } from './events.service';
import { getAddress, isAddress } from 'ethers';
import { BasicAuthGuard } from '../../auth/basic-auth.guard';

@ApiTags('events')
@Controller('events')
export class EventsController {
constructor(private readonly eventsService: EventsService) {}

@UseGuards(BasicAuthGuard)
@Sse('/sse/:safe')
sse(@Param('safe') safe: string): Observable<MessageEvent> {
if (isAddress(safe) && getAddress(safe) === safe)
Expand Down
3 changes: 2 additions & 1 deletion src/routes/events/events.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { QueueModule } from '../../datasources/queue/queue.module';
import { WebhookModule } from '../webhook/webhook.module';
import { ConfigModule } from '@nestjs/config';

@Module({
imports: [QueueModule, WebhookModule],
imports: [QueueModule, WebhookModule, ConfigModule],
controllers: [EventsController],
providers: [EventsService],
})
Expand Down
26 changes: 22 additions & 4 deletions test/app.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,21 @@ describe('AppController (e2e)', () => {
});

describe('/events/sse/:safe (GET)', () => {
const validSafeAddress = '0x8618ce407F169ABB1388348A19632AaFA857CCB9';
const notValidAddress = '0x8618CE407F169ABB1388348A19632AaFA857CCB9';
const sseAuthToken = 'aW5mcmFAc2FmZS5nbG9iYWw6YWJjMTIz';
const sseAuthorizationHeader = `Basic ${sseAuthToken}`;

it('should be protected by an Authorization token', () => {
const url = `/events/sse/${validSafeAddress}`;
const expected = {
statusCode: 403,
message: 'Forbidden resource',
error: 'Forbidden',
};
return request(app.getHttpServer()).get(url).expect(403).expect(expected);
});
it('should subscribe and receive Server Side Events', () => {
const validSafeAddress = '0x8618ce407F169ABB1388348A19632AaFA857CCB9';
const msg = {
chainId: '1',
type: 'SAFE_CREATED' as TxServiceEventType,
Expand All @@ -60,7 +73,9 @@ describe('AppController (e2e)', () => {
const protocol = server instanceof Server ? 'https' : 'http';
const url = protocol + '://127.1.0.1:' + port + path;

const eventSource = new EventSource(url);
const eventSource = new EventSource(url, {
headers: { Authorization: sseAuthorizationHeader },
});
// Use an empty promise so test has to wait for it, and do the cleanup there
const messageReceived = new Promise((resolve) => {
eventSource.onmessage = (event) => {
Expand All @@ -82,14 +97,17 @@ describe('AppController (e2e)', () => {
return messageReceived;
});
it('should return a 400 if safe address is not EIP55 valid', () => {
const notValidAddress = '0x8618CE407F169ABB1388348A19632AaFA857CCB9';
const url = `/events/sse/${notValidAddress}`;
const expected = {
statusCode: 400,
message: 'Not valid EIP55 address',
error: `${notValidAddress} is not a valid EIP55 Safe address`,
};
return request(app.getHttpServer()).get(url).expect(400).expect(expected);
return request(app.getHttpServer())
.get(url)
.set('Authorization', sseAuthorizationHeader)
.expect(400)
.expect(expected);
});
});
});

0 comments on commit 6f1d5dc

Please sign in to comment.