fix: recover conflicting credentials by verifying the recorded owner

This commit is contained in:
tdurieux
2026-09-08 14:21:16 +02:00
parent 530e388e46
commit 0c808a8675
5 changed files with 215 additions and 3 deletions
+19 -2
View File
@@ -1,5 +1,7 @@
import { mongo } from "mongoose";
import { createTokenCipher, EncryptedToken } from "./credential-crypto";
import { createOwnerCredentialRecovery } from "./recover-owner-credential";
import { IdentityResult } from "./recover-repository-owners";
type Cipher = ReturnType<typeof createTokenCipher>;
const resources = ["anonymizedrepositories", "anonymizedgists", "anonymizedpullrequests"];
@@ -8,6 +10,8 @@ export interface MigrationOptions {
apply?: boolean;
removeLegacy?: boolean;
preferOwnerToken?: boolean;
recoverOwnerTokens?: boolean;
identify?: (token: string) => Promise<IdentityResult>;
batchSize?: number;
report?: (event: { collection: string; id: string; issue: string }) => void;
}
@@ -17,13 +21,14 @@ export async function migrateCredentials(db: mongo.Db, cipher: Cipher, options:
const credentials = db.collection("credentials");
const users = db.collection("users");
const batchSize = options.batchSize || 100;
const counts = { owners: 0, created: 0, removed: 0, issues: 0 };
const counts = { owners: 0, created: 0, removed: 0, issues: 0, halted: false };
const recover = createOwnerCredentialRecovery(options.identify);
const issue = (collection: string, id: unknown, reason: string) => {
counts.issues++;
options.report?.({ collection, id: String(id), issue: reason });
};
if (options.apply) await credentials.createIndex({ ownerId: 1, provider: 1 }, { unique: true });
for await (const user of users.find({}, { projection: { accessTokens: 1, accessTokenDates: 1, status: 1 } }).batchSize(batchSize)) {
for await (const user of users.find({}, { projection: { accessTokens: 1, accessTokenDates: 1, status: 1, externalIDs: 1 } }).batchSize(batchSize)) {
counts.owners++;
const ownerId = user._id;
const existing = await credentials.findOne({ ownerId, provider: "github" });
@@ -35,11 +40,14 @@ export async function migrateCredentials(db: mongo.Db, cipher: Cipher, options:
}
const ownerToken = user.accessTokens?.github;
const authoritative = !!(selected || (typeof ownerToken === "string" && ownerToken));
const recovering = options.recoverOwnerTokens && !authoritative && user.status !== "removed";
const candidates = new Set<string>();
const inspect = (value: unknown, collection: string, id: unknown) => {
if (value === undefined || value === null || value === "") return;
if (typeof value !== "string") {
issue(collection, id, "malformed_token"); valid = false; return;
}
if (recovering) { candidates.add(value); return; }
if (!selected) selected = value;
else if (selected !== value && !(options.preferOwnerToken && authoritative)) {
issue(collection, id, "conflicting_token"); valid = false;
@@ -55,6 +63,15 @@ export async function migrateCredentials(db: mongo.Db, cipher: Cipher, options:
}
}
if (!valid) continue;
if (recovering && candidates.size) {
const result = await recover(user.externalIDs?.github, candidates);
if ("issue" in result) {
issue("users", ownerId, result.issue);
if (result.halt) { counts.halted = true; return counts; }
continue;
}
selected = result.token;
}
// Removed accounts must never regain credentials during backfill.
if (user.status === "removed") {
if (options.apply && options.removeLegacy) await credentials.deleteMany({ ownerId });
+35
View File
@@ -0,0 +1,35 @@
import { createHash } from "crypto";
import { identifyGitHubToken, IdentityResult } from "./recover-repository-owners";
type Result = { token: string } | { issue: string; halt?: boolean };
/** A resource token is usable only when it authenticates the already recorded owner. */
export function createOwnerCredentialRecovery(identify = identifyGitHubToken) {
const cache = new Map<string, IdentityResult>();
let nextRequest = 0;
return async (githubId: unknown, candidates: Iterable<string>): Promise<Result> => {
const expected = String(githubId ?? "");
if (!/^[1-9][0-9]*$/.test(expected)) return { issue: "missing_or_invalid_owner_github_id" };
let selected: string | undefined;
for (const token of new Set(candidates)) {
const hash = createHash("sha256").update(token).digest("hex");
let identity = cache.get(hash);
if (!identity) {
await new Promise(resolve => setTimeout(resolve, Math.max(0, nextRequest - Date.now())));
nextRequest = Date.now() + 250;
try { identity = await identify(token); }
catch { return { issue: "github_request_failed", halt: true }; }
if (cache.size >= 10000) cache.delete(cache.keys().next().value!);
cache.set(hash, identity);
}
if ("issue" in identity) {
if (identity.issue === "invalid_or_revoked_token") continue;
return identity;
}
if (identity.githubId !== expected) continue;
if (selected) return { issue: "multiple_valid_owner_tokens" };
selected = token;
}
return selected ? { token: selected } : { issue: "no_valid_owner_token" };
};
}
+2 -1
View File
@@ -7,7 +7,7 @@ import { migrateCredentials, verifyCredentials, enforceCredentialStorage } from
async function main() {
const args = new Set(process.argv.slice(2));
for (const arg of args) {
if (!["--apply", "--remove-legacy", "--prefer-owner-token", "--maintenance", "--verify", "--enforce"].includes(arg)) {
if (!["--apply", "--remove-legacy", "--prefer-owner-token", "--recover-owner-tokens", "--maintenance", "--verify", "--enforce"].includes(arg)) {
throw new Error("Unknown migration option");
}
}
@@ -28,6 +28,7 @@ async function main() {
const result = await migrateCredentials(db, cipher, {
apply: args.has("--apply"), removeLegacy: args.has("--remove-legacy"),
preferOwnerToken: args.has("--prefer-owner-token"),
recoverOwnerTokens: args.has("--recover-owner-tokens"),
report: event => process.stdout.write(JSON.stringify(event) + "\n"),
});
process.stdout.write(JSON.stringify(result) + "\n");