Skip to content
Merged
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
6 changes: 6 additions & 0 deletions src/auth/guards/api-key-permissions.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';

export const API_KEY_PERMISSIONS_KEY = 'api_key_permissions';

export const ApiKeyPermissions = (...permissions: string[]) =>
SetMetadata(API_KEY_PERMISSIONS_KEY, permissions);
115 changes: 115 additions & 0 deletions src/auth/guards/api-key.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
Inject,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { createHash } from 'crypto';
import { SupabaseService } from '../../database/supabase.client';
import { API_KEY_PERMISSIONS_KEY } from './api-key-permissions.decorator';

interface ApiKeyRecord {
id: string;
vendor_id: string;
name: string;
key_prefix: string;
key_hash: string;
permissions: string[];
is_active: boolean;
last_used_at: string | null;
expires_at: string | null;
created_at: string;
updated_at: string;
}

@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(
private readonly supabaseService: SupabaseService,
private readonly reflector: Reflector,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<{
headers: Record<string, string | string[] | undefined>;
apiKey?: ApiKeyRecord;
}>();

const apiKeyHeader = request.headers['x-api-key'];

if (!apiKeyHeader || typeof apiKeyHeader !== 'string') {
throw new UnauthorizedException({
code: 'API_KEY_MISSING',
message: 'X-API-Key header is required.',
});
}

const keyHash = createHash('sha256').update(apiKeyHeader).digest('hex');

const client = this.supabaseService.getServiceRoleClient();
const { data, error } = await client
.from('api_keys')
.select('*')
.eq('key_hash', keyHash)
.single();

if (error || !data) {
throw new UnauthorizedException({
code: 'API_KEY_INVALID',
message: 'Invalid API key.',
});
}

const keyRecord = data as unknown as ApiKeyRecord;

if (!keyRecord.is_active) {
throw new UnauthorizedException({
code: 'API_KEY_INACTIVE',
message: 'API key has been revoked.',
});
}

if (keyRecord.expires_at && new Date(keyRecord.expires_at) < new Date()) {
throw new UnauthorizedException({
code: 'API_KEY_EXPIRED',
message: 'API key has expired.',
});
}

const requiredPermissions = this.reflector.get<string[]>(
API_KEY_PERMISSIONS_KEY,
context.getHandler(),
);

if (requiredPermissions && requiredPermissions.length > 0) {
const keyPermissions: string[] = keyRecord.permissions ?? [];
const hasPermission = requiredPermissions.some((p) => keyPermissions.includes(p));
if (!hasPermission) {
throw new ForbiddenException({
code: 'API_KEY_INSUFFICIENT_PERMISSIONS',
message: 'API key does not have the required permissions for this resource.',
});
}
}

this.updateLastUsed(keyRecord.id);

request.apiKey = keyRecord;
return true;
}

private async updateLastUsed(keyId: string): Promise<void> {
try {
const client = this.supabaseService.getServiceRoleClient();
await client
.from('api_keys')
.update({ last_used_at: new Date().toISOString() })
.eq('id', keyId);
} catch {
// Fire-and-forget β€” failure to update last_used_at should not block the request
}
}
}
15 changes: 8 additions & 7 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import * as Sentry from '@sentry/nestjs';

Sentry.init({
dsn: process.env.SENTRY_DSN || undefined,
environment: process.env.NODE_ENV || 'development',
tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE
? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE)
: 0.1,
});
Sentry.init(
{
environment: process.env.NODE_ENV || 'development',
tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE
? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE)
: 0.1,
} as Parameters<typeof Sentry.init>[0],
);

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
Expand Down
5 changes: 3 additions & 2 deletions src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { ApiKeyGuard } from '../../auth/guards/api-key.guard';
import { SupabaseService } from '../../database/supabase.client';
import { UsersRepository } from '../../database/repositories/users.repository';
import { getJwtConfig } from '../../config/jwt.config';
Expand All @@ -19,7 +20,7 @@ import { getJwtConfig } from '../../config/jwt.config';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, SupabaseService, ConfigService, UsersRepository],
exports: [AuthService, JwtStrategy, PassportModule],
providers: [AuthService, JwtStrategy, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository],
exports: [AuthService, JwtStrategy, ApiKeyGuard, PassportModule],
})
export class AuthModule {}
38 changes: 38 additions & 0 deletions src/modules/vendors/dto/api-key-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class ApiKeyResponseDto {
@ApiProperty()
id: string;

@ApiProperty()
vendorId: string;

@ApiProperty()
name: string;

@ApiProperty({ description: 'First 8 characters of the API key for identification' })
keyPrefix: string;

@ApiProperty({ type: [String] })
permissions: string[];

@ApiProperty()
isActive: boolean;

@ApiPropertyOptional()
lastUsedAt?: string;

@ApiPropertyOptional()
expiresAt?: string;

@ApiProperty()
createdAt: string;

@ApiProperty()
updatedAt: string;
}

export class ApiKeyCreatedResponseDto extends ApiKeyResponseDto {
@ApiProperty({ description: 'Full API key β€” this will only be shown once on creation' })
fullKey: string;
}
18 changes: 18 additions & 0 deletions src/modules/vendors/dto/create-api-key.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { IsString, IsArray, IsOptional, IsDateString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateApiKeyDto {
@ApiProperty({ example: 'Production API Key' })
@IsString()
name: string;

@ApiProperty({ example: ['loans:read', 'transactions:read'] })
@IsArray()
@IsString({ each: true })
permissions: string[];

@ApiPropertyOptional({ example: '2027-06-27T00:00:00Z' })
@IsOptional()
@IsDateString()
expiresAt?: string;
}
72 changes: 70 additions & 2 deletions src/modules/vendors/vendors.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
import { Controller, Get, Param, Query, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam } from '@nestjs/swagger';
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
Query,
ParseUUIDPipe,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiQuery,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { VendorsService } from './vendors.service';
import { VendorResponseDto, VendorType } from './dto/vendor.dto';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyResponseDto, ApiKeyCreatedResponseDto } from './dto/api-key-response.dto';

@ApiTags('vendors')
@Controller('vendors')
Expand All @@ -26,4 +49,49 @@ export class VendorsController {
async getById(@Param('id') id: string): Promise<VendorResponseDto> {
return this.vendorsService.getById(id);
}

@Post('api-keys')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.CREATED)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new API key for the authenticated vendor' })
@ApiResponse({ status: 201, description: 'API key created', type: ApiKeyCreatedResponseDto })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Vendor not found' })
async createApiKey(
@CurrentUser() user: { wallet: string },
@Body() dto: CreateApiKeyDto,
): Promise<ApiKeyCreatedResponseDto> {
return this.vendorsService.createApiKey(user.wallet, dto);
}

@Get('api-keys')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'List all API keys for the authenticated vendor' })
@ApiResponse({ status: 200, description: 'List of API keys', type: [ApiKeyResponseDto] })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Vendor not found' })
async listApiKeys(
@CurrentUser() user: { wallet: string },
): Promise<ApiKeyResponseDto[]> {
return this.vendorsService.listApiKeys(user.wallet);
}

@Delete('api-keys/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiBearerAuth()
@ApiOperation({ summary: 'Revoke an API key' })
@ApiParam({ name: 'id', description: 'API key UUID' })
@ApiResponse({ status: 204, description: 'API key revoked' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'API key not found' })
async revokeApiKey(
@CurrentUser() user: { wallet: string },
@Param('id', ParseUUIDPipe) keyId: string,
): Promise<void> {
return this.vendorsService.revokeApiKey(user.wallet, keyId);
}
}
5 changes: 4 additions & 1 deletion src/modules/vendors/vendors.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
import { VendorsService } from './vendors.service';
import { VendorsController } from './vendors.controller';
import { SupabaseService } from '../../database/supabase.client';
import { VendorsRepository } from '../../database/repositories/vendors.repository';
import { AuthModule } from '../auth/auth.module';

@Module({
providers: [VendorsService, SupabaseService],
imports: [AuthModule],
providers: [VendorsService, VendorsRepository, SupabaseService],
controllers: [VendorsController],
exports: [VendorsService],
})
Expand Down
Loading
Loading