managarten/services/mana-auth/src/services/api-keys.ts
Till JS 4318948980 feat(mana-auth): add guilds, api-keys, me, security, auth routes
Complete the mana-auth Hono service with all remaining endpoints
from mana-core-auth.

Added:
- routes/auth.ts: Full auth flow (register, login, logout, validate,
  password reset, profile, change-password, account deletion,
  security events) with lockout + security event logging
- routes/guilds.ts: Guild CRUD, member management, invitations
  (delegates to Better Auth org plugin + mana-credits for pools)
- routes/api-keys.ts: API key generation, listing, revocation,
  validation (sk_live_* format, SHA-256 hashed)
- routes/me.ts: GDPR data export/delete (Articles 17 & 20)
- services/security.ts: SecurityEventsService (fire-and-forget audit)
  + AccountLockoutService (5 failures/15min → 30min lockout)
- services/api-keys.ts: Key generation, validation, scope checks

Updated:
- index.ts: Wire all routes with proper middleware (JWT, service auth)

Service now has ~1,900 LOC covering all functionality from the
original ~11,500 LOC NestJS mana-core-auth (83% reduction).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 02:57:22 +01:00

103 lines
2.5 KiB
TypeScript

/**
* API Keys Service — Generate, validate, revoke service API keys
*/
import { eq, and, isNull, sql } from 'drizzle-orm';
import { randomBytes, createHash } from 'crypto';
import type { Database } from '../db/connection';
import { NotFoundError } from '../lib/errors';
// Schema imported inline to avoid circular deps
import { apiKeys } from '../db/schema/api-keys';
export class ApiKeysService {
constructor(private db: Database) {}
private generateKey(): string {
return `sk_live_${randomBytes(32).toString('hex')}`;
}
private hashKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
private getKeyPrefix(key: string): string {
return key.replace('sk_live_', '').slice(0, 8);
}
async listUserApiKeys(userId: string) {
return this.db
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
scopes: apiKeys.scopes,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
revokedAt: apiKeys.revokedAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId));
}
async createApiKey(userId: string, data: { name: string; scopes?: string[] }) {
const key = this.generateKey();
const hash = this.hashKey(key);
const prefix = this.getKeyPrefix(key);
const [created] = await this.db
.insert(apiKeys)
.values({
userId,
name: data.name,
keyHash: hash,
keyPrefix: prefix,
scopes: data.scopes || ['stt', 'tts'],
})
.returning();
return { ...created, key }; // Full key returned ONLY on creation
}
async revokeApiKey(userId: string, keyId: string) {
const [revoked] = await this.db
.update(apiKeys)
.set({ revokedAt: new Date() })
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId)))
.returning();
if (!revoked) throw new NotFoundError('API key not found');
return { success: true };
}
async validateApiKey(apiKey: string, scope?: string) {
const hash = this.hashKey(apiKey);
const [key] = await this.db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, hash), isNull(apiKeys.revokedAt)))
.limit(1);
if (!key) return { valid: false };
// Check scope if provided
if (scope && key.scopes && !(key.scopes as string[]).includes(scope)) {
return { valid: false, reason: 'scope_denied' };
}
// Update lastUsedAt (fire-and-forget)
this.db
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, key.id))
.catch(() => {});
return {
valid: true,
userId: key.userId,
scopes: key.scopes,
rateLimit: { requests: 60, window: 60 },
};
}
}