Files
openmaic/OpenMAIC/app/api/access-code/verify/route.ts
2026-08-16 14:58:47 +08:00

123 lines
4.0 KiB
TypeScript

import { cookies } from 'next/headers';
import { timingSafeEqual } from 'crypto';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { createAccessToken } from '@/lib/server/access-token';
import {
accessCodeRateLimiter,
resolveAccessCodeRateLimitConfig,
resolveAccessCodeRateLimitKey,
} from '@/lib/server/access-code-rate-limit';
import {
ACCESS_CODE_COOKIE_NAME,
resolveAccessCodeSessionTtlSeconds,
} from '@/lib/server/access-token-policy';
import { capBodyStream } from '@/lib/server/capped-stream';
import { hasValidOpsMutationOrigin } from '@/lib/server/ops-access';
const ACCESS_CODE_REQUEST_MAX_BYTES = 8 * 1024;
function exceedsDeclaredRequestLimit(request: Request): boolean {
const declaredLength = request.headers.get('content-length')?.trim();
if (!declaredLength || !/^\d+$/.test(declaredLength)) {
return false;
}
return Number(declaredLength) > ACCESS_CODE_REQUEST_MAX_BYTES;
}
async function readAccessCodeBody(
request: Request,
): Promise<{ body: { code?: string } } | { response: Response }> {
if (exceedsDeclaredRequestLimit(request)) {
return {
response: apiError('INVALID_REQUEST', 413, 'Access code request is too large'),
};
}
if (!request.body) {
return { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') };
}
const cappedBody = capBodyStream(request.body, ACCESS_CODE_REQUEST_MAX_BYTES);
let rawBody: string;
try {
rawBody = await new Response(cappedBody.stream).text();
} catch {
if (cappedBody.exceeded()) {
return {
response: apiError('INVALID_REQUEST', 413, 'Access code request is too large'),
};
}
return { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') };
}
try {
const parsed: unknown = JSON.parse(rawBody);
return {
body:
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as { code?: string })
: {},
};
} catch {
return { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') };
}
}
export async function POST(request: Request) {
const accessCode = process.env.ACCESS_CODE;
if (!accessCode) {
return apiSuccess({ valid: true });
}
// Reject browser cross-site/simple requests before touching the conservative
// shared limiter bucket. Otherwise any external page could lock every
// operator out with a handful of no-CORS text/plain requests.
if (!hasValidOpsMutationOrigin(request)) {
return apiError('FORBIDDEN', 403, 'Cross-origin access code request denied');
}
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) {
return apiError('INVALID_REQUEST', 415, 'Access code request requires application/json');
}
const bodyResult = await readAccessCodeBody(request);
if ('response' in bodyResult) {
return bodyResult.response;
}
const { body } = bodyResult;
const clientKey = resolveAccessCodeRateLimitKey(request);
const rateLimit = accessCodeRateLimiter.consume(clientKey, resolveAccessCodeRateLimitConfig());
if (!rateLimit.allowed) {
const response = apiError('RATE_LIMITED', 429, 'Too many access code attempts');
response.headers.set('Retry-After', String(rateLimit.retryAfterSeconds));
return response;
}
// Constant-time comparison
if (!body.code) {
return apiError('INVALID_REQUEST', 401, 'Invalid access code');
}
const encoder = new TextEncoder();
const a = encoder.encode(body.code);
const b = encoder.encode(accessCode);
if (a.byteLength !== b.byteLength || !timingSafeEqual(a, b)) {
return apiError('INVALID_REQUEST', 401, 'Invalid access code');
}
accessCodeRateLimiter.reset(clientKey);
const ttlSeconds = resolveAccessCodeSessionTtlSeconds(
process.env.ACCESS_CODE_SESSION_TTL_SECONDS,
);
const token = createAccessToken(accessCode);
const cookieStore = await cookies();
cookieStore.set(ACCESS_CODE_COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: ttlSeconds,
secure: process.env.NODE_ENV === 'production',
});
return apiSuccess({ valid: true });
}