Merge pull request #130 from kikootwo/feature/api-tokens

Feature/api tokens
This commit is contained in:
kikootwo
2026-03-04 23:11:21 -05:00
committed by GitHub
30 changed files with 3013 additions and 82 deletions
@@ -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 });
}
})
);
}
+190
View File
@@ -0,0 +1,190 @@
/**
* Component: Admin API Token Management Routes
* 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 { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
import { generateApiToken } from '@/lib/utils/api-token';
import { z } from 'zod';
const logger = RMABLogger.create('API.Admin.ApiTokens');
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(), // Accepted for compatibility, but cannot differ from target user 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.
* Token role is always derived from the target user's current 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 });
}
// Enforce per-user token cap (count only active, non-expired tokens)
const activeTokenCount = await prisma.apiToken.count({
where: {
userId: targetUserId,
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
});
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
return NextResponse.json(
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
{ status: 403 }
);
}
// Security guard: token role must always match the target user's persisted role.
// This avoids role/identity mismatch (for example: acting as user A with admin role).
if (role && role !== targetUser.role) {
logger.warn('Admin attempted token role override that differs from target user role', {
requestedRole: role,
userActualRole: targetUser.role,
targetUser: targetUser.plexUsername,
createdBy: req.user!.username,
});
return NextResponse.json(
{
error: `Token role must match target user's role (${targetUser.role}).`,
},
{ status: 400 }
);
}
const tokenRole = targetUser.role;
// Generate the token
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
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 });
}
})
);
}
+59
View File
@@ -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 });
}
});
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Component: User API Token Routes (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 { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
import { generateApiToken } from '@/lib/utils/api-token';
import { z } from 'zod';
const logger = RMABLogger.create('API.User.ApiTokens');
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 });
}
// Enforce per-user token cap (count only active, non-expired tokens)
const activeTokenCount = await prisma.apiToken.count({
where: {
userId: req.user!.id,
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},
});
if (activeTokenCount >= MAX_TOKENS_PER_USER) {
return NextResponse.json(
{ error: `Token limit reached. Users may have at most ${MAX_TOKENS_PER_USER} active API tokens.` },
{ status: 403 }
);
}
// Generate the token
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
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 });
}
});
}