Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions apps/backend/api-renamed/downgrade.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion apps/backend/api-renamed/user-access.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
22 changes: 22 additions & 0 deletions apps/backend/scripts/encrypt-access-tokens.js
Original file line number Diff line number Diff line change
@@ -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();
}
157 changes: 125 additions & 32 deletions apps/backend/src/common/database.js
Original file line number Diff line number Diff line change
@@ -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;
}

/**
Expand Down Expand Up @@ -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]);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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;
Expand All @@ -194,15 +240,15 @@ 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) {
return null;
}

const query = `
SELECT access_token, private_access
SELECT user_id, access_token, private_access
FROM authenticated_users
WHERE user_key = $1
LIMIT 1
Expand All @@ -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;
Expand All @@ -229,15 +272,15 @@ 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) {
return null;
}

const query = `
SELECT access_token, private_access
SELECT user_id, access_token, private_access
FROM authenticated_users
WHERE user_id = $1
LIMIT 1
Expand All @@ -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<string>}>} 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 };
}
Loading