From bcd1ff0db8100908813dfd7bf3364fdb018b1d33 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Fri, 28 Aug 2026 17:03:54 +0200 Subject: [PATCH 1/3] feat(frontend): render wizard previews from core, not from the backend router --- apps/frontend/astro.config.ts | 6 -- apps/frontend/e2e/app.spec.ts | 6 ++ apps/frontend/e2e/stats-rank.spec.ts | 6 ++ apps/frontend/e2e/stubCardApi.ts | 20 ++++ apps/frontend/e2e/wizard-auth.spec.ts | 6 ++ apps/frontend/package.json | 1 - .../src/wizard/components/Card/SvgInline.tsx | 26 ++--- apps/frontend/src/wizard/mock-http.ts | 96 ------------------- apps/frontend/src/wizard/renderCard.ts | 46 +++++++++ pnpm-lock.yaml | 3 - 10 files changed, 90 insertions(+), 126 deletions(-) create mode 100644 apps/frontend/e2e/stubCardApi.ts delete mode 100644 apps/frontend/src/wizard/mock-http.ts create mode 100644 apps/frontend/src/wizard/renderCard.ts diff --git a/apps/frontend/astro.config.ts b/apps/frontend/astro.config.ts index 4e0191c38d3c1..d5c44fd553ca8 100644 --- a/apps/frontend/astro.config.ts +++ b/apps/frontend/astro.config.ts @@ -116,11 +116,5 @@ export default defineConfig({ }, ], }, - // The backend code the wizard reuses imports `pg`, which never runs in the browser. - build: { - rolldownOptions: { - external: ["pg"], - }, - }, }, }); diff --git a/apps/frontend/e2e/app.spec.ts b/apps/frontend/e2e/app.spec.ts index 63b2b5e674be0..9d32cafdd1a88 100644 --- a/apps/frontend/e2e/app.spec.ts +++ b/apps/frontend/e2e/app.spec.ts @@ -1,7 +1,13 @@ import { expect, test } from "@playwright/test"; +import { stubCardApi } from "./stubCardApi"; + const REPO_URL = "https://github.com/stats-organization/github-stats-extended"; +test.beforeEach(async ({ page }) => { + await stubCardApi(page); +}); + test("load initial page correctly", async ({ page }) => { await page.goto(""); diff --git a/apps/frontend/e2e/stats-rank.spec.ts b/apps/frontend/e2e/stats-rank.spec.ts index b1180e2f74040..67a22ba7237a1 100644 --- a/apps/frontend/e2e/stats-rank.spec.ts +++ b/apps/frontend/e2e/stats-rank.spec.ts @@ -1,5 +1,11 @@ import { expect, test } from "@playwright/test"; +import { stubCardApi } from "./stubCardApi"; + +test.beforeEach(async ({ page }) => { + await stubCardApi(page); +}); + test("selecting 'None' progress style hides the rank circle", async ({ page, }) => { diff --git a/apps/frontend/e2e/stubCardApi.ts b/apps/frontend/e2e/stubCardApi.ts new file mode 100644 index 0000000000000..2f6f25a9ae723 --- /dev/null +++ b/apps/frontend/e2e/stubCardApi.ts @@ -0,0 +1,20 @@ +import type { Page } from "@playwright/test"; + +/** + * Fulfils the card endpoints: the `Display` stage fetches `https:///api…`, which nothing serves here. + * + * @param page - The page to install the route handler on. + */ +export async function stubCardApi(page: Page): Promise { + // Pathname, not a `**/api**` glob: that also matches module URLs like `/src/wizard/api/user.ts`. + // Install before a spec's own route for one `/api` endpoint, which then wins. + await page.route( + (url) => url.pathname === "/api" || url.pathname.startsWith("/api/"), + (route) => + route.fulfill({ + status: 200, + contentType: "image/svg+xml", + body: '', + }), + ); +} diff --git a/apps/frontend/e2e/wizard-auth.spec.ts b/apps/frontend/e2e/wizard-auth.spec.ts index 5e7e054a72f18..1a25a4ebddcc6 100644 --- a/apps/frontend/e2e/wizard-auth.spec.ts +++ b/apps/frontend/e2e/wizard-auth.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; +import { stubCardApi } from "./stubCardApi"; + /** * Puts the SPA into an authenticated state without contacting GitHub by * stubbing the OAuth code exchange and the follow-up user-access lookup: @@ -26,6 +28,10 @@ async function mockAuthEndpoints(page: Page): Promise { } test.describe("the wizard auth-driven stage transition", () => { + test.beforeEach(async ({ page }) => { + await stubCardApi(page); + }); + test("auto-advances from Login to Select a Card once authenticated", async ({ page, }) => { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3c1bf9f115620..a4b0d1eeee596 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,7 +16,6 @@ "@astrojs/react": "^6.0.2", "@astrojs/starlight": "^0.41.7", "@reduxjs/toolkit": "^2.12.0", - "@stats-organization/github-readme-stats-backend": "workspace:^", "@stats-organization/github-readme-stats-core": "workspace:^", "@tailwindcss/vite": "^4.3.3", "astro": "^7.2.0", diff --git a/apps/frontend/src/wizard/components/Card/SvgInline.tsx b/apps/frontend/src/wizard/components/Card/SvgInline.tsx index c5cd9b47307a0..a42692132773c 100644 --- a/apps/frontend/src/wizard/components/Card/SvgInline.tsx +++ b/apps/frontend/src/wizard/components/Card/SvgInline.tsx @@ -1,5 +1,3 @@ -// @ts-expect-error type info should be added later -import { router } from "@stats-organization/github-readme-stats-backend"; import { loadConfigFromEnv } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; import { useEffect, useRef, useState } from "react"; @@ -12,7 +10,7 @@ import { useIsAuthenticated, useUserToken, } from "../../../redux/selectors/userSelectors.js"; -import { createMockRequest, createMockResponse } from "../../mock-http.js"; +import { renderCard } from "../../renderCard.js"; interface SvgInlineProps { url: string; @@ -56,7 +54,6 @@ export function SvgInline(props: SvgInlineProps): JSX.Element { setLoaded(false); let body: string; - let status; if (isAuthenticated && (!userToken || userToken === "placeholderPAT")) { // waiting for backend call to private-access @@ -65,24 +62,13 @@ export function SvgInline(props: SvgInlineProps): JSX.Element { if (stage === 4 && !isAuthenticated) { const res = await axios.get(url); + if (res.status >= 300) { + console.error("failed to fetch SVG"); + return; + } body = res.data; - status = res.status; } else { - const req = createMockRequest({ - method: "GET", - url, - }); - const res = createMockResponse(); - // will be solved by npm package - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await router(req, res); - body = res._getBody() as string; - status = res._getStatusCode(); - } - - if (status >= 300) { - console.error("failed to fetch/generate SVG"); - return; + body = (await renderCard(url)).content; } if (!isCurrent) { diff --git a/apps/frontend/src/wizard/mock-http.ts b/apps/frontend/src/wizard/mock-http.ts deleted file mode 100644 index d62af77f2a1bb..0000000000000 --- a/apps/frontend/src/wizard/mock-http.ts +++ /dev/null @@ -1,96 +0,0 @@ -type HeaderMap = Partial>; - -type CreateMockRequestOptions = { - url: string; - headers?: HeaderMap; -} & ( - | { - method: "GET"; - } - | { - method: "POST"; - body: unknown; - } -); - -type CreateMockRequestResult = { - url: string; - headers: HeaderMap; -} & ( - | { - method: "GET"; - } - | { - method: "POST"; - body: unknown; - } -); - -export function createMockRequest( - options: CreateMockRequestOptions, -): CreateMockRequestResult { - const { headers = {}, ...rest } = options; - - return { ...rest, headers }; -} - -interface CreateMockResponseResult { - statusCode: number; - chunks: Array; - - setHeader(name: string, value: string): void; - getHeader(name: string): string | undefined; - getHeaders(): HeaderMap; - - write(chunk: unknown): void; - end(chunk: unknown): void; - - // --- Helpers for inspection --- - _getStatusCode(): number; - _getHeaders(): HeaderMap; - _getBody(): unknown; -} - -export function createMockResponse(): CreateMockResponseResult { - const statusCode = 200; - const headers: HeaderMap = {}; - const chunks: Array = []; - - const res: CreateMockResponseResult = { - statusCode, - chunks, - - setHeader(name, value) { - headers[name.toLowerCase()] = value; - }, - getHeader(name) { - return headers[name.toLowerCase()]; - }, - getHeaders() { - return { ...headers }; - }, - - write(chunk) { - if (typeof chunk !== "string") { - chunk = String(chunk); - } - chunks.push(chunk); - }, - - end(chunk) { - res.write(chunk); - }, - - _getStatusCode() { - return statusCode; - }, - _getHeaders() { - return { ...headers }; - }, - _getBody() { - return chunks.join(""); - }, - }; - - return res; -} diff --git a/apps/frontend/src/wizard/renderCard.ts b/apps/frontend/src/wizard/renderCard.ts new file mode 100644 index 0000000000000..6e161bbf7dad1 --- /dev/null +++ b/apps/frontend/src/wizard/renderCard.ts @@ -0,0 +1,46 @@ +import { + api, + gist, + pin, + topLangs, + wakatime, +} from "@stats-organization/github-readme-stats-core"; + +/** + * What a core api handler returns: + * a rendered card, or a rendered error card. + */ +interface CardResult { + status: string; + content: string; +} + +/** Core's api handlers are still JavaScript, so every param they destructure is inferred as required. */ +type CardHandler = (query: Record) => Promise; + +const CARD_HANDLERS: Record = { + "/api": api as CardHandler, + "/api/gist": gist as CardHandler, + "/api/pin": pin as CardHandler, + "/api/top-langs": topLangs as CardHandler, + "/api/wakatime": wakatime as CardHandler, +}; + +/** + * Renders a card in the browser from the URL the wizard built for it. + * The wizard's PAT arrives through `loadConfigFromEnv` instead. + * + * @param url - Absolute card URL, e.g. `https://host/api/top-langs?username=x`. + * @returns The rendered card, or the rendered error card when a param is rejected. + * @throws When `url` is not one of the card endpoints. + */ +export async function renderCard(url: string): Promise { + const { pathname, searchParams } = new URL(url); + + const handler = CARD_HANDLERS[pathname]; + if (!handler) { + throw new Error(`No card renderer for "${pathname}"`); + } + + return handler(Object.fromEntries(searchParams)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fba5d942ac8c..cc848f3a4814c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,9 +121,6 @@ importers: '@reduxjs/toolkit': specifier: ^2.12.0 version: 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1))(react@19.2.8) - '@stats-organization/github-readme-stats-backend': - specifier: workspace:^ - version: link:../backend '@stats-organization/github-readme-stats-core': specifier: workspace:^ version: link:../../packages/core From bb26c7ace370e6c39ce7ecf4728b9e3995e0b004 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Fri, 28 Aug 2026 17:30:33 +0200 Subject: [PATCH 2/3] test: change e2e api/ stub approach --- apps/frontend/astro.config.ts | 24 +++++++++++++++++++++++- apps/frontend/e2e/app.spec.ts | 6 ------ apps/frontend/e2e/stats-rank.spec.ts | 6 ------ apps/frontend/e2e/stubCardApi.ts | 20 -------------------- apps/frontend/e2e/wizard-auth.spec.ts | 6 ------ apps/frontend/playwright.config.ts | 9 +++++++-- 6 files changed, 30 insertions(+), 41 deletions(-) delete mode 100644 apps/frontend/e2e/stubCardApi.ts diff --git a/apps/frontend/astro.config.ts b/apps/frontend/astro.config.ts index d5c44fd553ca8..06c2f5d529e04 100644 --- a/apps/frontend/astro.config.ts +++ b/apps/frontend/astro.config.ts @@ -98,7 +98,29 @@ export default defineConfig({ }), ], vite: { - plugins: [tailwindcss()], + plugins: [ + tailwindcss(), + + // The e2e run sets `STUB_CARD_API`: answer the card endpoints here rather than + // proxy them to a `pnpm dev:backend` that is not running. + !!process.env.STUB_CARD_API && { + name: "stub-card-api", + // In `configureServer`'s body, so it runs ahead of Vite's proxy middleware. + configureServer(server) { + server.middlewares.use((req, res, next) => { + const pathname = req.url?.split("?")[0] ?? ""; + if (pathname !== "/api" && !pathname.startsWith("/api/")) { + next(); + return; + } + res.setHeader("Content-Type", "image/svg+xml"); + res.end( + '', + ); + }); + }, + }, + ], /* * On Vercel the same deployment serves `/api` and `/frontend`, so the docs can reference cards by root-relative path. * Locally the two are separate servers, so forward `/api` to `pnpm dev:backend` to keep those paths working. diff --git a/apps/frontend/e2e/app.spec.ts b/apps/frontend/e2e/app.spec.ts index 9d32cafdd1a88..63b2b5e674be0 100644 --- a/apps/frontend/e2e/app.spec.ts +++ b/apps/frontend/e2e/app.spec.ts @@ -1,13 +1,7 @@ import { expect, test } from "@playwright/test"; -import { stubCardApi } from "./stubCardApi"; - const REPO_URL = "https://github.com/stats-organization/github-stats-extended"; -test.beforeEach(async ({ page }) => { - await stubCardApi(page); -}); - test("load initial page correctly", async ({ page }) => { await page.goto(""); diff --git a/apps/frontend/e2e/stats-rank.spec.ts b/apps/frontend/e2e/stats-rank.spec.ts index 67a22ba7237a1..b1180e2f74040 100644 --- a/apps/frontend/e2e/stats-rank.spec.ts +++ b/apps/frontend/e2e/stats-rank.spec.ts @@ -1,11 +1,5 @@ import { expect, test } from "@playwright/test"; -import { stubCardApi } from "./stubCardApi"; - -test.beforeEach(async ({ page }) => { - await stubCardApi(page); -}); - test("selecting 'None' progress style hides the rank circle", async ({ page, }) => { diff --git a/apps/frontend/e2e/stubCardApi.ts b/apps/frontend/e2e/stubCardApi.ts deleted file mode 100644 index 2f6f25a9ae723..0000000000000 --- a/apps/frontend/e2e/stubCardApi.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Page } from "@playwright/test"; - -/** - * Fulfils the card endpoints: the `Display` stage fetches `https:///api…`, which nothing serves here. - * - * @param page - The page to install the route handler on. - */ -export async function stubCardApi(page: Page): Promise { - // Pathname, not a `**/api**` glob: that also matches module URLs like `/src/wizard/api/user.ts`. - // Install before a spec's own route for one `/api` endpoint, which then wins. - await page.route( - (url) => url.pathname === "/api" || url.pathname.startsWith("/api/"), - (route) => - route.fulfill({ - status: 200, - contentType: "image/svg+xml", - body: '', - }), - ); -} diff --git a/apps/frontend/e2e/wizard-auth.spec.ts b/apps/frontend/e2e/wizard-auth.spec.ts index 1a25a4ebddcc6..5e7e054a72f18 100644 --- a/apps/frontend/e2e/wizard-auth.spec.ts +++ b/apps/frontend/e2e/wizard-auth.spec.ts @@ -1,8 +1,6 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; -import { stubCardApi } from "./stubCardApi"; - /** * Puts the SPA into an authenticated state without contacting GitHub by * stubbing the OAuth code exchange and the follow-up user-access lookup: @@ -28,10 +26,6 @@ async function mockAuthEndpoints(page: Page): Promise { } test.describe("the wizard auth-driven stage transition", () => { - test.beforeEach(async ({ page }) => { - await stubCardApi(page); - }); - test("auto-advances from Login to Select a Card once authenticated", async ({ page, }) => { diff --git a/apps/frontend/playwright.config.ts b/apps/frontend/playwright.config.ts index 81684b84182e9..05f1ce21fb4cd 100644 --- a/apps/frontend/playwright.config.ts +++ b/apps/frontend/playwright.config.ts @@ -79,7 +79,12 @@ export default defineConfig({ command: "pnpm run dev", url: baseURL, reuseExistingServer: !process.env["CI"], - // `astro dev` detaches itself in an AI-agent shell, which reads as exiting early. - env: { ASTRO_DEV_BACKGROUND: "1" }, + env: { + // `astro dev` detaches itself in an AI-agent shell, which reads as exiting early. + ASTRO_DEV_BACKGROUND: "1", + // `STUB_CARD_API` has the dev server answer `/api` itself, instead of proxying + // the card endpoints to a backend that is not running here. + STUB_CARD_API: "1", + }, }, }); From 84452f0d3d47c6457808cc937e03556805646f98 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Fri, 28 Aug 2026 13:52:37 +0200 Subject: [PATCH 3/3] feat(backend): encrypt stored GitHub access tokens --- apps/backend/api-renamed/downgrade.js | 28 +-- apps/backend/api-renamed/user-access.js | 9 +- apps/backend/package.json | 1 + apps/backend/scripts/encrypt-access-tokens.js | 22 +++ apps/backend/src/common/database.js | 157 ++++++++++++---- apps/backend/src/common/tokenEncryption.js | 162 +++++++++++++++++ apps/backend/tests/database.test.js | 170 ++++++++++++++++++ apps/backend/tests/tokenEncryption.test.js | 153 ++++++++++++++++ apps/frontend/src/content/docs/docs/deploy.md | 25 +++ knip.jsonc | 2 +- 10 files changed, 682 insertions(+), 47 deletions(-) create mode 100644 apps/backend/scripts/encrypt-access-tokens.js create mode 100644 apps/backend/src/common/tokenEncryption.js create mode 100644 apps/backend/tests/database.test.js create mode 100644 apps/backend/tests/tokenEncryption.test.js diff --git a/apps/backend/api-renamed/downgrade.js b/apps/backend/api-renamed/downgrade.js index e52600d777dde..2f2e9677ec680 100644 --- a/apps/backend/api-renamed/downgrade.js +++ b/apps/backend/api-renamed/downgrade.js @@ -38,21 +38,23 @@ export default async (req, res) => { return; } - // delete existing app authorization via GitHub API + // delete existing app authorization via GitHub API; skipped if the stored token is unreadable try { - await axios.delete( - `https://api.github.com/applications/${process.env.OAUTH_CLIENT_ID}/grant`, - { - auth: { - username: process.env.OAUTH_CLIENT_ID, - password: process.env.OAUTH_CLIENT_SECRET, + if (userAccess.token !== null) { + await axios.delete( + `https://api.github.com/applications/${process.env.OAUTH_CLIENT_ID}/grant`, + { + auth: { + username: process.env.OAUTH_CLIENT_ID, + password: process.env.OAUTH_CLIENT_SECRET, + }, + data: { access_token: userAccess.token }, + headers: { + Accept: "application/vnd.github+json", + }, }, - data: { access_token: userAccess.token }, - headers: { - Accept: "application/vnd.github+json", - }, - }, - ); + ); + } } catch (err) { logger.error(err); res.statusCode = 500; diff --git a/apps/backend/api-renamed/user-access.js b/apps/backend/api-renamed/user-access.js index 0e86821699b5e..cdcd62b654282 100644 --- a/apps/backend/api-renamed/user-access.js +++ b/apps/backend/api-renamed/user-access.js @@ -1,6 +1,6 @@ import { logger } from "@stats-organization/github-readme-stats-core"; -import { getUserAccessByKey } from "../src/common/database.js"; +import { deleteUser, getUserAccessByKey } from "../src/common/database.js"; /** * @param {any} req The request. @@ -16,6 +16,13 @@ export default async (req, res) => { res.send("user not found"); return; } + if (result.token === null) { + // the stored token cannot be decrypted: drop it so the user can log in again + await deleteUser(user_key); + res.statusCode = 404; + res.send("user must log in again"); + return; + } res.send({ privateAccess: result.privateAccess, diff --git a/apps/backend/package.json b/apps/backend/package.json index 090c87b949d7c..c32ada0aa129d 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -14,6 +14,7 @@ "test:e2e": "vitest --config vitest.config.e2e.ts", "bench": "vitest bench --run --config vitest.config.bench.ts", "lint": "eslint", + "encrypt-access-tokens": "node scripts/encrypt-access-tokens.js", "typecheck": "tsc -p tsconfig.typecheck.json" }, "devDependencies": { diff --git a/apps/backend/scripts/encrypt-access-tokens.js b/apps/backend/scripts/encrypt-access-tokens.js new file mode 100644 index 0000000000000..28705d40752b4 --- /dev/null +++ b/apps/backend/scripts/encrypt-access-tokens.js @@ -0,0 +1,22 @@ +/** + * @file Re-encrypts every stored access token with the current `TOKEN_ENCRYPTION_KEY`. + */ + +import { encryptStoredAccessTokens, pool } from "../src/common/database.js"; + +try { + const { encrypted, failed } = await encryptStoredAccessTokens(); + console.log(`Encrypted ${encrypted} access token(s).`); + if (failed.length > 0) { + console.error( + `Could not decrypt the token of: ${failed.join(", ")}. ` + + "Add the key they were encrypted with to TOKEN_ENCRYPTION_KEY, or let those users log in again.", + ); + process.exitCode = 1; + } +} catch (err) { + console.error(err.message); + process.exitCode = 1; +} finally { + await pool?.end(); +} diff --git a/apps/backend/src/common/database.js b/apps/backend/src/common/database.js index 8bf2e83f21aa4..ea3f77b182276 100644 --- a/apps/backend/src/common/database.js +++ b/apps/backend/src/common/database.js @@ -1,9 +1,35 @@ +import { logger } from "@stats-organization/github-readme-stats-core"; + +import { AccessTokenCipher, TokenDecryptionError } from "./tokenEncryption.js"; + +/** SQLSTATE `undefined_table`, see https://www.postgresql.org/docs/current/errcodes-appendix.html */ +const UNDEFINED_TABLE = "42P01"; + export let pool = null; if (process.env.POSTGRES_URL) { const { Pool } = await import("pg"); pool = new Pool({ connectionString: process.env.POSTGRES_URL, }); + if (!process.env.TOKEN_ENCRYPTION_KEY) { + logger.error( + "TOKEN_ENCRYPTION_KEY is not set: GitHub access tokens are stored in plaintext. " + + "Generate a key with `openssl rand -base64 32` and set it to encrypt them at rest.", + ); + } +} + +/** @type {AccessTokenCipher | null} */ +let cipher = null; + +/** + * Parsed once, on first use, so a bad key only fails the token paths and not every endpoint. + * + * @returns {AccessTokenCipher} Cipher for `TOKEN_ENCRYPTION_KEY` + */ +function getCipher() { + cipher ??= AccessTokenCipher.fromEnv(); + return cipher; } /** @@ -55,8 +81,7 @@ export async function storeRequest(req) { try { await pool.query(insertQuery, [req.url]); } catch (err) { - // Check for undefined_table error (SQLSTATE 42P01) - if (err.code === "42P01") { + if (err.code === UNDEFINED_TABLE) { await createAllTables(); // Retry the insert after creating the table await pool.query(insertQuery, [req.url]); @@ -82,7 +107,7 @@ export async function deleteOldRequests(interval) { let result = await pool.query(deleteQuery); console.log(`Deleted ${result.rowCount} old requests.`); } catch (err) { - if (err.code === "42P01") { + if (err.code === UNDEFINED_TABLE) { console.log("Error deleting requests, table doesn't exist"); } else { throw err; @@ -111,7 +136,7 @@ export async function getRecentRequests(minInterval, maxInterval) { try { ({ rows } = await pool.query(query)); } catch (err) { - if (err.code === "42P01") { + if (err.code === UNDEFINED_TABLE) { console.log("Error fetching requests, table doesn't exist"); } else { throw err; @@ -143,28 +168,49 @@ export async function storeUser(userId, accessToken, userKey, privateAccess) { private_access = EXCLUDED.private_access `; + const values = [ + userId, + getCipher().encrypt(accessToken, userId), + userKey, + privateAccess, + ]; + try { - await pool.query(insertQuery, [ - userId, - accessToken, - userKey, - privateAccess, - ]); + await pool.query(insertQuery, values); } catch (err) { - if (err.code === "42P01") { + if (err.code === UNDEFINED_TABLE) { await createAllTables(); - await pool.query(insertQuery, [ - userId, - accessToken, - userKey, - privateAccess, - ]); + await pool.query(insertQuery, values); } else { throw err; } } } +/** + * @param {{user_id: string, access_token: string}} row Database row + * @returns {string | null} Plaintext token, or null if it cannot be decrypted (config errors propagate) + */ +function tryDecrypt(row) { + try { + return getCipher().decrypt(row.access_token, row.user_id); + } catch (err) { + if (!(err instanceof TokenDecryptionError)) { + throw err; + } + logger.error(`${err.message} (user: ${row.user_id})`); + return null; + } +} + +/** + * @param {{user_id: string, access_token: string, private_access: boolean}} row Database row + * @returns {{token: string | null, privateAccess: boolean}} token is null when it cannot be decrypted and the user has to log in again + */ +function toUserAccess(row) { + return { token: tryDecrypt(row), privateAccess: row.private_access }; +} + /** * Delete a user from the database. * @@ -182,7 +228,7 @@ export async function deleteUser(userKey) { try { await pool.query(deleteQuery, [userKey]); } catch (err) { - if (err.code === "42P01") { + if (err.code === UNDEFINED_TABLE) { console.log("Error deleting user, table doesn't exist"); } else { throw err; @@ -194,7 +240,7 @@ export async function deleteUser(userKey) { * Fetches token and private access status for a given user_key. * * @param {string} userKey user key of the user to fetch information for - * @returns {Promise<{token: string, privateAccess: boolean} | null>} token and private access status, or null if user not found + * @returns {Promise<{token: string | null, privateAccess: boolean} | null>} null if user not found, token null if it must log in again */ export async function getUserAccessByKey(userKey) { if (!pool) { @@ -202,7 +248,7 @@ export async function getUserAccessByKey(userKey) { } const query = ` - SELECT access_token, private_access + SELECT user_id, access_token, private_access FROM authenticated_users WHERE user_key = $1 LIMIT 1 @@ -212,12 +258,9 @@ export async function getUserAccessByKey(userKey) { if (rows.length === 0) { return null; } - return { - token: rows[0].access_token, - privateAccess: rows[0].private_access, - }; + return toUserAccess(rows[0]); } catch (err) { - if (err.code === "42P01") { + if (err.code === UNDEFINED_TABLE) { return null; } else { throw err; @@ -229,7 +272,7 @@ export async function getUserAccessByKey(userKey) { * Fetches token and private access status for a given username. * * @param {string} userName GitHub username of the user to fetch information for - * @returns {Promise<{token: string, privateAccess: boolean} | null>} token and private access status, or null if user not found + * @returns {Promise<{token: string | null, privateAccess: boolean} | null>} null if user not found, token null if it must log in again */ export async function getUserAccessByName(userName) { if (!pool) { @@ -237,7 +280,7 @@ export async function getUserAccessByName(userName) { } const query = ` - SELECT access_token, private_access + SELECT user_id, access_token, private_access FROM authenticated_users WHERE user_id = $1 LIMIT 1 @@ -247,15 +290,65 @@ export async function getUserAccessByName(userName) { if (rows.length === 0) { return null; } - return { - token: rows[0].access_token, - privateAccess: rows[0].private_access, - }; + return toUserAccess(rows[0]); } catch (err) { - if (err.code === "42P01") { + if (err.code === UNDEFINED_TABLE) { return null; } else { throw err; } } } + +/** + * Re-encrypts every stored token that is not yet encrypted with the first key. + * Covers legacy plaintext rows and rows written with a rotated-out key. + * + * @returns {Promise<{encrypted: number, failed: Array}>} Rewritten row count and users whose token could not be read + */ +export async function encryptStoredAccessTokens() { + if (!pool) { + throw new Error("POSTGRES_URL is not set"); + } + const cipher = getCipher(); + if (!cipher.isEnabled) { + throw new Error( + "TOKEN_ENCRYPTION_KEY is not set, there is nothing to encrypt", + ); + } + + let rows; + try { + ({ rows } = await pool.query( + "SELECT user_id, access_token FROM authenticated_users", + )); + } catch (err) { + if (err.code === UNDEFINED_TABLE) { + return { encrypted: 0, failed: [] }; + } + throw err; + } + + let encrypted = 0; + const failed = []; + + for (const row of rows) { + if (cipher.isCurrent(row.access_token, row.user_id)) { + continue; + } + const token = tryDecrypt(row); + if (token === null) { + failed.push(row.user_id); + continue; + } + + // compare-and-swap so a login since the SELECT is not overwritten + const { rowCount } = await pool.query( + "UPDATE authenticated_users SET access_token = $2 WHERE user_id = $1 AND access_token = $3", + [row.user_id, cipher.encrypt(token, row.user_id), row.access_token], + ); + encrypted += rowCount ?? 0; + } + + return { encrypted, failed }; +} diff --git a/apps/backend/src/common/tokenEncryption.js b/apps/backend/src/common/tokenEncryption.js new file mode 100644 index 0000000000000..708866f630a22 --- /dev/null +++ b/apps/backend/src/common/tokenEncryption.js @@ -0,0 +1,162 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const KEY_BYTES = 32; +const IV_BYTES = 12; +const HEX_KEY = /^[0-9a-fA-F]{64}$/; + +/** Marks a value as encrypted and versions the format. */ +const PREFIX = "gse.v1."; + +/** The stored token is unreadable, the user has to log in again. Any other error is a config problem. */ +export class TokenDecryptionError extends Error { + name = "TokenDecryptionError"; +} + +/** + * @returns {Array} Keys from the comma-separated `TOKEN_ENCRYPTION_KEY`, each 32 bytes of base64 or hex + */ +function readEncryptionKeysFromEnv() { + return (process.env.TOKEN_ENCRYPTION_KEY ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => { + const key = Buffer.from(value, HEX_KEY.test(value) ? "hex" : "base64"); + if (key.length !== KEY_BYTES) { + throw new Error( + `Invalid TOKEN_ENCRYPTION_KEY: expected ${KEY_BYTES} bytes (base64 or hex), got ${key.length}`, + ); + } + return key; + }); +} + +/** + * AES-256-GCM cipher for stored GitHub access tokens. + * The first key encrypts, every key decrypts (rotation); with no key it is a pass-through. + */ +export class AccessTokenCipher { + /** @type {Array} */ + #keys; + + /** + * @param {Array} keys First key encrypts, all decrypt; empty disables encryption + */ + constructor(keys) { + this.#keys = keys; + } + + /** + * @returns {AccessTokenCipher} Cipher for `TOKEN_ENCRYPTION_KEY`, throws on an invalid key + */ + static fromEnv() { + return new AccessTokenCipher(readEncryptionKeysFromEnv()); + } + + /** + * @param {string} value Value read from the database + * @returns {boolean} false for a legacy plaintext token + */ + static isEncryptedValue(value) { + return value.startsWith(PREFIX); + } + + /** + * @returns {boolean} true when at least one key is configured + */ + get isEnabled() { + return this.#keys.length > 0; + } + + /** + * @param {string} token Plaintext GitHub access token + * @param {string} userId Bound to the ciphertext so it cannot be moved to another row + * @returns {string} Encrypted token, or plaintext if encryption is disabled + */ + encrypt(token, userId) { + const [key] = this.#keys; + if (!key) { + return token; + } + + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, key, iv).setAAD( + Buffer.from(userId), + ); + const ciphertext = Buffer.concat([cipher.update(token), cipher.final()]); + const parts = [iv, cipher.getAuthTag(), ciphertext].map((part) => + part.toString("base64"), + ); + return PREFIX + parts.join("."); + } + + /** + * @param {string} value `access_token` column; legacy plaintext passes through + * @param {string} userId Owner of the row + * @returns {string} Plaintext GitHub access token + * @throws {TokenDecryptionError} malformed value or no key matches + * @throws {Error} encrypted value but no key configured + */ + decrypt(value, userId) { + if (!AccessTokenCipher.isEncryptedValue(value)) { + return value; + } + if (!this.isEnabled) { + throw new Error( + "The stored access token is encrypted but TOKEN_ENCRYPTION_KEY is not set", + ); + } + const token = this.#decryptWith(this.#keys, value, userId); + if (token === null) { + throw new TokenDecryptionError( + "Failed to decrypt the stored access token: no key in TOKEN_ENCRYPTION_KEY matches", + ); + } + return token; + } + + /** + * @param {string} value `access_token` column + * @param {string} userId Owner of the row + * @returns {boolean} true if the value is already encrypted with the first key + */ + isCurrent(value, userId) { + return ( + AccessTokenCipher.isEncryptedValue(value) && + this.#decryptWith(this.#keys.slice(0, 1), value, userId) !== null + ); + } + + /** + * @param {Array} keys Keys to try in order + * @param {string} value Encrypted value + * @param {string} userId Owner of the row + * @returns {string | null} Plaintext, or null if no key authenticates the value + */ + #decryptWith(keys, value, userId) { + const parts = value.slice(PREFIX.length).split("."); + if (parts.length !== 3) { + throw new TokenDecryptionError("Malformed encrypted access token"); + } + const [iv, authTag, ciphertext] = parts.map((part) => + Buffer.from(part, "base64"), + ); + const aad = Buffer.from(userId); + + for (const key of keys) { + try { + const decipher = createDecipheriv(ALGORITHM, key, iv) + .setAAD(aad) + .setAuthTag(authTag); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString(); + } catch { + // wrong key, try the next one + } + } + return null; + } +} diff --git a/apps/backend/tests/database.test.js b/apps/backend/tests/database.test.js new file mode 100644 index 0000000000000..f3e3effc30645 --- /dev/null +++ b/apps/backend/tests/database.test.js @@ -0,0 +1,170 @@ +/** + * @file Tests for access token storage in `authenticated_users`. + */ + +import { randomBytes } from "node:crypto"; + +import { logger } from "@stats-organization/github-readme-stats-core"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { AccessTokenCipher } from "../src/common/tokenEncryption.js"; + +const { queryMock } = vi.hoisted(() => ({ queryMock: vi.fn() })); + +vi.mock(import("pg"), () => { + class Pool { + query = queryMock; + } + + return { default: { Pool }, Pool }; +}); + +const KEY = randomBytes(32); +const OLD_KEY = randomBytes(32); +const cipher = new AccessTokenCipher([KEY]); + +let storeUser, + getUserAccessByName, + getUserAccessByKey, + encryptStoredAccessTokens; + +beforeAll(async () => { + vi.stubEnv("POSTGRES_URL", "postgres://user:password@localhost:5432/test"); + vi.stubEnv("TOKEN_ENCRYPTION_KEY", KEY.toString("base64")); + + ({ + storeUser, + getUserAccessByName, + getUserAccessByKey, + encryptStoredAccessTokens, + } = await import("../src/common/database.js")); +}); + +beforeEach(() => { + queryMock.mockReset().mockResolvedValue({ rows: [], rowCount: 0 }); +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +const row = (user_id, access_token, private_access = true) => ({ + user_id, + access_token, + private_access, +}); + +describe("Test database access token storage", () => { + it("should never send the plaintext token to Postgres", async () => { + await storeUser("anuraghazra", "gho_secret", "user-key", true); + + const [, values] = queryMock.mock.calls[0]; + expect(values).toEqual([ + "anuraghazra", + expect.stringMatching(/^gse\.v1\./), + "user-key", + true, + ]); + expect(JSON.stringify(values)).not.toContain("gho_secret"); + }); + + it("should decrypt the token when reading it back", async () => { + await storeUser("anuraghazra", "gho_secret", "user-key", true); + const [, [, storedToken]] = queryMock.mock.calls[0]; + queryMock.mockResolvedValue({ rows: [row("anuraghazra", storedToken)] }); + + await expect(getUserAccessByName("anuraghazra")).resolves.toEqual({ + token: "gho_secret", + privateAccess: true, + }); + await expect(getUserAccessByKey("user-key")).resolves.toEqual({ + token: "gho_secret", + privateAccess: true, + }); + }); + + it("should read back a token stored before encryption was enabled", async () => { + queryMock.mockResolvedValue({ + rows: [row("anuraghazra", "gho_legacy", false)], + }); + + await expect(getUserAccessByName("anuraghazra")).resolves.toEqual({ + token: "gho_legacy", + privateAccess: false, + }); + }); + + it("should return a null token when it cannot be decrypted", async () => { + const error = vi.spyOn(logger, "error").mockImplementation(() => {}); + queryMock.mockResolvedValue({ + rows: [row("anuraghazra", "gse.v1.aaa.bbb.ccc")], + }); + + await expect(getUserAccessByName("anuraghazra")).resolves.toEqual({ + token: null, + privateAccess: true, + }); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("(user: anuraghazra)"), + ); + expect(JSON.stringify(error.mock.calls)).not.toContain("gse.v1.aaa"); + error.mockRestore(); + }); + + it("should re-encrypt only the tokens not yet on the current key", async () => { + vi.spyOn(logger, "error").mockImplementation(() => {}); + const current = cipher.encrypt("gho_current", "rickstaa"); + const old = new AccessTokenCipher([OLD_KEY]).encrypt("gho_old", "qwerty"); + queryMock + .mockResolvedValueOnce({ + rows: [ + row("anuraghazra", "gho_legacy"), + row("rickstaa", current), + row("qwerty", old), + ], + }) + .mockResolvedValue({ rows: [], rowCount: 1 }); + + await expect(encryptStoredAccessTokens()).resolves.toEqual({ + encrypted: 1, + failed: ["qwerty"], + }); + + expect(queryMock).toHaveBeenCalledTimes(2); + const [sql, values] = queryMock.mock.calls[1]; + // compare-and-swap on the value that was read + expect(sql).toMatch(/AND access_token = \$3/); + expect(values[0]).toBe("anuraghazra"); + expect(cipher.decrypt(values[1], "anuraghazra")).toBe("gho_legacy"); + expect(values[2]).toBe("gho_legacy"); + vi.restoreAllMocks(); + }); + + it("should not count a token that was rewritten by a login in the meantime", async () => { + queryMock + .mockResolvedValueOnce({ rows: [row("anuraghazra", "gho_legacy")] }) + .mockResolvedValue({ rows: [], rowCount: 0 }); + + await expect(encryptStoredAccessTokens()).resolves.toEqual({ + encrypted: 0, + failed: [], + }); + }); + + it("should report nothing to encrypt when the table does not exist", async () => { + queryMock.mockRejectedValue(Object.assign(new Error(), { code: "42P01" })); + + await expect(encryptStoredAccessTokens()).resolves.toEqual({ + encrypted: 0, + failed: [], + }); + }); +}); diff --git a/apps/backend/tests/tokenEncryption.test.js b/apps/backend/tests/tokenEncryption.test.js new file mode 100644 index 0000000000000..fe651cadcbdcb --- /dev/null +++ b/apps/backend/tests/tokenEncryption.test.js @@ -0,0 +1,153 @@ +/** + * @file Tests for `AccessTokenCipher`. + */ + +import { randomBytes } from "node:crypto"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + AccessTokenCipher, + TokenDecryptionError, +} from "../src/common/tokenEncryption.js"; + +const KEY = randomBytes(32); +const OTHER_KEY = randomBytes(32); +const cipher = new AccessTokenCipher([KEY]); +const USER = "anuraghazra"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("Test AccessTokenCipher", () => { + it("should round-trip a token", () => { + const encrypted = cipher.encrypt("gho_secret", USER); + + expect(encrypted).not.toContain("gho_secret"); + expect(encrypted).toMatch(/^gse\.v1\./); + expect(AccessTokenCipher.isEncryptedValue(encrypted)).toBe(true); + expect(cipher.decrypt(encrypted, USER)).toBe("gho_secret"); + }); + + it("should round-trip an empty token", () => { + expect(cipher.decrypt(cipher.encrypt("", USER), USER)).toBe(""); + }); + + it("should produce a different ciphertext for every call", () => { + expect(cipher.encrypt("gho_secret", USER)).not.toBe( + cipher.encrypt("gho_secret", USER), + ); + }); + + it("should pass legacy plaintext values through", () => { + expect(AccessTokenCipher.isEncryptedValue("gho_secret")).toBe(false); + expect(cipher.decrypt("gho_secret", USER)).toBe("gho_secret"); + expect(cipher.isCurrent("gho_secret", USER)).toBe(false); + }); + + it("should store plaintext when no key is configured", () => { + const disabled = new AccessTokenCipher([]); + + expect(disabled.isEnabled).toBe(false); + expect(disabled.encrypt("gho_secret", USER)).toBe("gho_secret"); + }); + + it("should decrypt with a rotated-out key and report it as not current", () => { + const encrypted = new AccessTokenCipher([OTHER_KEY]).encrypt( + "gho_secret", + USER, + ); + const rotated = new AccessTokenCipher([KEY, OTHER_KEY]); + + expect(rotated.decrypt(encrypted, USER)).toBe("gho_secret"); + expect(rotated.isCurrent(encrypted, USER)).toBe(false); + expect(rotated.isCurrent(rotated.encrypt("gho_secret", USER), USER)).toBe( + true, + ); + expect(() => cipher.decrypt(encrypted, USER)).toThrow(TokenDecryptionError); + }); + + it("should throw when the key is missing for an encrypted token", () => { + const encrypted = cipher.encrypt("gho_secret", USER); + let error; + try { + new AccessTokenCipher([]).decrypt(encrypted, USER); + } catch (err) { + error = err; + } + + expect(error).not.toBeInstanceOf(TokenDecryptionError); + expect(error.message).toMatch(/TOKEN_ENCRYPTION_KEY is not set/); + }); + + it("should reject a ciphertext moved to another user", () => { + const encrypted = cipher.encrypt("gho_secret", USER); + + expect(() => cipher.decrypt(encrypted, "rickstaa")).toThrow( + TokenDecryptionError, + ); + }); + + it("should throw when the ciphertext was tampered with", () => { + const parts = cipher.encrypt("gho_secret", USER).split("."); + const tampered = Buffer.from(parts[4], "base64"); + tampered[0] ^= 0xff; + parts[4] = tampered.toString("base64"); + + expect(() => cipher.decrypt(parts.join("."), USER)).toThrow( + /no key in TOKEN_ENCRYPTION_KEY matches/, + ); + }); + + it("should throw on a malformed encrypted value", () => { + expect(() => cipher.decrypt("gse.v1.only-one-part", USER)).toThrow( + /Malformed encrypted access token/, + ); + expect(() => cipher.decrypt("gse.v1.a.b.c.d", USER)).toThrow( + /Malformed encrypted access token/, + ); + }); + + describe("fromEnv", () => { + it("should accept base64 and hex keys", () => { + vi.stubEnv("TOKEN_ENCRYPTION_KEY", KEY.toString("base64")); + const fromBase64 = AccessTokenCipher.fromEnv(); + vi.stubEnv("TOKEN_ENCRYPTION_KEY", KEY.toString("hex")); + const fromHex = AccessTokenCipher.fromEnv(); + + expect( + fromHex.decrypt(fromBase64.encrypt("gho_secret", USER), USER), + ).toBe("gho_secret"); + }); + + it("should read a comma-separated key list, ignoring whitespace", () => { + vi.stubEnv( + "TOKEN_ENCRYPTION_KEY", + ` ${KEY.toString("base64")} ,${OTHER_KEY.toString("base64")}\n`, + ); + const encrypted = new AccessTokenCipher([OTHER_KEY]).encrypt( + "gho_secret", + USER, + ); + + expect(AccessTokenCipher.fromEnv().decrypt(encrypted, USER)).toBe( + "gho_secret", + ); + }); + + it("should be disabled without a key", () => { + vi.stubEnv("TOKEN_ENCRYPTION_KEY", ""); + + expect(AccessTokenCipher.fromEnv().isEnabled).toBe(false); + }); + + it("should reject a key of the wrong size", () => { + vi.stubEnv("TOKEN_ENCRYPTION_KEY", randomBytes(16).toString("base64")); + + expect(() => AccessTokenCipher.fromEnv()).toThrow( + /Invalid TOKEN_ENCRYPTION_KEY/, + ); + }); + }); +}); diff --git a/apps/frontend/src/content/docs/docs/deploy.md b/apps/frontend/src/content/docs/docs/deploy.md index e20de4a0f6aa2..a5eeaedee3baf 100644 --- a/apps/frontend/src/content/docs/docs/deploy.md +++ b/apps/frontend/src/content/docs/docs/deploy.md @@ -136,6 +136,26 @@ Click on the deploy button to get started! Add an SQL database, either through an integration such as ["Nile"](https://vercel.com/marketplace/nile), or by manually setting the environment variable `POSTGRES_URL`. +#### Encrypt the stored access tokens + +Set `TOKEN_ENCRYPTION_KEY` to encrypt the GitHub access tokens in `authenticated_users` at rest (AES-256-GCM). Generate it with: + +```sh +openssl rand -base64 32 +``` + +Keep it in the Vercel environment variables, not in the database. If it is lost, affected users have to log in again. + +Existing tokens are encrypted on the user's next login. To encrypt them now, redeploy with the key set, then run against the same database (exits non-zero if a token cannot be read): + +```sh +POSTGRES_URL=… TOKEN_ENCRYPTION_KEY=… pnpm --filter ./apps/backend run encrypt-access-tokens +``` + +:::tip[Rotating the key] +Set `TOKEN_ENCRYPTION_KEY=,` and redeploy: new logins are encrypted with the first key, stored tokens are decrypted with whichever key matches. Run the command above with the same value to re-encrypt everything, then drop the old key. +::: + #### Use your own OAuth App [Create your own OAuth App](https://github.com/settings/developers) and set the environment variables `OAUTH_REDIRECT_URI`, `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` on Vercel accordingly. @@ -172,6 +192,11 @@ GitHub Stats Extended provides several environment variables that can be used to Sets the duration in hours after which the server stops proactively regenerating a previously requested card if it hasn't been requested again in the meantime. Defaults to 8 days, i.e. 192 hours. Any int or float + + TOKEN_ENCRYPTION_KEY + Encrypts the stored GitHub access tokens at rest, see above. Comma-separate several keys to rotate: the first encrypts, all decrypt. + One or more 32 byte keys, base64 or hex + WHITELIST A comma-separated list of GitHub usernames that are allowed to access your instance. If this variable is not set, all usernames are allowed. diff --git a/knip.jsonc b/knip.jsonc index 8ae2314517e54..11f621182cfd5 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -9,7 +9,7 @@ "ignore": ["src/graphql/generated/**"] }, "apps/backend": { - "entry": ["api-renamed/*.js"] + "entry": ["api-renamed/*.js", "scripts/*.js"] }, "apps/frontend": { "entry": ["src/wakatime-override.ts"],