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,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<ApiResult<ApiKey[]>> {
return client.get<ApiKey[]>('/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<ApiResult<ApiKeyWithSecret>> {
return client.post<ApiKeyWithSecret>('/api/v1/api-keys', dto);
},
/**
* Revoke an API key
*/
async revoke(id: string): Promise<ApiResult<void>> {
return client.delete<void>(`/api/v1/api-keys/${id}`);
},
};

View file

@ -0,0 +1,420 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Button, Input, Card, PageHeader, Badge } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { apiKeysService, type ApiKey, type ApiKeyWithSecret } from '$lib/api/api-keys';
// State
let loading = $state(true);
let apiKeys = $state<ApiKey[]>([]);
let error = $state<string | null>(null);
// Create modal state
let showCreateModal = $state(false);
let creating = $state(false);
let newKeyName = $state('');
let createdKey = $state<ApiKeyWithSecret | null>(null);
let copied = $state(false);
// Revoke state
let revoking = $state<string | null>(null);
// Computed: active keys (not revoked)
let activeKeys = $derived(apiKeys.filter((k) => !k.revokedAt));
let revokedKeys = $derived(apiKeys.filter((k) => k.revokedAt));
onMount(async () => {
await loadKeys();
});
async function loadKeys() {
loading = true;
error = null;
const result = await apiKeysService.list();
if (result.error) {
error = result.error;
} else {
apiKeys = result.data || [];
}
loading = false;
}
async function handleCreate() {
if (!newKeyName.trim()) return;
creating = true;
const result = await apiKeysService.create({ name: newKeyName.trim() });
if (result.error) {
error = result.error;
} else if (result.data) {
createdKey = result.data;
// Add to list (without the secret key)
apiKeys = [
...apiKeys,
{
...result.data,
key: undefined as unknown as string, // Remove secret from local state
},
];
}
creating = false;
newKeyName = '';
}
async function handleRevoke(id: string) {
revoking = id;
const result = await apiKeysService.revoke(id);
if (result.error) {
error = result.error;
} else {
// Update local state
apiKeys = apiKeys.map((k) =>
k.id === id ? { ...k, revokedAt: new Date().toISOString() } : k
);
}
revoking = null;
}
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function closeCreateModal() {
showCreateModal = false;
createdKey = null;
newKeyName = '';
copied = false;
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<div>
<PageHeader
title="API Keys"
description="Manage your API keys for programmatic access to STT and TTS services"
size="lg"
>
{#snippet actions()}
<Button onclick={() => (showCreateModal = true)}>
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Create API Key
</Button>
{/snippet}
</PageHeader>
{#if loading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else}
<div class="space-y-6">
{#if error}
<div
class="rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{error}
</div>
{/if}
<!-- Active Keys -->
<Card>
<div class="p-6">
<div class="flex items-center gap-3 mb-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20 text-green-600 dark:text-green-400"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold">Active Keys</h2>
<p class="text-sm text-muted-foreground">
{activeKeys.length} active key{activeKeys.length !== 1 ? 's' : ''}
</p>
</div>
</div>
{#if activeKeys.length === 0}
<div class="text-center py-8 text-muted-foreground">
<svg
class="h-12 w-12 mx-auto mb-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<p class="font-medium">No API keys yet</p>
<p class="text-sm mt-1">Create your first API key to get started</p>
</div>
{:else}
<div class="space-y-3">
{#each activeKeys as key (key.id)}
<div class="flex items-center justify-between p-4 rounded-lg bg-surface-hover">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium">{key.name}</span>
<Badge variant="secondary">{key.scopes.join(', ')}</Badge>
</div>
<div class="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<code class="bg-muted px-2 py-0.5 rounded font-mono text-xs"
>{key.keyPrefix}</code
>
<span>Created: {formatDate(key.createdAt)}</span>
<span>Last used: {formatDate(key.lastUsedAt)}</span>
</div>
</div>
<Button
variant="destructive"
size="sm"
loading={revoking === key.id}
onclick={() => handleRevoke(key.id)}
>
{revoking === key.id ? 'Revoking...' : 'Revoke'}
</Button>
</div>
{/each}
</div>
{/if}
</div>
</Card>
<!-- Revoked Keys (if any) -->
{#if revokedKeys.length > 0}
<Card>
<div class="p-6">
<div class="flex items-center gap-3 mb-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold">Revoked Keys</h2>
<p class="text-sm text-muted-foreground">
{revokedKeys.length} revoked key{revokedKeys.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<div class="space-y-3">
{#each revokedKeys as key (key.id)}
<div
class="flex items-center justify-between p-4 rounded-lg bg-surface-hover opacity-60"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium line-through">{key.name}</span>
<Badge variant="destructive">Revoked</Badge>
</div>
<div class="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<code class="bg-muted px-2 py-0.5 rounded font-mono text-xs"
>{key.keyPrefix}</code
>
<span>Revoked: {formatDate(key.revokedAt)}</span>
</div>
</div>
</div>
{/each}
</div>
</div>
</Card>
{/if}
<!-- Usage Instructions -->
<Card>
<div class="p-6">
<div class="flex items-center gap-3 mb-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold">How to Use</h2>
<p class="text-sm text-muted-foreground">Include your API key in requests</p>
</div>
</div>
<div class="space-y-4">
<div>
<p class="text-sm font-medium mb-2">Speech-to-Text (STT)</p>
<pre class="bg-muted p-3 rounded-lg text-sm overflow-x-auto"><code
>curl -X POST https://stt-api.mana.how/transcribe \
-H "X-API-Key: sk_live_your_key_here" \
-F "audio=@audio.mp3"</code
></pre>
</div>
<div>
<p class="text-sm font-medium mb-2">Text-to-Speech (TTS)</p>
<pre class="bg-muted p-3 rounded-lg text-sm overflow-x-auto"><code
>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</code
></pre>
</div>
</div>
</div>
</Card>
</div>
{/if}
</div>
<!-- Create API Key Modal -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<button class="absolute inset-0 bg-black/50" onclick={closeCreateModal} aria-label="Close modal"
></button>
<!-- Modal -->
<div class="relative bg-surface rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
{#if createdKey}
<!-- Success: Show the key -->
<div class="text-center">
<div
class="flex h-12 w-12 mx-auto mb-4 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20 text-green-600 dark:text-green-400"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">API Key Created</h3>
<p class="text-sm text-muted-foreground mb-4">
Copy your API key now. You won't be able to see it again.
</p>
<div class="relative mb-4">
<code
class="block w-full p-3 bg-muted rounded-lg text-sm font-mono break-all text-left"
>
{createdKey.key}
</code>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 p-2 hover:bg-surface-hover rounded"
onclick={() => copyToClipboard(createdKey!.key)}
>
{#if copied}
<svg
class="h-5 w-5 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{:else}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
{/if}
</button>
</div>
{#if copied}
<p class="text-sm text-green-600 dark:text-green-400 mb-4">Copied to clipboard!</p>
{/if}
<Button onclick={closeCreateModal} class="w-full">Done</Button>
</div>
{:else}
<!-- Form: Create key -->
<h3 class="text-lg font-semibold mb-4">Create API Key</h3>
<div class="mb-4">
<label for="keyName" class="block text-sm font-medium mb-2">Key Name</label>
<Input
type="text"
id="keyName"
bind:value={newKeyName}
placeholder="e.g., Production API Key"
/>
<p class="mt-1 text-xs text-muted-foreground">A friendly name to identify this key</p>
</div>
<div class="flex gap-3">
<Button variant="secondary" onclick={closeCreateModal} class="flex-1">Cancel</Button>
<Button
onclick={handleCreate}
loading={creating}
disabled={!newKeyName.trim()}
class="flex-1"
>
{creating ? 'Creating...' : 'Create Key'}
</Button>
</div>
{/if}
</div>
</div>
{/if}

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