feat(auth): add API key management for STT/TTS services

- Add api_keys schema in mana-core-auth with SHA-256 hashing
- Create NestJS module with CRUD endpoints and validation
- Add external auth module to STT/TTS for sk_live_ key validation
- Create web UI page at /api-keys for key management
- Support rate limiting per key with configurable limits
- Cache validation results for 5 minutes to reduce auth service load

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-12 02:12:05 +01:00
parent 552dc10f25
commit 8b6ff0c679
18 changed files with 1238 additions and 16 deletions

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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<string>('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<ValidateApiKeyResponseDto> {
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,
},
};
}
}

View file

@ -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;
}

View file

@ -0,0 +1,2 @@
export * from './create-api-key.dto';
export * from './validate-api-key.dto';

View file

@ -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;
}

View file

@ -0,0 +1,4 @@
export * from './api-keys.module';
export * from './api-keys.service';
export * from './api-keys.controller';
export * from './dto';

View file

@ -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: [
{

View file

@ -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<string[]>().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;

View file

@ -1,3 +1,4 @@
export * from './api-keys.schema';
export * from './auth.schema';
export * from './credits.schema';
export * from './feedback.schema';

View file

@ -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(

View file

@ -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")

View file

@ -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

View file

@ -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(

View file

@ -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")

View file

@ -20,3 +20,6 @@ tqdm>=4.67.0
# Utilities
aiofiles>=24.1.0
# External Auth (mana-core-auth integration)
httpx>=0.27.0