From c5a78823680eb2fceec42639eeca157d612d88a5 Mon Sep 17 00:00:00 2001 From: Jesus Ordosgoitty Date: Thu, 9 Apr 2026 11:05:27 -0400 Subject: [PATCH] feat: implement project-based API key authentication using a global guard and CLI project creation script --- PROJECT_KNOWLEDGE.md | 16 ++++++ backend/package.json | 3 +- backend/src/app.controller.ts | 2 + backend/src/app.module.ts | 12 ++++- backend/src/auth/auth.module.ts | 10 ++++ backend/src/auth/is-public.decorator.ts | 4 ++ backend/src/auth/project-auth.guard.ts | 66 +++++++++++++++++++++++++ backend/src/scripts/create-project.ts | 61 +++++++++++++++++++++++ 8 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 backend/src/auth/auth.module.ts create mode 100644 backend/src/auth/is-public.decorator.ts create mode 100644 backend/src/auth/project-auth.guard.ts create mode 100644 backend/src/scripts/create-project.ts diff --git a/PROJECT_KNOWLEDGE.md b/PROJECT_KNOWLEDGE.md index c56e132..628616b 100644 --- a/PROJECT_KNOWLEDGE.md +++ b/PROJECT_KNOWLEDGE.md @@ -46,6 +46,22 @@ - The project appears to be a marketplace or currency rate tracking application ("OtraAppDelDolarVzla" directory name suggests dollar rates, "AKomo" is the new branding). - **Recent Focus**: UI/UX enhancements, responsive design, and branding updates (renaming to AKomo). - **Backend Features**: Includes scripts for synchronizing currency logic (Binance, BCV). +- **Authentication**: Project-based API Key system using SHA-256 hashing. + +## Key Management + +### Project Creation +Authorized clients (like the mobile app) must have a project entry in Supabase and use an `x-api-key` header. + +To create a new project: +```bash +pnpm create:project "Project Name" [--test] +``` + +### Authentication Logic +- **Header**: `x-api-key` +- **Global Guard**: `ProjectAuthGuard` enforces validation on all routes except those marked with `@IsPublic()`. +- **Database Table**: `projects` (uuid, name, key_hash, prefix, is_active, created_at, last_used_at). ## Rules & Standards - **Package Manager**: `pnpm` (Global preference). `npm` used specifically for syncing mobile `package-lock.json` if needed. diff --git a/backend/package.json b/backend/package.json index 69792ad..86be7b1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,8 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "sync:binance": "ts-node src/scripts/sync-binance.ts", - "sync:bcv": "ts-node src/scripts/sync-bcv.ts" + "sync:bcv": "ts-node src/scripts/sync-bcv.ts", + "create:project": "ts-node src/scripts/create-project.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index cce879e..468a98d 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -1,10 +1,12 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; +import { IsPublic } from './auth/is-public.decorator'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} + @IsPublic() @Get() getHello(): string { return this.appService.getHello(); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3e8c9a7..80dd0f3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,9 @@ import { AppService } from './app.service'; import { ExchangeRatesModule } from './exchange-rates/exchange-rates.module'; import { SupabaseModule } from './supabase/supabase.module'; import { BuildsModule } from './builds/builds.module'; +import { AuthModule } from './auth/auth.module'; +import { APP_GUARD } from '@nestjs/core'; +import { ProjectAuthGuard } from './auth/project-auth.guard'; @Module({ imports: [ @@ -12,8 +15,15 @@ import { BuildsModule } from './builds/builds.module'; SupabaseModule, ExchangeRatesModule, BuildsModule, + AuthModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_GUARD, + useClass: ProjectAuthGuard, + }, + ], }) export class AppModule {} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..97c4e7b --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SupabaseModule } from '../supabase/supabase.module'; +import { ProjectAuthGuard } from './project-auth.guard'; + +@Module({ + imports: [SupabaseModule], + providers: [ProjectAuthGuard], + exports: [ProjectAuthGuard], +}) +export class AuthModule {} diff --git a/backend/src/auth/is-public.decorator.ts b/backend/src/auth/is-public.decorator.ts new file mode 100644 index 0000000..999f6f3 --- /dev/null +++ b/backend/src/auth/is-public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const IsPublic = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/src/auth/project-auth.guard.ts b/backend/src/auth/project-auth.guard.ts new file mode 100644 index 0000000..ebfa861 --- /dev/null +++ b/backend/src/auth/project-auth.guard.ts @@ -0,0 +1,66 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { createHash } from 'crypto'; +import { SupabaseService } from '../supabase/supabase.service'; +import { IS_PUBLIC_KEY } from './is-public.decorator'; + +@Injectable() +export class ProjectAuthGuard implements CanActivate { + constructor( + private reflector: Reflector, + private supabaseService: SupabaseService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const apiKey = request.headers['x-api-key']; + + if (!apiKey) { + throw new UnauthorizedException('API key is missing'); + } + + // Hash the incoming key for comparison + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + + const supabase = this.supabaseService.getClient(); + const { data: project, error } = await supabase + .from('projects') + .select('id, name, is_active') + .eq('key_hash', keyHash) + .single(); + + if (error || !project) { + throw new UnauthorizedException('Invalid API key'); + } + + if (!project.is_active) { + throw new UnauthorizedException('Project is inactive'); + } + + // Optional: Attach project info to request + request['project'] = project; + + // Update last used timestamp (non-blocking) + supabase + .from('projects') + .update({ last_used_at: new Date().toISOString() }) + .eq('id', project.id) + .then(); + + return true; + } +} diff --git a/backend/src/scripts/create-project.ts b/backend/src/scripts/create-project.ts new file mode 100644 index 0000000..aec5f93 --- /dev/null +++ b/backend/src/scripts/create-project.ts @@ -0,0 +1,61 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { SupabaseService } from '../supabase/supabase.service'; +import { createHash, randomBytes } from 'crypto'; + +async function bootstrap() { + const args = process.argv.slice(2); + const projectName = args[0]; + const type = args[1] === '--test' ? 'test' : 'live'; + + if (!projectName) { + console.error('Usage: pnpm ts-node src/scripts/create-project.ts "Project Name" [--test]'); + process.exit(1); + } + + const app = await NestFactory.createApplicationContext(AppModule); + const supabaseService = app.get(SupabaseService); + const supabase = supabaseService.getClient(); + + try { + // 1. Generate a random secure key + const randomPart = randomBytes(24).toString('base64url'); + const prefix = `ak_${type}`; + const rawKey = `${prefix}_${randomPart}`; + + // 2. Hash the key for database storage + const keyHash = createHash('sha256').update(rawKey).digest('hex'); + + // 3. Save to Supabase (assuming the table exists) + const { data, error } = await supabase + .from('projects') + .insert([ + { + name: projectName, + key_hash: keyHash, + prefix: prefix, + is_active: true, + }, + ]) + .select(); + + if (error) { + throw new Error(`Failed to create project: ${error.message}`); + } + + console.log('\n--- Project Created Successfully ---'); + console.log(`Project Name: ${projectName}`); + console.log(`Prefix: ${prefix}`); + console.log('------------------------------------'); + console.log('IMPORTANT: Copy the following API Key. It will NOT be shown again!'); + console.log(`\nAPI KEY: ${rawKey}\n`); + console.log('------------------------------------'); + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } finally { + await app.close(); + } +} + +bootstrap();