Skip to content

Commit

Permalink
Add AuthGuard (#1369)
Browse files Browse the repository at this point in the history
Adds the`AuthGuard` as part of a test implementation for SiWe authentication:

- Add `AuthGuard` with tests.
  • Loading branch information
iamacook authored Apr 10, 2024
1 parent 3358128 commit 816f4ac
Show file tree
Hide file tree
Showing 9 changed files with 370 additions and 5 deletions.
2 changes: 1 addition & 1 deletion src/datasources/jwt/jwt.service.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export const IJwtService = Symbol('IJwtService');
export interface IJwtService {
sign<T extends string | object>(
payload: T,
options: {
options?: {
expiresIn?: number;
notBefore?: number;
},
Expand Down
8 changes: 7 additions & 1 deletion src/domain/auth/auth.repository.interface.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { SiweMessage } from '@/domain/auth/entities/siwe-message.entity';
import { JwtAccessTokenPayload } from '@/routes/auth/entities/jwt-access-token.payload.entity';
import { Request } from 'express';

export const IAuthRepository = Symbol('IAuthRepository');

export interface IAuthRepository {
generateNonce(): Promise<{ nonce: string }>;

verify(args: { message: SiweMessage; signature: `0x${string}` }): Promise<{
verifyMessage(args: {
message: SiweMessage;
signature: `0x${string}`;
}): Promise<{
accessToken: string;
tokenType: string;
notBefore: number | null;
expiresIn: number | null;
}>;

getAccessToken(request: Request, tokenType: string): string | null;

verifyAccessToken(accessToken: string): JwtAccessTokenPayload;
}
24 changes: 22 additions & 2 deletions src/domain/auth/auth.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
import { AuthService } from '@/routes/auth/auth.service';
import { VerifyAuthMessageDto } from '@/routes/auth/entities/verify-auth-message.dto.entity';
import { IJwtService } from '@/datasources/jwt/jwt.service.interface';
import {
JwtAccessTokenPayload,
JwtAccessTokenPayloadSchema,
} from '@/routes/auth/entities/jwt-access-token.payload.entity';

@Injectable()
export class AuthRepository implements IAuthRepository {
Expand Down Expand Up @@ -65,7 +69,7 @@ export class AuthRepository implements IAuthRepository {
* @returns notBefore - epoch from when token is valid (if applicable, otherwise null)
* @returns expiresIn - time in seconds until the token expires (if applicable, otherwise null)
*/
async verify(args: VerifyAuthMessageDto): Promise<{
async verifyMessage(args: VerifyAuthMessageDto): Promise<{
accessToken: string;
tokenType: string;
notBefore: number | null;
Expand All @@ -77,6 +81,10 @@ export class AuthRepository implements IAuthRepository {
throw new UnauthorizedException();
}

const jwtAccessTokenPayload: JwtAccessTokenPayload = {
signer_address: args.message.address,
};

const dateWhenTokenIsValid = args.message.notBefore
? new Date(args.message.notBefore)
: null;
Expand All @@ -91,7 +99,7 @@ export class AuthRepository implements IAuthRepository {
? this.getSecondsUntil(dateWhenTokenExpires)
: null;

const accessToken = this.jwtService.sign(args.message, {
const accessToken = this.jwtService.sign(jwtAccessTokenPayload, {
...(secondsUntilTokenIsValid !== null && {
notBefore: secondsUntilTokenIsValid,
}),
Expand Down Expand Up @@ -174,4 +182,16 @@ export class AuthRepository implements IAuthRepository {

return token;
}

/**
* Verifies access token and returns the {@link JwtAccessTokenPayload}.
*
* @param accessToken - JWT access token
* @throws if the token is invalid or doesn't parse as expected.
* @returns the {@link JwtAccessTokenPayload}.
*/
verifyAccessToken(accessToken: string): JwtAccessTokenPayload {
const payload = this.jwtService.verify(accessToken);
return JwtAccessTokenPayloadSchema.parse(payload);
}
}
2 changes: 1 addition & 1 deletion src/routes/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ export class AuthService {
notBefore: number | null;
expiresIn: number | null;
}> {
return await this.authRepository.verify(args);
return await this.authRepository.verifyMessage(args);
}
}
8 changes: 8 additions & 0 deletions src/routes/auth/entities/jwt-access-token.payload.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
import { z } from 'zod';

export type JwtAccessTokenPayload = z.infer<typeof JwtAccessTokenPayloadSchema>;

export const JwtAccessTokenPayloadSchema = z.object({
signer_address: AddressSchema,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IBuilder, Builder } from '@/__tests__/builder';
import { JwtAccessTokenPayload } from '@/routes/auth/entities/jwt-access-token.payload.entity';
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';

export function jwtAccessTokenPayloadBuilder(): IBuilder<JwtAccessTokenPayload> {
return new Builder<JwtAccessTokenPayload>().with(
'signer_address',
getAddress(faker.finance.ethereumAddress()),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { JwtAccessTokenPayloadSchema } from '@/routes/auth/entities/jwt-access-token.payload.entity';
import { jwtAccessTokenPayloadBuilder } from '@/routes/auth/entities/schemas/__tests__/jwt-access-token.payload.builder';
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';

describe('JwtAccessTokenSchema', () => {
it('should parse a valid JwtAccessTokenSchema', () => {
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder().build();

const result = JwtAccessTokenPayloadSchema.safeParse(jwtAccessTokenPayload);

expect(result.success).toBe(true);
// Address did not checksum as it already way
expect(result.success && result.data).toStrictEqual(jwtAccessTokenPayload);
});

it('should checksum the signer_address', () => {
const nonChecksummedAddress = faker.finance.ethereumAddress().toLowerCase();
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder()
.with('signer_address', nonChecksummedAddress as `0x${string}`)
.build();

const result = JwtAccessTokenPayloadSchema.safeParse(jwtAccessTokenPayload);

expect(result.success && result.data.signer_address).toBe(
getAddress(nonChecksummedAddress),
);
});

it('should not allow a non-address signer_address', () => {
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder()
.with('signer_address', faker.lorem.word() as `0x${string}`)
.build();

const result = JwtAccessTokenPayloadSchema.safeParse(jwtAccessTokenPayload);

expect(result.success).toBe(false);
expect(!result.success && result.error.issues).toStrictEqual([
{
code: 'custom',
message: 'Invalid input',
path: ['signer_address'],
},
]);
});

it('should not parse an invalid JwtAccessTokenSchema', () => {
const jwtAccessTokenPayload = {
unknown: 'payload',
};

const result = JwtAccessTokenPayloadSchema.safeParse(jwtAccessTokenPayload);

expect(result.success).toBe(false);
expect(!result.success && result.error.issues).toStrictEqual([
{
code: 'invalid_type',
expected: 'string',
message: 'Required',
path: ['signer_address'],
received: 'undefined',
},
]);
});
});
208 changes: 208 additions & 0 deletions src/routes/auth/guards/auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { TestAppProvider } from '@/__tests__/test-app.provider';
import { ConfigurationModule } from '@/config/configuration.module';
import configuration from '@/config/entities/__tests__/configuration';
import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module';
import { CacheModule } from '@/datasources/cache/cache.module';
import { IJwtService } from '@/datasources/jwt/jwt.service.interface';
import { AuthDomainModule } from '@/domain/auth/auth.domain.module';
import { jwtAccessTokenPayloadBuilder } from '@/routes/auth/entities/schemas/__tests__/jwt-access-token.payload.builder';
import { TestLoggingModule } from '@/logging/__tests__/test.logging.module';
import { AuthGuard } from '@/routes/auth/guards/auth.guard';
import { faker } from '@faker-js/faker';
import { Get, INestApplication } from '@nestjs/common';
import { Controller, UseGuards } from '@nestjs/common';
import { TestingModule, Test } from '@nestjs/testing';
import * as request from 'supertest';

function secondsUntil(date: Date): number {
return Math.floor((date.getTime() - Date.now()) / 1000);
}

@Controller()
class TestController {
@Get('valid')
@UseGuards(AuthGuard)
async validRoute(): Promise<{ secret: string }> {
return { secret: 'This is a secret message' };
}
}

describe('AuthGuard', () => {
let app: INestApplication;
let jwtService: IJwtService;

beforeEach(async () => {
jest.useFakeTimers();

const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
TestLoggingModule,
ConfigurationModule.register(configuration),
CacheModule,
AuthDomainModule,
],
controllers: [TestController],
})
.overrideModule(CacheModule)
.useModule(TestCacheModule)
.compile();

jwtService = moduleFixture.get<IJwtService>(IJwtService);
app = await new TestAppProvider().provide(moduleFixture);
await app.init();
});

afterEach(async () => {
jest.useRealTimers();
await app.close();
});

it('should not allow access if there is no token', async () => {
await request(app.getHttpServer()).get('/valid').expect(403).expect({
message: 'Forbidden resource',
error: 'Forbidden',
statusCode: 403,
});
});

it('should not allow access if verification of the token fails', async () => {
const accessToken = faker.string.alphanumeric();

expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed');

await request(app.getHttpServer())
.get('/valid')
.set('authorization', `Bearer ${accessToken}`)
.expect(403)
.expect({
message: 'Forbidden resource',
error: 'Forbidden',
statusCode: 403,
});
});

it('should not allow access if a token is not yet valid', async () => {
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder().build();
const notBefore = faker.date.future();
const accessToken = jwtService.sign(jwtAccessTokenPayload, {
notBefore: secondsUntil(notBefore),
});

expect(() => jwtService.verify(accessToken)).toThrow('jwt not active');

await request(app.getHttpServer())
.get('/valid')
.set('authorization', `Bearer ${accessToken}`)
.expect(403)
.expect({
message: 'Forbidden resource',
error: 'Forbidden',
statusCode: 403,
});
});

it('should not allow access if a token has expired', async () => {
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder().build();
const expiresIn = 0; // Now
const accessToken = jwtService.sign(jwtAccessTokenPayload, {
expiresIn,
});
jest.advanceTimersByTime(1);

expect(() => jwtService.verify(accessToken)).toThrow('jwt expired');

await request(app.getHttpServer())
.get('/valid')
.set('authorization', `Bearer ${accessToken}`)
.expect(403)
.expect({
message: 'Forbidden resource',
error: 'Forbidden',
statusCode: 403,
});
});

it('should not allow access if a verified token is not that of a JwtAccessTokenPayload', async () => {
const jwtAccessTokenPayload = {
unknown: 'payload',
};
const accessToken = jwtService.sign(jwtAccessTokenPayload);

expect(() => jwtService.verify(accessToken)).not.toThrow();

await request(app.getHttpServer())
.get('/valid')
.set('authorization', `Bearer ${accessToken}`)
.expect(403)
.expect({
message: 'Forbidden resource',
error: 'Forbidden',
statusCode: 403,
});
});

describe('should allow access if the JwtAccessTokenSchema is valid', () => {
it('when notBefore nor expiresIn is specified', async () => {
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder().build();
const accessToken = jwtService.sign(jwtAccessTokenPayload);

expect(() => jwtService.verify(accessToken)).not.toThrow();

await request(app.getHttpServer())
.get('/valid')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect({ secret: 'This is a secret message' });
});

it('when notBefore is and expirationTime is not specified', async () => {
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder().build();
const notBefore = faker.date.past();
const accessToken = jwtService.sign(jwtAccessTokenPayload, {
notBefore: secondsUntil(notBefore),
});

expect(() => jwtService.verify(accessToken)).not.toThrow();

await request(app.getHttpServer())
.get('/valid')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect({ secret: 'This is a secret message' });
});

it('when expiresIn is and notBefore is not specified', async () => {
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder().build();
const expiresIn = faker.date.future();
const accessToken = jwtService.sign(jwtAccessTokenPayload, {
expiresIn: secondsUntil(expiresIn),
});

expect(() => jwtService.verify(accessToken)).not.toThrow();

await request(app.getHttpServer())
.get('/valid')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect({ secret: 'This is a secret message' });
});

it('when notBefore and expirationTime are specified', async () => {
const jwtAccessTokenPayload = jwtAccessTokenPayloadBuilder().build();
const notBefore = faker.date.past();
const expiresIn = faker.date.future();
const accessToken = jwtService.sign(jwtAccessTokenPayload, {
notBefore: secondsUntil(notBefore),
expiresIn: secondsUntil(expiresIn),
});

expect(() => jwtService.verify(accessToken)).not.toThrow();

await request(app.getHttpServer())
.get('/valid')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect({ secret: 'This is a secret message' });
});
});
});
Loading

0 comments on commit 816f4ac

Please sign in to comment.