From bfe3cb28b4514c391c7599850a8932e30772c27a Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 18 Aug 2026 10:05:28 +0300 Subject: [PATCH] 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. --- README.md | 2 +- app/api/admin/growth/route.ts | 18 ++++-- lib/admin-api-token.ts | 58 +++++++++++++++++++ tests/api/analytics-scoreboard.test.ts | 76 ++++++++++++++++++++++++ tests/unit/lib/admin-api-token.test.ts | 80 ++++++++++++++++++++++++++ 5 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 lib/admin-api-token.ts create mode 100644 tests/unit/lib/admin-api-token.test.ts diff --git a/README.md b/README.md index 2e6eadc..eb6c6b7 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ Behavior when disabled: - `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available. - `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT` to the browser-reachable MinIO origin (for example `http://localhost:9000` locally, or `https://minio.example.com` when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom `R2_PRESIGN_ENDPOINT` values without rebuilding or editing `next.config.ts`. - `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact. -- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`. Off by default, and nothing leaves the instance either way. +- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`, or as JSON on `/api/admin/growth` by a script sending `Authorization: Bearer $OPENFRAME_ADMIN_API_TOKEN` (at least 32 characters, unset by default, in which case an admin session is the only way in). Off by default, and nothing leaves the instance either way. For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts: diff --git a/app/api/admin/growth/route.ts b/app/api/admin/growth/route.ts index 2e75810..331b68f 100644 --- a/app/api/admin/growth/route.ts +++ b/app/api/admin/growth/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from 'next/server'; import { auth } from '@/lib/auth'; +import { isAdminApiTokenRequest } from '@/lib/admin-api-token'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard'; import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; @@ -9,12 +10,17 @@ import { logError } from '@/lib/logger'; // the scoreboard instead of somebody retyping it into a table. export async function GET(request: NextRequest) { try { - const session = await auth(); - if (!session?.user?.id) { - return apiErrors.unauthorized(); - } - if (!session.user.isAdmin) { - return apiErrors.forbidden('Admin access required'); + // A bearer token stands in for the admin session so the weekly digest can + // read this without a browser. Unset by default, in which case the only way + // in is still an admin session. + if (!isAdminApiTokenRequest(request)) { + const session = await auth(); + if (!session?.user?.id) { + return apiErrors.unauthorized(); + } + if (!session.user.isAdmin) { + return apiErrors.forbidden('Admin access required'); + } } if (!isProductAnalyticsEnabled()) { diff --git a/lib/admin-api-token.ts b/lib/admin-api-token.ts new file mode 100644 index 0000000..2fcd6b6 --- /dev/null +++ b/lib/admin-api-token.ts @@ -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 `. */ +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); +} diff --git a/tests/api/analytics-scoreboard.test.ts b/tests/api/analytics-scoreboard.test.ts index e3249eb..517741e 100644 --- a/tests/api/analytics-scoreboard.test.ts +++ b/tests/api/analytics-scoreboard.test.ts @@ -13,6 +13,9 @@ import { getCohortComparison, getScoreboard, } from '@/lib/analytics/scoreboard'; +import { GET as growthRoute } from '@/app/api/admin/growth/route'; +import { apiRequest, callRoute, readData } from '../helpers/request'; +import { signedInAs, signedOut } from '../helpers/session'; import { createUser } from '../factories'; function daysAgo(days: number): Date { @@ -271,3 +274,76 @@ describe('getCohortComparison', () => { expect(comparison?.rows.every((row) => row.signups === 0)).toBe(true); }); }); + +// The token path exists so a scheduled digest can read this endpoint with no +// browser. It is the only way into admin data that carries no session, so the +// cases that matter are the ones where it must not open: unset, wrong, and a +// caller who is signed in but not an admin. +describe('GET /api/admin/growth', () => { + const TOKEN = 'wq7Fr2Tn8Vb4Kd1Mw6Hs9Lp3Cf5Gj0Ye'; + + function growthRequest(headers?: Record) { + return callRoute(growthRoute, apiRequest('/api/admin/growth', { headers })); + } + + it('refuses an anonymous caller when no token is configured', async () => { + signedOut(); + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', ''); + + // The header a caller would send if they had guessed the scheme but there is + // nothing to guess: an unset token must never match. + const response = await growthRequest({ authorization: `Bearer ${TOKEN}` }); + expect(response.status).toBe(401); + }); + + it('refuses a signed-in caller who is not an admin', async () => { + const user = await createUser(); + signedInAs({ id: user.id, email: user.email, isAdmin: false }); + + const response = await growthRequest(); + expect(response.status).toBe(403); + }); + + it('refuses a bearer token that is not the configured one', async () => { + signedOut(); + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN); + + const response = await growthRequest({ + authorization: 'Bearer wq7Fr2Tn8Vb4Kd1Mw6Hs9Lp3Cf5Gj0Yf', + }); + expect(response.status).toBe(401); + }); + + it('serves the scoreboard to a caller carrying the configured token', async () => { + signedOut(); + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN); + + const paying = await createUser({ subscriptionStatus: 'ACTIVE' }); + await seedEvent({ name: 'SIGNUP_COMPLETED', occurredAt: daysAgo(1), userId: paying.id }); + + const response = await growthRequest({ authorization: `Bearer ${TOKEN}` }); + expect(response.status).toBe(200); + + const scoreboard = await readData(response); + expect(scoreboard.paidAccounts.map((row: { userId: string }) => row.userId)).toContain( + paying.id + ); + // Rates ride along with each week; the digest reads them rather than + // recomputing the denominators. + expect(scoreboard.weeks.at(-1)).toHaveProperty('rates'); + }); + + it('still refuses the token when analytics are off, without saying so', async () => { + signedOut(); + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false'); + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN); + + // Authorized, but the flag is off: a 400, not a scoreboard. + const authorized = await growthRequest({ authorization: `Bearer ${TOKEN}` }); + expect(authorized.status).toBe(400); + + // Unauthorized callers must not learn the flag's state from the status code. + const anonymous = await growthRequest(); + expect(anonymous.status).toBe(401); + }); +}); diff --git a/tests/unit/lib/admin-api-token.test.ts b/tests/unit/lib/admin-api-token.test.ts new file mode 100644 index 0000000..75e0da7 --- /dev/null +++ b/tests/unit/lib/admin-api-token.test.ts @@ -0,0 +1,80 @@ +// The guard that lets a script read admin data without a session. +// +// Every case here is a way the endpoint could end up open: a token short enough +// to guess, a comparison that accepts a prefix, or an absent env var read as an +// absent header and waved through. + +import { describe, expect, it, afterEach, vi } from 'vitest'; +import { + MIN_ADMIN_API_TOKEN_LENGTH, + getAdminApiToken, + isAdminApiTokenRequest, +} from '@/lib/admin-api-token'; + +// Written out rather than generated from the constant: a test whose input comes +// from the code under test stops testing the length rule the moment it changes. +const TOKEN = 'zq4Xr7Tn2Vb9Kd5Mw8Hs3Lp6Cf1Gj0Ye'; +const SHORT = 'zq4Xr7Tn2Vb9Kd5Mw8Hs3Lp6Cf1Gj0Y'; + +function requestWith(headers: Record): Request { + return new Request('http://localhost:3000/api/admin/growth', { headers }); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('getAdminApiToken', () => { + it('is null when nothing is configured', () => { + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', ''); + expect(getAdminApiToken()).toBeNull(); + }); + + it('is null for a token shorter than the minimum', () => { + expect(SHORT).toHaveLength(MIN_ADMIN_API_TOKEN_LENGTH - 1); + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', SHORT); + expect(getAdminApiToken()).toBeNull(); + }); + + it('trims surrounding whitespace, which a pasted env value carries', () => { + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', ` ${TOKEN}\n`); + expect(getAdminApiToken()).toBe(TOKEN); + }); +}); + +describe('isAdminApiTokenRequest', () => { + it('refuses every request when no token is configured', () => { + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', ''); + expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${TOKEN}` }))).toBe(false); + expect(isAdminApiTokenRequest(requestWith({}))).toBe(false); + }); + + it('refuses a matching header when the configured token is too short', () => { + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', SHORT); + expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${SHORT}` }))).toBe(false); + }); + + it('accepts the configured token, whatever case the scheme is written in', () => { + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN); + expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${TOKEN}` }))).toBe(true); + expect(isAdminApiTokenRequest(requestWith({ authorization: `bearer ${TOKEN}` }))).toBe(true); + }); + + it('refuses a prefix, a suffix and a different token of the same length', () => { + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN); + expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${SHORT}` }))).toBe(false); + expect(isAdminApiTokenRequest(requestWith({ authorization: `Bearer ${TOKEN}x` }))).toBe(false); + expect( + isAdminApiTokenRequest( + requestWith({ authorization: 'Bearer aq4Xr7Tn2Vb9Kd5Mw8Hs3Lp6Cf1Gj0Ye' }) + ) + ).toBe(false); + }); + + it('refuses a missing header, a bare token and another scheme', () => { + vi.stubEnv('OPENFRAME_ADMIN_API_TOKEN', TOKEN); + expect(isAdminApiTokenRequest(requestWith({}))).toBe(false); + expect(isAdminApiTokenRequest(requestWith({ authorization: TOKEN }))).toBe(false); + expect(isAdminApiTokenRequest(requestWith({ authorization: `Basic ${TOKEN}` }))).toBe(false); + }); +});