mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge pull request #127 from borski/feature/per-user-api-tokens
Add per-user API tokens with security hardening
This commit is contained in:
@@ -68,6 +68,8 @@ model User {
|
||||
goodreadsShelves GoodreadsShelf[]
|
||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||
watchedSeries WatchedSeries[]
|
||||
watchedAuthors WatchedAuthor[]
|
||||
|
||||
@@ -498,6 +500,34 @@ model ReportedIssue {
|
||||
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// API TOKEN TABLE
|
||||
// Static API tokens for programmatic access (alternative to JWT)
|
||||
// Documentation: documentation/backend/services/api-tokens.md
|
||||
// ============================================================================
|
||||
|
||||
model ApiToken {
|
||||
id String @id @default(uuid())
|
||||
name String // User-friendly label (e.g., "Home Assistant", "Webhook")
|
||||
tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext)
|
||||
tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2")
|
||||
role String @default("user") // Token role: 'admin' or 'user'
|
||||
createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens)
|
||||
userId String @map("user_id") // The user identity this token acts as
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
expiresAt DateTime? @map("expires_at") // null = never expires
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tokenHash])
|
||||
@@index([createdById])
|
||||
@@index([userId])
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
model GoodreadsShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
@@ -210,6 +210,7 @@ export const getTabValidation = (
|
||||
return validated.paths;
|
||||
case 'ebook':
|
||||
case 'bookdate':
|
||||
case 'api':
|
||||
return true; // These tabs handle their own saving
|
||||
default:
|
||||
return false;
|
||||
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
||||
{ id: 'api' as const, label: 'API', icon: '🔑' },
|
||||
];
|
||||
|
||||
@@ -243,4 +243,4 @@ export interface BookDateModel {
|
||||
/**
|
||||
* Tab identifier type
|
||||
*/
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api';
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
|
||||
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
||||
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
||||
import { NotificationsTab } from './tabs/NotificationsTab';
|
||||
import { ApiTab } from './tabs/ApiTab/ApiTab';
|
||||
|
||||
// Types and Helpers
|
||||
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
||||
@@ -346,8 +347,11 @@ export default function AdminSettings() {
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === 'api' && <ApiTab />}
|
||||
|
||||
{/* Save Button (only for tabs that save through main page) */}
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
|
||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Component: API Token Management Tab (Admin)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
role: string;
|
||||
createdBy: string;
|
||||
createdById: string;
|
||||
tokenUser: string;
|
||||
tokenUserId: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function ApiTab() {
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
|
||||
const [newTokenUserId, setNewTokenUserId] = useState('');
|
||||
const [newTokenRole, setNewTokenRole] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/api-tokens');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTokens(data.tokens);
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load API tokens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/users');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, user selector just won't populate
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTokens();
|
||||
fetchUsers();
|
||||
}, [fetchTokens, fetchUsers]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newTokenName.trim()) {
|
||||
setError('Token name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let expiresAt: string | null = null;
|
||||
if (newTokenExpiry !== 'never') {
|
||||
const date = new Date();
|
||||
switch (newTokenExpiry) {
|
||||
case '30d': date.setDate(date.getDate() + 30); break;
|
||||
case '90d': date.setDate(date.getDate() + 90); break;
|
||||
case '1y': date.setFullYear(date.getFullYear() + 1); break;
|
||||
}
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
|
||||
const body: Record<string, any> = { name: newTokenName.trim(), expiresAt };
|
||||
if (newTokenUserId) body.userId = newTokenUserId;
|
||||
if (newTokenRole) body.role = newTokenRole;
|
||||
|
||||
const response = await fetchWithAuth('/api/admin/api-tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCreatedToken(data.fullToken);
|
||||
setNewTokenName('');
|
||||
setNewTokenExpiry('never');
|
||||
setNewTokenUserId('');
|
||||
setNewTokenRole('');
|
||||
setShowCreateForm(false);
|
||||
await fetchTokens();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to create token');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to create token');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/api-tokens/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTokens(tokens.filter((t) => t.id !== id));
|
||||
} else {
|
||||
setError('Failed to revoke token');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to revoke token');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (createdToken) {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// When a user is selected, default the role to their actual role
|
||||
const handleUserChange = (userId: string) => {
|
||||
setNewTokenUserId(userId);
|
||||
if (userId) {
|
||||
const selectedUser = users.find((u) => u.id === userId);
|
||||
if (selectedUser && !newTokenRole) {
|
||||
setNewTokenRole(selectedUser.role);
|
||||
}
|
||||
} else {
|
||||
setNewTokenRole('');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage API tokens for all users. Create tokens for any user with any role for programmatic access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreatedToken(null)}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
placeholder="e.g., Home Assistant, Webhook"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={newTokenExpiry}
|
||||
onChange={(e) => setNewTokenExpiry(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
User (acts as)
|
||||
</label>
|
||||
<select
|
||||
value={newTokenUserId}
|
||||
onChange={(e) => handleUserChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Current user (default)</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.plexUsername} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Role override
|
||||
</label>
|
||||
<select
|
||||
value={newTokenRole}
|
||||
onChange={(e) => setNewTokenRole(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">User's default role</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreateForm(false); setNewTokenName(''); setNewTokenExpiry('never'); setNewTokenUserId(''); setNewTokenRole(''); }}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
Create New Token
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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 className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Acts As</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Role</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Created By</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.tokenUser}</td>
|
||||
<td className="py-3 px-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
token.role === 'admin'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{token.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(token.id)}
|
||||
disabled={deletingId === token.id}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Component: API Token Delete Route
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/api-tokens/[id]
|
||||
* Revoke (delete) an API token
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiToken.delete({ where: { id } });
|
||||
|
||||
logger.info('API token revoked', { tokenId: id, name: token.name, revokedBy: req.user!.username });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Component: Admin API Token Management Routes
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||
|
||||
const API_TOKEN_PREFIX = 'rmab_';
|
||||
const TOKEN_RANDOM_BYTES = 32;
|
||||
|
||||
const CreateTokenSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
userId: z.string().uuid().optional(), // Admin can specify which user the token acts as
|
||||
role: z.enum(['admin', 'user']).optional(), // Admin can override the token role
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/api-tokens
|
||||
* List ALL API tokens across all users
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
include: {
|
||||
createdBy: {
|
||||
select: { id: true, plexUsername: true },
|
||||
},
|
||||
tokenUser: {
|
||||
select: { id: true, plexUsername: true, role: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const sanitized = tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
role: t.role,
|
||||
createdBy: t.createdBy.plexUsername,
|
||||
createdById: t.createdBy.id,
|
||||
tokenUser: t.tokenUser.plexUsername,
|
||||
tokenUserId: t.tokenUser.id,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
expiresAt: t.expiresAt,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tokens: sanitized });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/api-tokens
|
||||
* Create a new API token. Admin can optionally specify userId and role.
|
||||
* Returns the full token ONCE.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||
requireAdmin(req, async () => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body);
|
||||
|
||||
// Determine target user (defaults to the admin themselves)
|
||||
const targetUserId = userId || req.user!.id;
|
||||
|
||||
// Verify the target user exists
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, role: true, plexUsername: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'Target user not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Determine token role (defaults to target user's role)
|
||||
const tokenRole = role || targetUser.role;
|
||||
|
||||
// Generate the token
|
||||
const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex');
|
||||
const fullToken = `${API_TOKEN_PREFIX}${randomPart}`;
|
||||
const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex');
|
||||
const tokenPrefix = fullToken.substring(0, 12); // "rmab_" + 7 chars
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
role: tokenRole,
|
||||
createdById: req.user!.id,
|
||||
userId: targetUserId,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Admin API token created', {
|
||||
tokenId: apiToken.id,
|
||||
name,
|
||||
createdBy: req.user!.username,
|
||||
targetUser: targetUser.plexUsername,
|
||||
role: tokenRole,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
token: {
|
||||
id: apiToken.id,
|
||||
name: apiToken.name,
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
role: apiToken.role,
|
||||
expiresAt: apiToken.expiresAt,
|
||||
createdAt: apiToken.createdAt,
|
||||
},
|
||||
// Full token is returned ONLY on creation
|
||||
fullToken,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Component: User API Token Delete Route (self-service)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/api-tokens/[id]
|
||||
* Revoke (delete) one of the current user's own API tokens
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only allow deleting own tokens
|
||||
if (token.userId !== req.user!.id) {
|
||||
return NextResponse.json({ error: 'Token not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiToken.delete({ where: { id } });
|
||||
|
||||
logger.info('User API token revoked', { tokenId: id, name: token.name, userId: req.user!.id });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke user API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Component: User API Token Routes (self-service)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||
|
||||
const API_TOKEN_PREFIX = 'rmab_';
|
||||
const TOKEN_RANDOM_BYTES = 32;
|
||||
|
||||
const CreateTokenSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/api-tokens
|
||||
* List the current user's own API tokens
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const sanitized = tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
role: t.role,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
expiresAt: t.expiresAt,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tokens: sanitized });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list user API tokens', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/api-tokens
|
||||
* Create a token for the current user with their own role. Returns full token ONCE.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { name, expiresAt } = CreateTokenSchema.parse(body);
|
||||
|
||||
// Look up the user's actual role from the database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Generate the token
|
||||
const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex');
|
||||
const fullToken = `${API_TOKEN_PREFIX}${randomPart}`;
|
||||
const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex');
|
||||
const tokenPrefix = fullToken.substring(0, 12); // "rmab_" + 7 chars
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
role: user.role, // Always the user's own role
|
||||
createdById: req.user!.id,
|
||||
userId: req.user!.id, // Token acts as the current user
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('User API token created', { tokenId: apiToken.id, name, userId: req.user!.id });
|
||||
|
||||
return NextResponse.json({
|
||||
token: {
|
||||
id: apiToken.id,
|
||||
name: apiToken.name,
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
role: apiToken.role,
|
||||
expiresAt: apiToken.expiresAt,
|
||||
createdAt: apiToken.createdAt,
|
||||
},
|
||||
fullToken,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create user API token', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
|
||||
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
||||
|
||||
const statConfig = [
|
||||
@@ -240,6 +241,9 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* API Tokens */}
|
||||
<ApiTokensSection />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Component: API Tokens Section (Profile Page)
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
role: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function ApiTokensSection() {
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/api-tokens');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTokens(data.tokens);
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load API tokens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTokens();
|
||||
}, [fetchTokens]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newTokenName.trim()) {
|
||||
setError('Token name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let expiresAt: string | null = null;
|
||||
if (newTokenExpiry !== 'never') {
|
||||
const date = new Date();
|
||||
switch (newTokenExpiry) {
|
||||
case '30d': date.setDate(date.getDate() + 30); break;
|
||||
case '90d': date.setDate(date.getDate() + 90); break;
|
||||
case '1y': date.setFullYear(date.getFullYear() + 1); break;
|
||||
}
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth('/api/user/api-tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newTokenName.trim(), expiresAt }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCreatedToken(data.fullToken);
|
||||
setNewTokenName('');
|
||||
setNewTokenExpiry('never');
|
||||
setShowCreateForm(false);
|
||||
await fetchTokens();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Failed to create token');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to create token');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/user/api-tokens/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTokens(tokens.filter((t) => t.id !== id));
|
||||
} else {
|
||||
setError('Failed to revoke token');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to revoke token');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (createdToken) {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
API Tokens
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Create personal API tokens for programmatic access to the API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newly created token banner */}
|
||||
{createdToken && (
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Token created successfully! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{createdToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreatedToken(null)}
|
||||
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create token form */}
|
||||
{showCreateForm ? (
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
placeholder="e.g., Home Assistant, Webhook"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Expiration
|
||||
</label>
|
||||
<select
|
||||
value={newTokenExpiry}
|
||||
onChange={(e) => setNewTokenExpiry(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="never">Never</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newTokenName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Token'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreateForm(false); setNewTokenName(''); setNewTokenExpiry('never'); }}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||
>
|
||||
Create New Token
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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 className="mt-2 text-sm">No API tokens yet</p>
|
||||
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||
<td className="py-3 px-2">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{formatDate(token.lastUsedAt)}</td>
|
||||
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||
{token.expiresAt ? (
|
||||
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||
{formatDate(token.expiresAt)}
|
||||
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
'Never'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(token.id)}
|
||||
disabled={deletingId === token.id}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage instructions */}
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||
</p>
|
||||
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,15 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Auth');
|
||||
|
||||
const API_TOKEN_PREFIX = 'rmab_';
|
||||
|
||||
export interface AuthenticatedRequest extends NextRequest {
|
||||
user?: TokenPayload & { id: string };
|
||||
}
|
||||
@@ -32,9 +35,66 @@ function extractToken(request: NextRequest): string | null {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate via static API token (rmab_ prefix).
|
||||
* Returns a synthetic TokenPayload if valid, null otherwise.
|
||||
* Updates lastUsedAt asynchronously.
|
||||
*/
|
||||
async function authenticateApiToken(token: string): Promise<(TokenPayload & { id: string }) | null> {
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: {
|
||||
tokenUser: {
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken) return null;
|
||||
|
||||
// Check expiration
|
||||
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
||||
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject tokens for soft-deleted users
|
||||
const user = apiToken.tokenUser;
|
||||
if (!user || user.deletedAt) {
|
||||
logger.warn('API token used by deleted or missing user', {
|
||||
tokenPrefix: apiToken.tokenPrefix,
|
||||
userId: user?.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update lastUsedAt (fire-and-forget)
|
||||
prisma.apiToken.update({
|
||||
where: { id: apiToken.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
}).catch(() => {});
|
||||
|
||||
// Use the token's target user (userId), not the creator (createdById)
|
||||
return {
|
||||
sub: user.id,
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
role: apiToken.role,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require authentication
|
||||
* Verifies JWT token and adds user to request
|
||||
* Verifies JWT token or static API token and adds user to request
|
||||
*/
|
||||
export async function requireAuth(
|
||||
request: NextRequest,
|
||||
@@ -53,6 +113,26 @@ export async function requireAuth(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a static API token
|
||||
if (token.startsWith(API_TOKEN_PREFIX)) {
|
||||
const apiUser = await authenticateApiToken(token);
|
||||
if (!apiUser) {
|
||||
logger.error('API token authentication failed');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired API token',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = apiUser;
|
||||
return handler(authenticatedRequest);
|
||||
}
|
||||
|
||||
// Fall back to JWT verification
|
||||
const payload = verifyAccessToken(token);
|
||||
|
||||
if (!payload) {
|
||||
@@ -69,9 +149,13 @@ export async function requireAuth(
|
||||
// Verify user still exists in database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || user.deletedAt) {
|
||||
logger.error('User not found in database', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const current = buckets.get(key);
|
||||
|
||||
if (!current || now >= current.resetAt) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
|
||||
}
|
||||
|
||||
if (current.count >= maxRequests) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
current.count += 1;
|
||||
return {
|
||||
allowed: true,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
|
||||
}
|
||||
|
||||
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export const createPrismaMock = () => ({
|
||||
bookDateSwipe: createModelMock(),
|
||||
goodreadsShelf: createModelMock(),
|
||||
goodreadsBookMapping: createModelMock(),
|
||||
apiToken: createModelMock(),
|
||||
work: createModelMock(),
|
||||
workAsin: createModelMock(),
|
||||
watchedSeries: createModelMock(),
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const verifyAccessTokenMock = vi.fn();
|
||||
@@ -29,6 +31,11 @@ const makeRequest = (authHeader?: string) => ({
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to create a valid API token hash for testing
|
||||
const createTestApiToken = (token: string) => {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
};
|
||||
|
||||
describe('auth middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -159,6 +166,98 @@ describe('auth middleware', () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects JWT tokens for soft-deleted users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn());
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.message).toMatch(/user not found/i);
|
||||
});
|
||||
|
||||
describe('API token authentication', () => {
|
||||
const testToken = 'rmab_test1234567890abcdef';
|
||||
const testTokenHash = createTestApiToken(testToken);
|
||||
|
||||
it('rejects API tokens for soft-deleted users', async () => {
|
||||
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
tokenHash: testTokenHash,
|
||||
role: 'user',
|
||||
expiresAt: null,
|
||||
tokenUser: {
|
||||
id: 'user-1',
|
||||
plexUsername: 'deleteduser',
|
||||
role: 'user',
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn());
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.message).toMatch(/invalid.*expired/i);
|
||||
});
|
||||
|
||||
it('rejects API tokens for missing users', async () => {
|
||||
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
tokenHash: testTokenHash,
|
||||
role: 'user',
|
||||
expiresAt: null,
|
||||
tokenUser: null,
|
||||
});
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn());
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.message).toMatch(/invalid.*expired/i);
|
||||
});
|
||||
|
||||
it('accepts valid API tokens for active users', async () => {
|
||||
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
tokenHash: testTokenHash,
|
||||
role: 'user',
|
||||
expiresAt: null,
|
||||
tokenUser: {
|
||||
id: 'user-1',
|
||||
plexUsername: 'activeuser',
|
||||
role: 'user',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
prismaMock.apiToken.update.mockResolvedValue({});
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const handler = vi.fn(async (req: any) =>
|
||||
NextResponse.json({ ok: true, userId: req.user?.id })
|
||||
);
|
||||
const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, handler);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(payload.userId).toBe('user-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns current user from token', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Component: API Token Rate Limit Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
checkApiTokenCreateRateLimit,
|
||||
checkApiTokenRevokeRateLimit,
|
||||
} from '@/lib/utils/apiTokenRateLimit';
|
||||
|
||||
describe('API Token Rate Limiting', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('checkApiTokenCreateRateLimit', () => {
|
||||
it('allows requests under the limit', () => {
|
||||
const actorId = 'user-create-1';
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = checkApiTokenCreateRateLimit(actorId);
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks requests over the limit (10/min)', () => {
|
||||
const actorId = 'user-create-2';
|
||||
|
||||
// Use up the limit
|
||||
for (let i = 0; i < 10; i++) {
|
||||
checkApiTokenCreateRateLimit(actorId);
|
||||
}
|
||||
|
||||
// 11th request should be blocked
|
||||
const result = checkApiTokenCreateRateLimit(actorId);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('resets after the window expires', () => {
|
||||
const actorId = 'user-create-3';
|
||||
|
||||
// Use up the limit
|
||||
for (let i = 0; i < 10; i++) {
|
||||
checkApiTokenCreateRateLimit(actorId);
|
||||
}
|
||||
|
||||
// Should be blocked
|
||||
expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(false);
|
||||
|
||||
// Advance time past the window (60 seconds)
|
||||
vi.advanceTimersByTime(61 * 1000);
|
||||
|
||||
// Should be allowed again
|
||||
expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks different actors separately', () => {
|
||||
const actor1 = 'user-create-4';
|
||||
const actor2 = 'user-create-5';
|
||||
|
||||
// Use up actor1's limit
|
||||
for (let i = 0; i < 10; i++) {
|
||||
checkApiTokenCreateRateLimit(actor1);
|
||||
}
|
||||
|
||||
// actor1 should be blocked
|
||||
expect(checkApiTokenCreateRateLimit(actor1).allowed).toBe(false);
|
||||
|
||||
// actor2 should still be allowed
|
||||
expect(checkApiTokenCreateRateLimit(actor2).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkApiTokenRevokeRateLimit', () => {
|
||||
it('allows requests under the limit', () => {
|
||||
const actorId = 'user-revoke-1';
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks requests over the limit (20/min)', () => {
|
||||
const actorId = 'user-revoke-2';
|
||||
|
||||
// Use up the limit
|
||||
for (let i = 0; i < 20; i++) {
|
||||
checkApiTokenRevokeRateLimit(actorId);
|
||||
}
|
||||
|
||||
// 21st request should be blocked
|
||||
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns correct retryAfterSeconds', () => {
|
||||
const actorId = 'user-revoke-3';
|
||||
|
||||
// Use up the limit
|
||||
for (let i = 0; i < 20; i++) {
|
||||
checkApiTokenRevokeRateLimit(actorId);
|
||||
}
|
||||
|
||||
// Advance 30 seconds into the window
|
||||
vi.advanceTimersByTime(30 * 1000);
|
||||
|
||||
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||
expect(result.allowed).toBe(false);
|
||||
// Should have ~30 seconds left
|
||||
expect(result.retryAfterSeconds).toBeLessThanOrEqual(30);
|
||||
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user