Add per-user API tokens with admin override support

- Add userId field to ApiToken schema (the user identity the token acts as)
- Auth middleware resolves token identity via userId instead of createdById
- New /api/user/api-tokens routes for self-service token management
- Admin /api/admin/api-tokens routes support userId and role overrides
- API Tokens section on profile page for all users
- Admin API tab shows all tokens with user/role selectors
This commit is contained in:
Michael Borohovski
2026-03-03 12:23:57 -08:00
parent bfd624e120
commit 61b183542c
12 changed files with 1192 additions and 3 deletions
+62 -1
View File
@@ -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,47 @@ 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 } } },
});
if (!apiToken) return null;
// Check expiration
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
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)
const user = apiToken.tokenUser;
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 +94,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) {