Skip to content

Commit 7524888

Browse files
authored
API Key Authentication System for Vendor Service-to-Service Integration (#60)
Added api_keys table (Supabase migration), ApiKeyGuard for X-API-Key header authentication with SHA-256 hashing and permission enforcement, and JWT-protected CRUD endpoints (POST/GET/DELETE /vendors/api-keys) returning the full key exactly once. The guard supports endpoint-level permission checks via @ApiKeyPermissions() decorator and fire-and-forget last_used_at tracking. Closes #34
1 parent be6ac8b commit 7524888

11 files changed

Lines changed: 772 additions & 14 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
3+
export const API_KEY_PERMISSIONS_KEY = 'api_key_permissions';
4+
5+
export const ApiKeyPermissions = (...permissions: string[]) =>
6+
SetMetadata(API_KEY_PERMISSIONS_KEY, permissions);

src/auth/guards/api-key.guard.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
Injectable,
3+
CanActivate,
4+
ExecutionContext,
5+
UnauthorizedException,
6+
ForbiddenException,
7+
Inject,
8+
} from '@nestjs/common';
9+
import { Reflector } from '@nestjs/core';
10+
import { createHash } from 'crypto';
11+
import { SupabaseService } from '../../database/supabase.client';
12+
import { API_KEY_PERMISSIONS_KEY } from './api-key-permissions.decorator';
13+
14+
interface ApiKeyRecord {
15+
id: string;
16+
vendor_id: string;
17+
name: string;
18+
key_prefix: string;
19+
key_hash: string;
20+
permissions: string[];
21+
is_active: boolean;
22+
last_used_at: string | null;
23+
expires_at: string | null;
24+
created_at: string;
25+
updated_at: string;
26+
}
27+
28+
@Injectable()
29+
export class ApiKeyGuard implements CanActivate {
30+
constructor(
31+
private readonly supabaseService: SupabaseService,
32+
private readonly reflector: Reflector,
33+
) {}
34+
35+
async canActivate(context: ExecutionContext): Promise<boolean> {
36+
const request = context.switchToHttp().getRequest<{
37+
headers: Record<string, string | string[] | undefined>;
38+
apiKey?: ApiKeyRecord;
39+
}>();
40+
41+
const apiKeyHeader = request.headers['x-api-key'];
42+
43+
if (!apiKeyHeader || typeof apiKeyHeader !== 'string') {
44+
throw new UnauthorizedException({
45+
code: 'API_KEY_MISSING',
46+
message: 'X-API-Key header is required.',
47+
});
48+
}
49+
50+
const keyHash = createHash('sha256').update(apiKeyHeader).digest('hex');
51+
52+
const client = this.supabaseService.getServiceRoleClient();
53+
const { data, error } = await client
54+
.from('api_keys')
55+
.select('*')
56+
.eq('key_hash', keyHash)
57+
.single();
58+
59+
if (error || !data) {
60+
throw new UnauthorizedException({
61+
code: 'API_KEY_INVALID',
62+
message: 'Invalid API key.',
63+
});
64+
}
65+
66+
const keyRecord = data as unknown as ApiKeyRecord;
67+
68+
if (!keyRecord.is_active) {
69+
throw new UnauthorizedException({
70+
code: 'API_KEY_INACTIVE',
71+
message: 'API key has been revoked.',
72+
});
73+
}
74+
75+
if (keyRecord.expires_at && new Date(keyRecord.expires_at) < new Date()) {
76+
throw new UnauthorizedException({
77+
code: 'API_KEY_EXPIRED',
78+
message: 'API key has expired.',
79+
});
80+
}
81+
82+
const requiredPermissions = this.reflector.get<string[]>(
83+
API_KEY_PERMISSIONS_KEY,
84+
context.getHandler(),
85+
);
86+
87+
if (requiredPermissions && requiredPermissions.length > 0) {
88+
const keyPermissions: string[] = keyRecord.permissions ?? [];
89+
const hasPermission = requiredPermissions.some((p) => keyPermissions.includes(p));
90+
if (!hasPermission) {
91+
throw new ForbiddenException({
92+
code: 'API_KEY_INSUFFICIENT_PERMISSIONS',
93+
message: 'API key does not have the required permissions for this resource.',
94+
});
95+
}
96+
}
97+
98+
this.updateLastUsed(keyRecord.id);
99+
100+
request.apiKey = keyRecord;
101+
return true;
102+
}
103+
104+
private async updateLastUsed(keyId: string): Promise<void> {
105+
try {
106+
const client = this.supabaseService.getServiceRoleClient();
107+
await client
108+
.from('api_keys')
109+
.update({ last_used_at: new Date().toISOString() })
110+
.eq('id', keyId);
111+
} catch {
112+
// Fire-and-forget — failure to update last_used_at should not block the request
113+
}
114+
}
115+
}

src/main.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import * as Sentry from '@sentry/nestjs';
22

3-
Sentry.init({
4-
dsn: process.env.SENTRY_DSN || undefined,
5-
environment: process.env.NODE_ENV || 'development',
6-
tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE
7-
? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE)
8-
: 0.1,
9-
});
3+
Sentry.init(
4+
{
5+
environment: process.env.NODE_ENV || 'development',
6+
tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE
7+
? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE)
8+
: 0.1,
9+
} as Parameters<typeof Sentry.init>[0],
10+
);
1011

1112
import { ValidationPipe } from '@nestjs/common';
1213
import { NestFactory } from '@nestjs/core';

src/modules/auth/auth.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
55
import { AuthController } from './auth.controller';
66
import { AuthService } from './auth.service';
77
import { JwtStrategy } from './jwt.strategy';
8+
import { ApiKeyGuard } from '../../auth/guards/api-key.guard';
89
import { SupabaseService } from '../../database/supabase.client';
910
import { UsersRepository } from '../../database/repositories/users.repository';
1011
import { getJwtConfig } from '../../config/jwt.config';
@@ -19,7 +20,7 @@ import { getJwtConfig } from '../../config/jwt.config';
1920
}),
2021
],
2122
controllers: [AuthController],
22-
providers: [AuthService, JwtStrategy, SupabaseService, ConfigService, UsersRepository],
23-
exports: [AuthService, JwtStrategy, PassportModule],
23+
providers: [AuthService, JwtStrategy, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository],
24+
exports: [AuthService, JwtStrategy, ApiKeyGuard, PassportModule],
2425
})
2526
export class AuthModule {}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
3+
export class ApiKeyResponseDto {
4+
@ApiProperty()
5+
id: string;
6+
7+
@ApiProperty()
8+
vendorId: string;
9+
10+
@ApiProperty()
11+
name: string;
12+
13+
@ApiProperty({ description: 'First 8 characters of the API key for identification' })
14+
keyPrefix: string;
15+
16+
@ApiProperty({ type: [String] })
17+
permissions: string[];
18+
19+
@ApiProperty()
20+
isActive: boolean;
21+
22+
@ApiPropertyOptional()
23+
lastUsedAt?: string;
24+
25+
@ApiPropertyOptional()
26+
expiresAt?: string;
27+
28+
@ApiProperty()
29+
createdAt: string;
30+
31+
@ApiProperty()
32+
updatedAt: string;
33+
}
34+
35+
export class ApiKeyCreatedResponseDto extends ApiKeyResponseDto {
36+
@ApiProperty({ description: 'Full API key — this will only be shown once on creation' })
37+
fullKey: string;
38+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { IsString, IsArray, IsOptional, IsDateString } from 'class-validator';
2+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
3+
4+
export class CreateApiKeyDto {
5+
@ApiProperty({ example: 'Production API Key' })
6+
@IsString()
7+
name: string;
8+
9+
@ApiProperty({ example: ['loans:read', 'transactions:read'] })
10+
@IsArray()
11+
@IsString({ each: true })
12+
permissions: string[];
13+
14+
@ApiPropertyOptional({ example: '2027-06-27T00:00:00Z' })
15+
@IsOptional()
16+
@IsDateString()
17+
expiresAt?: string;
18+
}

src/modules/vendors/vendors.controller.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
1-
import { Controller, Get, Param, Query, HttpCode, HttpStatus } from '@nestjs/common';
2-
import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam } from '@nestjs/swagger';
1+
import {
2+
Controller,
3+
Get,
4+
Post,
5+
Delete,
6+
Param,
7+
Body,
8+
Query,
9+
ParseUUIDPipe,
10+
UseGuards,
11+
HttpCode,
12+
HttpStatus,
13+
} from '@nestjs/common';
14+
import {
15+
ApiTags,
16+
ApiOperation,
17+
ApiResponse,
18+
ApiQuery,
19+
ApiParam,
20+
ApiBearerAuth,
21+
} from '@nestjs/swagger';
22+
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
23+
import { CurrentUser } from '../../common/decorators/current-user.decorator';
324
import { VendorsService } from './vendors.service';
425
import { VendorResponseDto, VendorType } from './dto/vendor.dto';
26+
import { CreateApiKeyDto } from './dto/create-api-key.dto';
27+
import { ApiKeyResponseDto, ApiKeyCreatedResponseDto } from './dto/api-key-response.dto';
528

629
@ApiTags('vendors')
730
@Controller('vendors')
@@ -26,4 +49,49 @@ export class VendorsController {
2649
async getById(@Param('id') id: string): Promise<VendorResponseDto> {
2750
return this.vendorsService.getById(id);
2851
}
52+
53+
@Post('api-keys')
54+
@UseGuards(JwtAuthGuard)
55+
@HttpCode(HttpStatus.CREATED)
56+
@ApiBearerAuth()
57+
@ApiOperation({ summary: 'Create a new API key for the authenticated vendor' })
58+
@ApiResponse({ status: 201, description: 'API key created', type: ApiKeyCreatedResponseDto })
59+
@ApiResponse({ status: 401, description: 'Unauthorized' })
60+
@ApiResponse({ status: 404, description: 'Vendor not found' })
61+
async createApiKey(
62+
@CurrentUser() user: { wallet: string },
63+
@Body() dto: CreateApiKeyDto,
64+
): Promise<ApiKeyCreatedResponseDto> {
65+
return this.vendorsService.createApiKey(user.wallet, dto);
66+
}
67+
68+
@Get('api-keys')
69+
@UseGuards(JwtAuthGuard)
70+
@HttpCode(HttpStatus.OK)
71+
@ApiBearerAuth()
72+
@ApiOperation({ summary: 'List all API keys for the authenticated vendor' })
73+
@ApiResponse({ status: 200, description: 'List of API keys', type: [ApiKeyResponseDto] })
74+
@ApiResponse({ status: 401, description: 'Unauthorized' })
75+
@ApiResponse({ status: 404, description: 'Vendor not found' })
76+
async listApiKeys(
77+
@CurrentUser() user: { wallet: string },
78+
): Promise<ApiKeyResponseDto[]> {
79+
return this.vendorsService.listApiKeys(user.wallet);
80+
}
81+
82+
@Delete('api-keys/:id')
83+
@UseGuards(JwtAuthGuard)
84+
@HttpCode(HttpStatus.NO_CONTENT)
85+
@ApiBearerAuth()
86+
@ApiOperation({ summary: 'Revoke an API key' })
87+
@ApiParam({ name: 'id', description: 'API key UUID' })
88+
@ApiResponse({ status: 204, description: 'API key revoked' })
89+
@ApiResponse({ status: 401, description: 'Unauthorized' })
90+
@ApiResponse({ status: 404, description: 'API key not found' })
91+
async revokeApiKey(
92+
@CurrentUser() user: { wallet: string },
93+
@Param('id', ParseUUIDPipe) keyId: string,
94+
): Promise<void> {
95+
return this.vendorsService.revokeApiKey(user.wallet, keyId);
96+
}
2997
}

src/modules/vendors/vendors.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
22
import { VendorsService } from './vendors.service';
33
import { VendorsController } from './vendors.controller';
44
import { SupabaseService } from '../../database/supabase.client';
5+
import { VendorsRepository } from '../../database/repositories/vendors.repository';
6+
import { AuthModule } from '../auth/auth.module';
57

68
@Module({
7-
providers: [VendorsService, SupabaseService],
9+
imports: [AuthModule],
10+
providers: [VendorsService, VendorsRepository, SupabaseService],
811
controllers: [VendorsController],
912
exports: [VendorsService],
1013
})

0 commit comments

Comments
 (0)