diff --git a/apps/manacore/apps/web/src/lib/api/api-keys.ts b/apps/manacore/apps/web/src/lib/api/api-keys.ts new file mode 100644 index 000000000..f83f86957 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/api/api-keys.ts @@ -0,0 +1,58 @@ +/** + * API Keys Service for ManaCore Web App + * Handles API key creation, listing, and revocation + */ + +import { createApiClient, type ApiResult } from './base-client'; + +const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env +const client = createApiClient(MANA_AUTH_URL); + +// Types +export interface ApiKey { + id: string; + name: string; + keyPrefix: string; + scopes: string[]; + rateLimitRequests: number; + rateLimitWindow: number; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; +} + +export interface ApiKeyWithSecret extends ApiKey { + key: string; // Full key - only returned on creation +} + +export interface CreateApiKeyDto { + name: string; + scopes?: string[]; + rateLimitRequests?: number; + rateLimitWindow?: number; +} + +// API Keys Service +export const apiKeysService = { + /** + * List all API keys for the current user + */ + async list(): Promise> { + return client.get('/api/v1/api-keys'); + }, + + /** + * Create a new API key + * Returns the full key only once - it cannot be retrieved later + */ + async create(dto: CreateApiKeyDto): Promise> { + return client.post('/api/v1/api-keys', dto); + }, + + /** + * Revoke an API key + */ + async revoke(id: string): Promise> { + return client.delete(`/api/v1/api-keys/${id}`); + }, +}; diff --git a/apps/manacore/apps/web/src/routes/(app)/api-keys/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/api-keys/+page.svelte new file mode 100644 index 000000000..45b76915d --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/api-keys/+page.svelte @@ -0,0 +1,420 @@ + + +
+ + {#snippet actions()} + + {/snippet} + + + {#if loading} +
+
+
+ {:else} +
+ {#if error} +
+ {error} +
+ {/if} + + + +
+
+
+ + + +
+
+

Active Keys

+

+ {activeKeys.length} active key{activeKeys.length !== 1 ? 's' : ''} +

+
+
+ + {#if activeKeys.length === 0} +
+ + + +

No API keys yet

+

Create your first API key to get started

+
+ {:else} +
+ {#each activeKeys as key (key.id)} +
+
+
+ {key.name} + {key.scopes.join(', ')} +
+
+ {key.keyPrefix} + Created: {formatDate(key.createdAt)} + Last used: {formatDate(key.lastUsedAt)} +
+
+ +
+ {/each} +
+ {/if} +
+
+ + + {#if revokedKeys.length > 0} + +
+
+
+ + + +
+
+

Revoked Keys

+

+ {revokedKeys.length} revoked key{revokedKeys.length !== 1 ? 's' : ''} +

+
+
+ +
+ {#each revokedKeys as key (key.id)} +
+
+
+ {key.name} + Revoked +
+
+ {key.keyPrefix} + Revoked: {formatDate(key.revokedAt)} +
+
+
+ {/each} +
+
+
+ {/if} + + + +
+
+
+ + + +
+
+

How to Use

+

Include your API key in requests

+
+
+ +
+
+

Speech-to-Text (STT)

+
curl -X POST https://stt-api.mana.how/transcribe \
+  -H "X-API-Key: sk_live_your_key_here" \
+  -F "audio=@audio.mp3"
+
+ +
+

Text-to-Speech (TTS)

+
curl -X POST https://tts-api.mana.how/synthesize/kokoro \
+  -H "X-API-Key: sk_live_your_key_here" \
+  -H "Content-Type: application/json" \
+  -d '{{ text: 'Hello world', voice: 'af_heart' }}' \
+  --output speech.wav
+
+
+
+
+
+ {/if} +
+ + +{#if showCreateModal} +
+ + + + +
+ {#if createdKey} + +
+
+ + + +
+ +

API Key Created

+

+ Copy your API key now. You won't be able to see it again. +

+ +
+ + {createdKey.key} + + +
+ + {#if copied} +

Copied to clipboard!

+ {/if} + + +
+ {:else} + +

Create API Key

+ +
+ + +

A friendly name to identify this key

+
+ +
+ + +
+ {/if} +
+
+{/if} diff --git a/services/mana-core-auth/src/api-keys/api-keys.controller.ts b/services/mana-core-auth/src/api-keys/api-keys.controller.ts new file mode 100644 index 000000000..afd3bfbb8 --- /dev/null +++ b/services/mana-core-auth/src/api-keys/api-keys.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiKeysService } from './api-keys.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { CurrentUserData } from '../common/decorators/current-user.decorator'; +import { CreateApiKeyDto, ValidateApiKeyDto } from './dto'; + +@Controller('api-keys') +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + /** + * List all API keys for the authenticated user + */ + @Get() + @UseGuards(JwtAuthGuard) + async listKeys(@CurrentUser() user: CurrentUserData) { + return this.apiKeysService.listUserApiKeys(user.userId); + } + + /** + * Create a new API key + * Returns the full key only once - it cannot be retrieved later + */ + @Post() + @UseGuards(JwtAuthGuard) + async createKey(@CurrentUser() user: CurrentUserData, @Body() dto: CreateApiKeyDto) { + return this.apiKeysService.createApiKey(user.userId, dto); + } + + /** + * Revoke an API key + */ + @Delete(':id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.NO_CONTENT) + async revokeKey(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + await this.apiKeysService.revokeApiKey(user.userId, id); + } + + /** + * Validate an API key (for STT/TTS services) + * This endpoint does NOT require JWT authentication + */ + @Post('validate') + async validateKey(@Body() dto: ValidateApiKeyDto) { + return this.apiKeysService.validateApiKey(dto.apiKey, dto.scope); + } +} diff --git a/services/mana-core-auth/src/api-keys/api-keys.module.ts b/services/mana-core-auth/src/api-keys/api-keys.module.ts new file mode 100644 index 000000000..337c57fc5 --- /dev/null +++ b/services/mana-core-auth/src/api-keys/api-keys.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ApiKeysController } from './api-keys.controller'; +import { ApiKeysService } from './api-keys.service'; + +@Module({ + controllers: [ApiKeysController], + providers: [ApiKeysService], + exports: [ApiKeysService], +}) +export class ApiKeysModule {} diff --git a/services/mana-core-auth/src/api-keys/api-keys.service.ts b/services/mana-core-auth/src/api-keys/api-keys.service.ts new file mode 100644 index 000000000..3836ca6f0 --- /dev/null +++ b/services/mana-core-auth/src/api-keys/api-keys.service.ts @@ -0,0 +1,173 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq, and, isNull } from 'drizzle-orm'; +import { createHash, randomBytes } from 'crypto'; +import { nanoid } from 'nanoid'; +import { getDb } from '../db/connection'; +import { apiKeys } from '../db/schema'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import type { ValidateApiKeyResponseDto } from './dto/validate-api-key.dto'; + +const DEFAULT_SCOPES = ['stt', 'tts']; +const KEY_PREFIX = 'sk_live_'; + +@Injectable() +export class ApiKeysService { + constructor(private configService: ConfigService) {} + + private getDb() { + const databaseUrl = this.configService.get('database.url'); + return getDb(databaseUrl!); + } + + /** + * Generate a new API key + * Format: sk_live_<32 random hex chars> + */ + private generateKey(): string { + const randomPart = randomBytes(16).toString('hex'); + return `${KEY_PREFIX}${randomPart}`; + } + + /** + * Hash an API key using SHA-256 + */ + private hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); + } + + /** + * Extract prefix for display (first 12 characters after sk_live_) + */ + private getKeyPrefix(key: string): string { + return key.substring(0, KEY_PREFIX.length + 8) + '...'; + } + + /** + * List all API keys for a user (without exposing the full key) + */ + async listUserApiKeys(userId: string) { + const db = this.getDb(); + const keys = await db + .select({ + id: apiKeys.id, + name: apiKeys.name, + keyPrefix: apiKeys.keyPrefix, + scopes: apiKeys.scopes, + rateLimitRequests: apiKeys.rateLimitRequests, + rateLimitWindow: apiKeys.rateLimitWindow, + createdAt: apiKeys.createdAt, + lastUsedAt: apiKeys.lastUsedAt, + revokedAt: apiKeys.revokedAt, + }) + .from(apiKeys) + .where(eq(apiKeys.userId, userId)); + + return keys; + } + + /** + * Create a new API key + * Returns the full key only once - it cannot be retrieved later + */ + async createApiKey(userId: string, dto: CreateApiKeyDto) { + const db = this.getDb(); + + const key = this.generateKey(); + const keyHash = this.hashKey(key); + const keyPrefix = this.getKeyPrefix(key); + + const [apiKey] = await db + .insert(apiKeys) + .values({ + id: nanoid(), + userId, + name: dto.name, + keyPrefix, + keyHash, + scopes: dto.scopes || DEFAULT_SCOPES, + rateLimitRequests: dto.rateLimitRequests || 60, + rateLimitWindow: dto.rateLimitWindow || 60, + }) + .returning(); + + // Return the full key only on creation + return { + id: apiKey.id, + name: apiKey.name, + key, // Full key - shown only once + keyPrefix: apiKey.keyPrefix, + scopes: apiKey.scopes, + rateLimitRequests: apiKey.rateLimitRequests, + rateLimitWindow: apiKey.rateLimitWindow, + createdAt: apiKey.createdAt, + }; + } + + /** + * Revoke an API key (soft delete) + */ + async revokeApiKey(userId: string, keyId: string) { + const db = this.getDb(); + + // Verify key exists and belongs to user + const [existing] = await db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt))) + .limit(1); + + if (!existing) { + throw new NotFoundException('API key not found'); + } + + await db + .update(apiKeys) + .set({ revokedAt: new Date() }) + .where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId))); + } + + /** + * Validate an API key (for STT/TTS services to call) + * This endpoint does NOT require authentication + */ + async validateApiKey(apiKey: string, scope?: string): Promise { + const db = this.getDb(); + + // Hash the incoming key to compare + const keyHash = this.hashKey(apiKey); + + // Find the key + const [key] = await db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.keyHash, keyHash), isNull(apiKeys.revokedAt))) + .limit(1); + + if (!key) { + return { valid: false, error: 'Invalid or revoked API key' }; + } + + // Check scope if provided + if (scope && key.scopes && !key.scopes.includes(scope)) { + return { valid: false, error: `API key does not have scope: ${scope}` }; + } + + // Update last used timestamp (fire-and-forget) + db.update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.id, key.id)) + .then(() => {}) + .catch(() => {}); + + return { + valid: true, + userId: key.userId, + scopes: key.scopes || [], + rateLimit: { + requests: key.rateLimitRequests, + window: key.rateLimitWindow, + }, + }; + } +} diff --git a/services/mana-core-auth/src/api-keys/dto/create-api-key.dto.ts b/services/mana-core-auth/src/api-keys/dto/create-api-key.dto.ts new file mode 100644 index 000000000..c93a80301 --- /dev/null +++ b/services/mana-core-auth/src/api-keys/dto/create-api-key.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsOptional, MaxLength, IsArray, IsInt, Min, Max } from 'class-validator'; + +export class CreateApiKeyDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + scopes?: string[]; + + @IsOptional() + @IsInt() + @Min(1) + @Max(1000) + rateLimitRequests?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(3600) + rateLimitWindow?: number; +} diff --git a/services/mana-core-auth/src/api-keys/dto/index.ts b/services/mana-core-auth/src/api-keys/dto/index.ts new file mode 100644 index 000000000..dc7bdafdf --- /dev/null +++ b/services/mana-core-auth/src/api-keys/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-api-key.dto'; +export * from './validate-api-key.dto'; diff --git a/services/mana-core-auth/src/api-keys/dto/validate-api-key.dto.ts b/services/mana-core-auth/src/api-keys/dto/validate-api-key.dto.ts new file mode 100644 index 000000000..e8269d2ac --- /dev/null +++ b/services/mana-core-auth/src/api-keys/dto/validate-api-key.dto.ts @@ -0,0 +1,21 @@ +import { IsString, IsOptional } from 'class-validator'; + +export class ValidateApiKeyDto { + @IsString() + apiKey: string; + + @IsOptional() + @IsString() + scope?: string; +} + +export class ValidateApiKeyResponseDto { + valid: boolean; + userId?: string; + scopes?: string[]; + rateLimit?: { + requests: number; + window: number; + }; + error?: string; +} diff --git a/services/mana-core-auth/src/api-keys/index.ts b/services/mana-core-auth/src/api-keys/index.ts new file mode 100644 index 000000000..67401a60d --- /dev/null +++ b/services/mana-core-auth/src/api-keys/index.ts @@ -0,0 +1,4 @@ +export * from './api-keys.module'; +export * from './api-keys.service'; +export * from './api-keys.controller'; +export * from './dto'; diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 46490796f..3245b5baa 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -3,17 +3,18 @@ import { ConfigModule } from '@nestjs/config'; import { ThrottlerModule } from '@nestjs/throttler'; import { APP_FILTER } from '@nestjs/core'; import configuration from './config/configuration'; +import { AdminModule } from './admin/admin.module'; +import { AiModule } from './ai/ai.module'; +import { ApiKeysModule } from './api-keys/api-keys.module'; import { AuthModule } from './auth/auth.module'; import { CreditsModule } from './credits/credits.module'; import { FeedbackModule } from './feedback/feedback.module'; +import { HealthModule } from './health/health.module'; import { ReferralsModule } from './referrals/referrals.module'; import { SettingsModule } from './settings/settings.module'; import { TagsModule } from './tags/tags.module'; -import { AiModule } from './ai/ai.module'; -import { HealthModule } from './health/health.module'; -import { MetricsModule } from './metrics'; import { AnalyticsModule } from './analytics'; -import { AdminModule } from './admin/admin.module'; +import { MetricsModule } from './metrics'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { LoggerModule } from './common/logger'; @@ -32,7 +33,9 @@ import { LoggerModule } from './common/logger'; LoggerModule, MetricsModule, AnalyticsModule, + AdminModule, AiModule, + ApiKeysModule, AuthModule, CreditsModule, FeedbackModule, @@ -40,7 +43,6 @@ import { LoggerModule } from './common/logger'; ReferralsModule, SettingsModule, TagsModule, - AdminModule, ], providers: [ { diff --git a/services/mana-core-auth/src/db/schema/api-keys.schema.ts b/services/mana-core-auth/src/db/schema/api-keys.schema.ts new file mode 100644 index 000000000..31491adea --- /dev/null +++ b/services/mana-core-auth/src/db/schema/api-keys.schema.ts @@ -0,0 +1,32 @@ +import { text, timestamp, jsonb, integer, index } from 'drizzle-orm/pg-core'; +import { authSchema, users } from './auth.schema'; + +/** + * API Keys table for programmatic access to services. + * Keys are hashed using SHA-256 for security - the full key is only shown once at creation. + */ +export const apiKeys = authSchema.table( + 'api_keys', + { + id: text('id').primaryKey(), // nanoid + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + name: text('name').notNull(), // User-friendly name for the key + keyPrefix: text('key_prefix').notNull(), // "sk_live_abc..." for display (first 12 chars) + keyHash: text('key_hash').notNull(), // SHA-256 hash of the full key + scopes: jsonb('scopes').$type().default(['stt', 'tts']).notNull(), // Allowed service scopes + rateLimitRequests: integer('rate_limit_requests').default(60).notNull(), // Requests per window + rateLimitWindow: integer('rate_limit_window').default(60).notNull(), // Window in seconds + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + }, + (table) => [ + index('api_keys_user_id_idx').on(table.userId), + index('api_keys_key_hash_idx').on(table.keyHash), + ] +); + +export type ApiKey = typeof apiKeys.$inferSelect; +export type NewApiKey = typeof apiKeys.$inferInsert; diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index 72a7970f2..f85eecfc8 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -1,3 +1,4 @@ +export * from './api-keys.schema'; export * from './auth.schema'; export * from './credits.schema'; export * from './feedback.schema'; diff --git a/services/mana-stt/app/auth.py b/services/mana-stt/app/auth.py index 60e540b72..40258c730 100644 --- a/services/mana-stt/app/auth.py +++ b/services/mana-stt/app/auth.py @@ -1,14 +1,18 @@ """ API Key Authentication for ManaCore STT Service -Simple API key authentication with rate limiting. -Keys are configured via environment variables. +Supports two authentication modes: +1. Local API keys: Configured via environment variables +2. External API keys: Validated via mana-core-auth service (when EXTERNAL_AUTH_ENABLED=true) Usage: + # Local keys API_KEYS=sk-key1:name1,sk-key2:name2 - - Or for unlimited internal access: INTERNAL_API_KEY=sk-internal-xxx + + # External auth (for user-created keys via mana.how) + EXTERNAL_AUTH_ENABLED=true + MANA_CORE_AUTH_URL=http://localhost:3001 """ import os @@ -21,6 +25,12 @@ from dataclasses import dataclass, field from fastapi import HTTPException, Security, Request from fastapi.security import APIKeyHeader +from .external_auth import ( + is_external_auth_enabled, + validate_api_key_external, + ExternalValidationResult, +) + logger = logging.getLogger(__name__) # Configuration @@ -106,6 +116,7 @@ class AuthResult: key_name: Optional[str] = None is_internal: bool = False rate_limit_remaining: Optional[int] = None + user_id: Optional[str] = None # Set when using external auth async def verify_api_key( @@ -115,6 +126,10 @@ async def verify_api_key( """ Verify API key and check rate limits. + Supports two authentication modes: + 1. External auth via mana-core-auth (for sk_live_ keys) + 2. Local auth via environment variables + Returns AuthResult with authentication status. Raises HTTPException if auth fails or rate limited. """ @@ -136,7 +151,52 @@ async def verify_api_key( headers={"WWW-Authenticate": "ApiKey"}, ) - # Validate key + # Try external auth first for sk_live_ keys (user-created keys via mana.how) + if api_key.startswith("sk_live_") and is_external_auth_enabled(): + external_result = await validate_api_key_external(api_key, "stt") + + if external_result is not None: + if external_result.valid: + # Use rate limits from external auth + rate_info = _rate_limits[api_key] + limit = external_result.rate_limit_requests + window = external_result.rate_limit_window + + if not rate_info.is_allowed(limit, window): + remaining = rate_info.remaining(limit, window) + logger.warning(f"Rate limit exceeded for external key") + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded. Try again in {window} seconds.", + headers={ + "X-RateLimit-Limit": str(limit), + "X-RateLimit-Remaining": str(remaining), + "X-RateLimit-Reset": str(int(time.time()) + window), + "Retry-After": str(window), + }, + ) + + remaining = rate_info.remaining(limit, window) + logger.debug(f"Authenticated external request from user {external_result.user_id} to {path}") + + return AuthResult( + authenticated=True, + key_name="external", + is_internal=False, + rate_limit_remaining=remaining, + user_id=external_result.user_id, + ) + else: + # External auth returned invalid + logger.warning(f"External auth failed: {external_result.error}") + raise HTTPException( + status_code=401, + detail=external_result.error or "Invalid API key.", + headers={"WWW-Authenticate": "ApiKey"}, + ) + # If external_result is None, fall through to local auth + + # Local auth: Validate key against environment variables if api_key not in _api_keys: logger.warning(f"Invalid API key attempt for {path}") raise HTTPException( diff --git a/services/mana-stt/app/external_auth.py b/services/mana-stt/app/external_auth.py new file mode 100644 index 000000000..6f64bd315 --- /dev/null +++ b/services/mana-stt/app/external_auth.py @@ -0,0 +1,145 @@ +""" +External API Key Validation via mana-core-auth + +When EXTERNAL_AUTH_ENABLED=true, API keys are validated against the +central mana-core-auth service. This allows users to create and manage +API keys from the mana.how web interface. + +Results are cached for 5 minutes to reduce load on the auth service. +""" + +import os +import time +import logging +import httpx +from typing import Optional +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +# Configuration +EXTERNAL_AUTH_ENABLED = os.getenv("EXTERNAL_AUTH_ENABLED", "false").lower() == "true" +MANA_CORE_AUTH_URL = os.getenv("MANA_CORE_AUTH_URL", "http://localhost:3001") +API_KEY_CACHE_TTL = int(os.getenv("API_KEY_CACHE_TTL", "300")) # 5 minutes +EXTERNAL_AUTH_TIMEOUT = float(os.getenv("EXTERNAL_AUTH_TIMEOUT", "5.0")) # seconds + + +@dataclass +class ExternalValidationResult: + """Result from external API key validation.""" + valid: bool + user_id: Optional[str] = None + scopes: Optional[list] = None + rate_limit_requests: int = 60 + rate_limit_window: int = 60 + error: Optional[str] = None + cached_at: float = 0.0 + + +# In-memory cache for validation results +# Key: API key, Value: ExternalValidationResult +_validation_cache: dict[str, ExternalValidationResult] = {} + + +def is_external_auth_enabled() -> bool: + """Check if external authentication is enabled.""" + return EXTERNAL_AUTH_ENABLED + + +def _get_cached_result(api_key: str) -> Optional[ExternalValidationResult]: + """Get cached validation result if still valid.""" + result = _validation_cache.get(api_key) + if result and (time.time() - result.cached_at) < API_KEY_CACHE_TTL: + return result + return None + + +def _cache_result(api_key: str, result: ExternalValidationResult): + """Cache a validation result.""" + result.cached_at = time.time() + _validation_cache[api_key] = result + + # Clean up old entries periodically (keep cache size manageable) + if len(_validation_cache) > 1000: + now = time.time() + expired_keys = [ + k for k, v in _validation_cache.items() + if (now - v.cached_at) >= API_KEY_CACHE_TTL + ] + for k in expired_keys: + del _validation_cache[k] + + +async def validate_api_key_external(api_key: str, scope: str) -> Optional[ExternalValidationResult]: + """ + Validate an API key against mana-core-auth service. + + Args: + api_key: The API key to validate (e.g., "sk_live_...") + scope: The required scope (e.g., "stt" or "tts") + + Returns: + ExternalValidationResult if external auth is enabled and the key was validated. + None if external auth is disabled or the service is unavailable (fallback to local). + """ + if not EXTERNAL_AUTH_ENABLED: + return None + + # Check cache first + cached = _get_cached_result(api_key) + if cached: + logger.debug(f"Using cached validation result for key prefix: {api_key[:12]}...") + # Check scope against cached result + if cached.valid and cached.scopes and scope not in cached.scopes: + return ExternalValidationResult( + valid=False, + error=f"API key does not have scope: {scope}", + ) + return cached + + # Call mana-core-auth validation endpoint + try: + async with httpx.AsyncClient(timeout=EXTERNAL_AUTH_TIMEOUT) as client: + response = await client.post( + f"{MANA_CORE_AUTH_URL}/api/v1/api-keys/validate", + json={"apiKey": api_key, "scope": scope}, + ) + + if response.status_code == 200: + data = response.json() + result = ExternalValidationResult( + valid=data.get("valid", False), + user_id=data.get("userId"), + scopes=data.get("scopes", []), + rate_limit_requests=data.get("rateLimit", {}).get("requests", 60), + rate_limit_window=data.get("rateLimit", {}).get("window", 60), + error=data.get("error"), + ) + _cache_result(api_key, result) + return result + else: + logger.warning( + f"External auth returned status {response.status_code}: {response.text}" + ) + # Don't cache errors - allow retry + return ExternalValidationResult( + valid=False, + error=f"Auth service returned {response.status_code}", + ) + + except httpx.TimeoutException: + logger.warning("External auth service timeout - falling back to local auth") + return None + except httpx.ConnectError: + logger.warning("Cannot connect to external auth service - falling back to local auth") + return None + except Exception as e: + logger.error(f"External auth error: {e}") + return None + + +def clear_cache(): + """Clear the validation cache (for testing or runtime updates).""" + global _validation_cache + _validation_cache.clear() + logger.info("External auth cache cleared") diff --git a/services/mana-stt/requirements.txt b/services/mana-stt/requirements.txt index 47da624dd..079d7d4a7 100644 --- a/services/mana-stt/requirements.txt +++ b/services/mana-stt/requirements.txt @@ -23,3 +23,6 @@ sentencepiece>=0.2.0 # Utilities numpy>=1.26.0 tqdm>=4.67.0 + +# External Auth (mana-core-auth integration) +httpx>=0.27.0 diff --git a/services/mana-tts/app/auth.py b/services/mana-tts/app/auth.py index 60e540b72..f632e0c88 100644 --- a/services/mana-tts/app/auth.py +++ b/services/mana-tts/app/auth.py @@ -1,14 +1,18 @@ """ -API Key Authentication for ManaCore STT Service +API Key Authentication for ManaCore TTS Service -Simple API key authentication with rate limiting. -Keys are configured via environment variables. +Supports two authentication modes: +1. Local API keys: Configured via environment variables +2. External API keys: Validated via mana-core-auth service (when EXTERNAL_AUTH_ENABLED=true) Usage: + # Local keys API_KEYS=sk-key1:name1,sk-key2:name2 - - Or for unlimited internal access: INTERNAL_API_KEY=sk-internal-xxx + + # External auth (for user-created keys via mana.how) + EXTERNAL_AUTH_ENABLED=true + MANA_CORE_AUTH_URL=http://localhost:3001 """ import os @@ -21,6 +25,12 @@ from dataclasses import dataclass, field from fastapi import HTTPException, Security, Request from fastapi.security import APIKeyHeader +from .external_auth import ( + is_external_auth_enabled, + validate_api_key_external, + ExternalValidationResult, +) + logger = logging.getLogger(__name__) # Configuration @@ -106,6 +116,7 @@ class AuthResult: key_name: Optional[str] = None is_internal: bool = False rate_limit_remaining: Optional[int] = None + user_id: Optional[str] = None # Set when using external auth async def verify_api_key( @@ -115,6 +126,10 @@ async def verify_api_key( """ Verify API key and check rate limits. + Supports two authentication modes: + 1. External auth via mana-core-auth (for sk_live_ keys) + 2. Local auth via environment variables + Returns AuthResult with authentication status. Raises HTTPException if auth fails or rate limited. """ @@ -136,7 +151,52 @@ async def verify_api_key( headers={"WWW-Authenticate": "ApiKey"}, ) - # Validate key + # Try external auth first for sk_live_ keys (user-created keys via mana.how) + if api_key.startswith("sk_live_") and is_external_auth_enabled(): + external_result = await validate_api_key_external(api_key, "tts") + + if external_result is not None: + if external_result.valid: + # Use rate limits from external auth + rate_info = _rate_limits[api_key] + limit = external_result.rate_limit_requests + window = external_result.rate_limit_window + + if not rate_info.is_allowed(limit, window): + remaining = rate_info.remaining(limit, window) + logger.warning(f"Rate limit exceeded for external key") + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded. Try again in {window} seconds.", + headers={ + "X-RateLimit-Limit": str(limit), + "X-RateLimit-Remaining": str(remaining), + "X-RateLimit-Reset": str(int(time.time()) + window), + "Retry-After": str(window), + }, + ) + + remaining = rate_info.remaining(limit, window) + logger.debug(f"Authenticated external request from user {external_result.user_id} to {path}") + + return AuthResult( + authenticated=True, + key_name="external", + is_internal=False, + rate_limit_remaining=remaining, + user_id=external_result.user_id, + ) + else: + # External auth returned invalid + logger.warning(f"External auth failed: {external_result.error}") + raise HTTPException( + status_code=401, + detail=external_result.error or "Invalid API key.", + headers={"WWW-Authenticate": "ApiKey"}, + ) + # If external_result is None, fall through to local auth + + # Local auth: Validate key against environment variables if api_key not in _api_keys: logger.warning(f"Invalid API key attempt for {path}") raise HTTPException( diff --git a/services/mana-tts/app/external_auth.py b/services/mana-tts/app/external_auth.py new file mode 100644 index 000000000..6f64bd315 --- /dev/null +++ b/services/mana-tts/app/external_auth.py @@ -0,0 +1,145 @@ +""" +External API Key Validation via mana-core-auth + +When EXTERNAL_AUTH_ENABLED=true, API keys are validated against the +central mana-core-auth service. This allows users to create and manage +API keys from the mana.how web interface. + +Results are cached for 5 minutes to reduce load on the auth service. +""" + +import os +import time +import logging +import httpx +from typing import Optional +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +# Configuration +EXTERNAL_AUTH_ENABLED = os.getenv("EXTERNAL_AUTH_ENABLED", "false").lower() == "true" +MANA_CORE_AUTH_URL = os.getenv("MANA_CORE_AUTH_URL", "http://localhost:3001") +API_KEY_CACHE_TTL = int(os.getenv("API_KEY_CACHE_TTL", "300")) # 5 minutes +EXTERNAL_AUTH_TIMEOUT = float(os.getenv("EXTERNAL_AUTH_TIMEOUT", "5.0")) # seconds + + +@dataclass +class ExternalValidationResult: + """Result from external API key validation.""" + valid: bool + user_id: Optional[str] = None + scopes: Optional[list] = None + rate_limit_requests: int = 60 + rate_limit_window: int = 60 + error: Optional[str] = None + cached_at: float = 0.0 + + +# In-memory cache for validation results +# Key: API key, Value: ExternalValidationResult +_validation_cache: dict[str, ExternalValidationResult] = {} + + +def is_external_auth_enabled() -> bool: + """Check if external authentication is enabled.""" + return EXTERNAL_AUTH_ENABLED + + +def _get_cached_result(api_key: str) -> Optional[ExternalValidationResult]: + """Get cached validation result if still valid.""" + result = _validation_cache.get(api_key) + if result and (time.time() - result.cached_at) < API_KEY_CACHE_TTL: + return result + return None + + +def _cache_result(api_key: str, result: ExternalValidationResult): + """Cache a validation result.""" + result.cached_at = time.time() + _validation_cache[api_key] = result + + # Clean up old entries periodically (keep cache size manageable) + if len(_validation_cache) > 1000: + now = time.time() + expired_keys = [ + k for k, v in _validation_cache.items() + if (now - v.cached_at) >= API_KEY_CACHE_TTL + ] + for k in expired_keys: + del _validation_cache[k] + + +async def validate_api_key_external(api_key: str, scope: str) -> Optional[ExternalValidationResult]: + """ + Validate an API key against mana-core-auth service. + + Args: + api_key: The API key to validate (e.g., "sk_live_...") + scope: The required scope (e.g., "stt" or "tts") + + Returns: + ExternalValidationResult if external auth is enabled and the key was validated. + None if external auth is disabled or the service is unavailable (fallback to local). + """ + if not EXTERNAL_AUTH_ENABLED: + return None + + # Check cache first + cached = _get_cached_result(api_key) + if cached: + logger.debug(f"Using cached validation result for key prefix: {api_key[:12]}...") + # Check scope against cached result + if cached.valid and cached.scopes and scope not in cached.scopes: + return ExternalValidationResult( + valid=False, + error=f"API key does not have scope: {scope}", + ) + return cached + + # Call mana-core-auth validation endpoint + try: + async with httpx.AsyncClient(timeout=EXTERNAL_AUTH_TIMEOUT) as client: + response = await client.post( + f"{MANA_CORE_AUTH_URL}/api/v1/api-keys/validate", + json={"apiKey": api_key, "scope": scope}, + ) + + if response.status_code == 200: + data = response.json() + result = ExternalValidationResult( + valid=data.get("valid", False), + user_id=data.get("userId"), + scopes=data.get("scopes", []), + rate_limit_requests=data.get("rateLimit", {}).get("requests", 60), + rate_limit_window=data.get("rateLimit", {}).get("window", 60), + error=data.get("error"), + ) + _cache_result(api_key, result) + return result + else: + logger.warning( + f"External auth returned status {response.status_code}: {response.text}" + ) + # Don't cache errors - allow retry + return ExternalValidationResult( + valid=False, + error=f"Auth service returned {response.status_code}", + ) + + except httpx.TimeoutException: + logger.warning("External auth service timeout - falling back to local auth") + return None + except httpx.ConnectError: + logger.warning("Cannot connect to external auth service - falling back to local auth") + return None + except Exception as e: + logger.error(f"External auth error: {e}") + return None + + +def clear_cache(): + """Clear the validation cache (for testing or runtime updates).""" + global _validation_cache + _validation_cache.clear() + logger.info("External auth cache cleared") diff --git a/services/mana-tts/requirements.txt b/services/mana-tts/requirements.txt index 50cf6a88b..fd09a0613 100644 --- a/services/mana-tts/requirements.txt +++ b/services/mana-tts/requirements.txt @@ -20,3 +20,6 @@ tqdm>=4.67.0 # Utilities aiofiles>=24.1.0 + +# External Auth (mana-core-auth integration) +httpx>=0.27.0