Skip to content

Commit

Permalink
Add AuthController (#1368)
Browse files Browse the repository at this point in the history
Adds`AuthController` as part of the SiWe-based authentication implementation:

- Add `AuthModule`
- Add `AuthController`, enabled behind a feature flag
  - `GET` `/v1/auth/nonce` - returns nonce to be signed in SiWe message
  - `POST` `/v1/auth/verify` - verify signature and return access token
    - Add `VerifyAuthMessageDtoSchema`
- Add `AuthService`
  - `generateNonce` - get nonce to include in SiWe message
  - `verify` - verify signature according to expiration, nonce and validity of signature
- Appropriate test coverage for the above.
  • Loading branch information
iamacook authored Apr 9, 2024
1 parent d61f215 commit 057b314
Show file tree
Hide file tree
Showing 14 changed files with 554 additions and 15 deletions.
3 changes: 3 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { SubscriptionControllerModule } from '@/routes/subscriptions/subscriptio
import { LockingModule } from '@/routes/locking/locking.module';
import { ZodErrorFilter } from '@/routes/common/filters/zod-error.filter';
import { CacheControlInterceptor } from '@/routes/common/interceptors/cache-control.interceptor';
import { AuthModule } from '@/routes/auth/auth.module';

@Module({})
export class AppModule implements NestModule {
Expand All @@ -52,6 +53,7 @@ export class AppModule implements NestModule {
// which is not available at this stage.
static register(configFactory = configuration): DynamicModule {
const {
auth: isAuthFeatureEnabled,
email: isEmailFeatureEnabled,
locking: isLockingFeatureEnabled,
relay: isRelayFeatureEnabled,
Expand All @@ -62,6 +64,7 @@ export class AppModule implements NestModule {
imports: [
// features
AboutModule,
...(isAuthFeatureEnabled ? [AuthModule] : []),
BalancesModule,
CacheHooksModule,
ChainsModule,
Expand Down
2 changes: 2 additions & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default (): ReturnType<typeof configuration> => ({
applicationPort: faker.internet.port().toString(),
auth: {
token: faker.string.hexadecimal({ length: 32 }),
nonceTtlSeconds: faker.number.int(),
},
balances: {
balancesTtlSeconds: faker.number.int(),
Expand Down Expand Up @@ -176,6 +177,7 @@ export default (): ReturnType<typeof configuration> => ({
relay: true,
swapsDecoding: true,
historyDebugLogs: false,
auth: true,
},
httpClient: { requestTimeout: faker.number.int() },
jwt: {
Expand Down
4 changes: 4 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export default () => ({
applicationPort: process.env.APPLICATION_PORT || '3000',
auth: {
token: process.env.AUTH_TOKEN,
nonceTtlSeconds: parseInt(
process.env.AUTH_NONCE_TTL_SECONDS ?? `${5 * 60}`,
),
},
balances: {
balancesTtlSeconds: parseInt(process.env.BALANCES_TTL_SECONDS ?? `${300}`),
Expand Down Expand Up @@ -173,6 +176,7 @@ export default () => ({
swapsDecoding: process.env.FF_SWAPS_DECODING?.toLowerCase() === 'true',
historyDebugLogs:
process.env.FF_HISTORY_DEBUG_LOGS?.toLowerCase() === 'true',
auth: process.env.FF_AUTH?.toLowerCase() === 'true',
},
httpClient: {
// Timeout in milliseconds to be used for the HTTP client.
Expand Down
9 changes: 9 additions & 0 deletions src/datasources/cache/cache.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity';

export class CacheRouter {
private static readonly ALL_TRANSACTIONS_KEY = 'all_transactions';
private static readonly AUTH_NONCE_KEY = 'auth_nonce';
private static readonly BACKBONE_KEY = 'backbone';
private static readonly CHAIN_KEY = 'chain';
private static readonly CHAINS_KEY = 'chains';
Expand Down Expand Up @@ -33,6 +34,14 @@ export class CacheRouter {
private static readonly ZERION_COLLECTIBLES_KEY = 'zerion_collectibles';
private static readonly RATE_LIMIT_KEY = 'rate_limit';

static getAuthNonceCacheKey(nonce: string): string {
return `${CacheRouter.AUTH_NONCE_KEY}_${nonce}`;
}

static getAuthNonceCacheDir(nonce: string): CacheDir {
return new CacheDir(CacheRouter.getAuthNonceCacheKey(nonce), '');
}

static getBalancesCacheKey(args: {
chainId: string;
safeAddress: string;
Expand Down
3 changes: 2 additions & 1 deletion src/domain/auth/auth.domain.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { AuthApiModule } from '@/datasources/auth-api/auth-api.module';
import { JwtModule } from '@/datasources/jwt/jwt.module';
import { AuthRepository } from '@/domain/auth/auth.repository';
import { IAuthRepository } from '@/domain/auth/auth.repository.interface';
import { Module } from '@nestjs/common';

@Module({
imports: [AuthApiModule],
imports: [AuthApiModule, JwtModule],
providers: [{ provide: IAuthRepository, useClass: AuthRepository }],
exports: [IAuthRepository],
})
Expand Down
12 changes: 7 additions & 5 deletions src/domain/auth/auth.repository.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { Request } from 'express';
export const IAuthRepository = Symbol('IAuthRepository');

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

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

getAccessToken(request: Request, tokenType: string): string | null;
}
145 changes: 137 additions & 8 deletions src/domain/auth/auth.repository.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,155 @@
import { CacheRouter } from '@/datasources/cache/cache.router';
import { IAuthRepository } from '@/domain/auth/auth.repository.interface';
import { SiweMessage } from '@/domain/auth/entities/siwe-message.entity';
import { IAuthApi } from '@/domain/interfaces/auth-api.interface';
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { IConfigurationService } from '@/config/configuration.service.interface';
import {
CacheService,
ICacheService,
} from '@/datasources/cache/cache.service.interface';
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';

@Injectable()
export class AuthRepository implements IAuthRepository {
private readonly nonceTtlInSeconds: number;

constructor(
@Inject(IAuthApi)
private readonly authApi: IAuthApi,
) {}
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
@Inject(CacheService) private readonly cacheService: ICacheService,
@Inject(IJwtService)
private readonly jwtService: IJwtService,
) {
this.nonceTtlInSeconds = this.configurationService.getOrThrow(
'auth.nonceTtlSeconds',
);
}

/**
* Generates a unique nonce and stores it in cache for later verification.
*
* @returns nonce - unique string to be signed
*/
async generateNonce(): Promise<{ nonce: string }> {
const nonce = this.authApi.generateNonce();

// Store nonce for reference to verify/prevent replay attacks
const cacheDir = CacheRouter.getAuthNonceCacheDir(nonce);
await this.cacheService.set(cacheDir, nonce, this.nonceTtlInSeconds);

return {
nonce,
};
}

/**
* Verifies the validity of a signed message and returns an access token:
*
* 1. Ensure the message itself has not expired.
* 2. Ensure the nonce was generated by us/is not a replay attack.
* 3. Verify the signature of the message.
* 4. Return an access token if all checks pass.
*
* @param args - DTO containing the message and signature to verify.
*
* The following adhere to JWT standard {@link https://datatracker.ietf.org/doc/html/rfc7519}
*
* @returns accessToken - JWT access token
* @returns tokenType - token type ('Bearer') to be used in the `Authorization` header
* @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<{
accessToken: string;
tokenType: string;
notBefore: number | null;
expiresIn: number | null;
}> {
const isValid = await this.isValid(args).catch(() => false);

generateNonce(): string {
return this.authApi.generateNonce();
if (!isValid) {
throw new UnauthorizedException();
}

const dateWhenTokenIsValid = args.message.notBefore
? new Date(args.message.notBefore)
: null;
const dateWhenTokenExpires = args.message.expirationTime
? new Date(args.message.expirationTime)
: null;

const secondsUntilTokenIsValid = dateWhenTokenIsValid
? this.getSecondsUntil(dateWhenTokenIsValid)
: null;
const secondsUntilTokenExpires = dateWhenTokenExpires
? this.getSecondsUntil(dateWhenTokenExpires)
: null;

const accessToken = this.jwtService.sign(args.message, {
...(secondsUntilTokenIsValid !== null && {
notBefore: secondsUntilTokenIsValid,
}),
...(secondsUntilTokenExpires !== null && {
expiresIn: secondsUntilTokenExpires,
}),
});

return {
tokenType: AuthService.AUTH_TOKEN_TOKEN_TYPE,
accessToken,
// Differing measurements match JWT standard {@link https://datatracker.ietf.org/doc/html/rfc7519}
notBefore: dateWhenTokenIsValid?.getTime() ?? null,
expiresIn: secondsUntilTokenExpires,
};
}

async verifyMessage(args: {
address: `0x${string}`;
/**
* Verifies that a message is valid according to its expiration date,
* signature and nonce.
*
* @param args.message - SiWe message in object form
* @param args.signature - signature from signing the message
*
* @returns boolean - whether the message is valid
*/
private async isValid(args: {
message: SiweMessage;
signature: `0x${string}`;
}): Promise<boolean> {
return this.authApi.verifyMessage(args);
const cacheDir = CacheRouter.getAuthNonceCacheDir(args.message.nonce);

const isExpired =
!!args.message.expirationTime &&
new Date(args.message.expirationTime) < new Date();

try {
// Verification is not necessary, message has expired
if (isExpired) {
return false;
}

const [isValidSignature, cachedNonce] = await Promise.all([
this.authApi.verifyMessage(args),
this.cacheService.get(cacheDir),
]);
const isValidNonce = cachedNonce === args.message.nonce;

return isValidSignature && isValidNonce;
} catch {
return false;
} finally {
await this.cacheService.deleteByKey(cacheDir.key);
}
}

private getSecondsUntil(date: Date): number {
return Math.floor((date.getTime() - Date.now()) / 1_000);
}

/**
Expand All @@ -33,7 +162,7 @@ export class AuthRepository implements IAuthRepository {
getAccessToken(request: Request, tokenType: string): string | null {
const header = request.headers.authorization;

if (typeof header !== 'string') {
if (!header) {
return null;
}

Expand Down
1 change: 0 additions & 1 deletion src/domain/interfaces/auth-api.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export interface IAuthApi {
generateNonce(): string;

verifyMessage(args: {
address: `0x${string}`;
message: SiweMessage;
signature: `0x${string}`;
}): Promise<boolean>;
Expand Down
Loading

0 comments on commit 057b314

Please sign in to comment.