mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user