feat(admin): let a token read the growth scoreboard without a session

The weekly digest reads /api/admin/growth from a script, which has no browser
and therefore no NextAuth session. The alternative was copying a session cookie
out of a browser by hand: those are JWTs with a 30-day lifetime, so a scheduled
job built on one stops working a month later and reports nothing rather than
reporting a failure.

The token path is off unless OPENFRAME_ADMIN_API_TOKEN is set, so an instance
that never sets it keeps session-only admin access. A value under 32 characters
is treated as no token at all: behind this header sit every paying account's
name, email and usage, and a short token is a guessable path to all of it.
Comparison runs over SHA-256 digests so it stays constant time without leaking
the token's length.
This commit is contained in:
2026-08-18 10:22:42 +03:00
parent 32164db15c
commit bfe3cb28b4
5 changed files with 227 additions and 7 deletions
+58
View File
@@ -0,0 +1,58 @@
/**
* A machine caller for the read-only admin endpoints.
*
* The growth scoreboard is read once a week by a digest script that has no
* browser, and therefore no NextAuth session. The alternative was copying a
* session cookie out of a browser by hand: those are JWTs with a 30-day
* lifetime, so a scheduled job built on one stops working a month later and
* reports nothing rather than reporting a failure.
*
* Off unless `OPENFRAME_ADMIN_API_TOKEN` is set, so a self-hosted instance that
* never sets it keeps session-only admin access.
*/
import { createHash, timingSafeEqual } from 'node:crypto';
/**
* The shortest token this accepts.
*
* Behind this header sit every paying account's name, email and usage, so a
* short token is a guessable path to all of it. A token under this length is
* treated as no token at all rather than as a weaker one: failing closed makes
* a bad value visible on the first call, where silently accepting it would
* leave the endpoint open and look fine.
*/
export const MIN_ADMIN_API_TOKEN_LENGTH = 32;
/** The configured token, or null when it is absent or too short to be safe. */
export function getAdminApiToken(): string | null {
const raw = process.env.OPENFRAME_ADMIN_API_TOKEN?.trim();
if (!raw || raw.length < MIN_ADMIN_API_TOKEN_LENGTH) return null;
return raw;
}
/**
* Constant-time comparison over SHA-256 digests.
*
* Hashing first is not about secrecy, it is about length: `timingSafeEqual`
* throws on unequal-length buffers, and the obvious length check before it
* would leak the token's length through timing. Digests are always 32 bytes.
*/
function tokensMatch(candidate: string, expected: string): boolean {
const digest = (value: string) => createHash('sha256').update(value, 'utf8').digest();
return timingSafeEqual(digest(candidate), digest(expected));
}
/** True when the request carries `Authorization: Bearer <the configured token>`. */
export function isAdminApiTokenRequest(request: Request): boolean {
const expected = getAdminApiToken();
if (!expected) return false;
const header = request.headers.get('authorization');
if (!header) return false;
const [scheme, ...rest] = header.trim().split(/\s+/);
if (scheme.toLowerCase() !== 'bearer' || rest.length !== 1) return false;
return tokensMatch(rest[0], expected);
}