mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
✨ 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:
parent
552dc10f25
commit
8b6ff0c679
18 changed files with 1238 additions and 16 deletions
58
apps/manacore/apps/web/src/lib/api/api-keys.ts
Normal file
58
apps/manacore/apps/web/src/lib/api/api-keys.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
420
apps/manacore/apps/web/src/routes/(app)/api-keys/+page.svelte
Normal file
420
apps/manacore/apps/web/src/routes/(app)/api-keys/+page.svelte
Normal 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}
|
||||
59
services/mana-core-auth/src/api-keys/api-keys.controller.ts
Normal file
59
services/mana-core-auth/src/api-keys/api-keys.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/api-keys/api-keys.module.ts
Normal file
10
services/mana-core-auth/src/api-keys/api-keys.module.ts
Normal 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 {}
|
||||
173
services/mana-core-auth/src/api-keys/api-keys.service.ts
Normal file
173
services/mana-core-auth/src/api-keys/api-keys.service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
2
services/mana-core-auth/src/api-keys/dto/index.ts
Normal file
2
services/mana-core-auth/src/api-keys/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-api-key.dto';
|
||||
export * from './validate-api-key.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
4
services/mana-core-auth/src/api-keys/index.ts
Normal file
4
services/mana-core-auth/src/api-keys/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './api-keys.module';
|
||||
export * from './api-keys.service';
|
||||
export * from './api-keys.controller';
|
||||
export * from './dto';
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
32
services/mana-core-auth/src/db/schema/api-keys.schema.ts
Normal file
32
services/mana-core-auth/src/db/schema/api-keys.schema.ts
Normal 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;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './api-keys.schema';
|
||||
export * from './auth.schema';
|
||||
export * from './credits.schema';
|
||||
export * from './feedback.schema';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
145
services/mana-stt/app/external_auth.py
Normal file
145
services/mana-stt/app/external_auth.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
145
services/mana-tts/app/external_auth.py
Normal file
145
services/mana-tts/app/external_auth.py
Normal 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")
|
||||
|
|
@ -20,3 +20,6 @@ tqdm>=4.67.0
|
|||
|
||||
# Utilities
|
||||
aiofiles>=24.1.0
|
||||
|
||||
# External Auth (mana-core-auth integration)
|
||||
httpx>=0.27.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue