Skip to content

Commit 954ba9b

Browse files
committed
fix(identity): address review feedback on dedupe determinism
1 parent 291a7a7 commit 954ba9b

File tree

4 files changed

+46
-53
lines changed

4 files changed

+46
-53
lines changed

index.ts

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -633,19 +633,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
633633
byRefreshTokenGlobal: Map<string, number[]>;
634634
};
635635

636-
const pickNewestFromIndices = (indices: number[]): number | undefined => {
637-
if (indices.length === 0) return undefined;
638-
const first = indices[0];
639-
if (typeof first !== "number") return undefined;
640-
let newestIndex = first;
641-
for (let i = 1; i < indices.length; i += 1) {
642-
const candidate = indices[i];
643-
if (typeof candidate !== "number") continue;
644-
newestIndex = pickNewestAccountIndex(newestIndex, candidate);
645-
}
646-
return newestIndex;
647-
};
648-
649636
const resolveNoOrgRefreshMatch = (
650637
indexes: IdentityIndexes,
651638
refreshToken: string,
@@ -654,25 +641,28 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
654641
const candidateId = candidateAccountId?.trim() || undefined;
655642
const matches = indexes.byRefreshTokenNoOrg.get(refreshToken);
656643
if (!matches || matches.length === 0) return undefined;
644+
let newestNoAccountId: number | undefined;
645+
let newestExactAccountId: number | undefined;
657646

658-
const withNoAccountId = matches.filter((index) => {
659-
const existing = accounts[index];
660-
return !normalizeStoredAccountId(existing);
661-
});
662-
663-
if (!candidateId) {
664-
return pickNewestFromIndices(withNoAccountId);
665-
}
666-
667-
const exactMatches = matches.filter((index) => {
647+
for (const index of matches) {
668648
const existing = accounts[index];
669-
return normalizeStoredAccountId(existing) === candidateId;
670-
});
671-
if (exactMatches.length > 0) {
672-
return pickNewestFromIndices(exactMatches);
649+
const existingAccountId = normalizeStoredAccountId(existing);
650+
if (!existingAccountId) {
651+
newestNoAccountId =
652+
typeof newestNoAccountId === "number"
653+
? pickNewestAccountIndex(newestNoAccountId, index)
654+
: index;
655+
continue;
656+
}
657+
if (candidateId && existingAccountId === candidateId) {
658+
newestExactAccountId =
659+
typeof newestExactAccountId === "number"
660+
? pickNewestAccountIndex(newestExactAccountId, index)
661+
: index;
662+
}
673663
}
674664

675-
return pickNewestFromIndices(withNoAccountId);
665+
return newestExactAccountId ?? newestNoAccountId;
676666
};
677667

678668
const resolveUniqueOrgScopedMatch = (
@@ -900,6 +890,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
900890
const target = accounts[preferredOrgIndex];
901891
const source = accounts[fallbackIndex];
902892
if (!target || !source) return true;
893+
const targetAccountId = normalizeStoredAccountId(target);
894+
const sourceAccountId = normalizeStoredAccountId(source);
895+
if (!targetAccountId && sourceAccountId) {
896+
return false;
897+
}
903898
if (hasDistinctNonEmptyAccountIds(target, source)) {
904899
return false;
905900
}

lib/auth/token-utils.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,17 +246,19 @@ function extractCanonicalOrganizationIds(
246246
const auth = payload[JWT_CLAIM_PATH];
247247
if (!isRecord(auth)) return [];
248248

249-
const organizationIds = extractOrganizationIdsByIndex(auth.organizations);
250-
if (organizationIds.length === 0) return organizationIds;
251-
252249
// Authoritative source: idToken['https://api.openai.com/auth'].organizations[0].id
253250
// Only fall back to broader field extraction when this exact path is missing.
254251
const primaryOrganizationId = extractPrimaryAuthClaimOrganizationId(payload);
252+
255253
if (primaryOrganizationId) {
254+
const organizationIds = extractOrganizationIdsByIndex(auth.organizations);
255+
if (organizationIds.length === 0) return [primaryOrganizationId];
256256
organizationIds[0] = primaryOrganizationId;
257+
return organizationIds;
257258
}
258259

259-
return organizationIds;
260+
// Fallback: broader field extraction
261+
return extractOrganizationIdsByIndex(auth.organizations);
260262
}
261263

262264
function resolveOrganizationOverridesForKey(

lib/storage.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,12 @@ type AccountLike = {
205205
lastUsed?: number;
206206
};
207207

208+
type RefreshDedupeEntry = {
209+
byOrg: Map<string, number>;
210+
preferredOrgIndex?: number;
211+
fallbackIndices: number[];
212+
};
213+
208214
async function ensureGitignore(storagePath: string): Promise<void> {
209215
if (!currentStoragePath) return;
210216

@@ -426,11 +432,7 @@ function deduplicateAccountsByRefreshToken<T extends AccountLike>(accounts: T[])
426432
return true;
427433
};
428434

429-
const refreshMap = new Map<string, {
430-
byOrg: Map<string, number>;
431-
preferredOrgIndex?: number;
432-
fallbackIndices: number[];
433-
}>();
435+
const refreshMap = new Map<string, RefreshDedupeEntry>();
434436

435437
const pickPreferredOrgIndex = (
436438
existingIndex: number | undefined,
@@ -441,37 +443,31 @@ function deduplicateAccountsByRefreshToken<T extends AccountLike>(accounts: T[])
441443
};
442444

443445
const collapseFallbackIntoPreferredOrg = (
444-
entry: {
445-
byOrg: Map<string, number>;
446-
preferredOrgIndex?: number;
447-
fallbackIndices: number[];
448-
},
446+
entry: RefreshDedupeEntry,
449447
): void => {
450448
if (entry.preferredOrgIndex === undefined || entry.fallbackIndices.length === 0) {
451449
return;
452450
}
453451

454452
const preferredOrgIndex = entry.preferredOrgIndex;
455-
const target = working[preferredOrgIndex];
456-
if (!target) return;
457-
458453
const remainingFallbacks: number[] = [];
459454
for (const fallbackIndex of entry.fallbackIndices) {
460455
if (fallbackIndex === preferredOrgIndex) continue;
461456
const source = working[fallbackIndex];
462457
if (!source) continue;
463458

464-
if (!canMergeByRefreshToken(target, source)) {
459+
const currentPreferred = working[preferredOrgIndex];
460+
if (!currentPreferred) {
465461
remainingFallbacks.push(fallbackIndex);
466462
continue;
467463
}
468464

469-
const mergeTarget = working[preferredOrgIndex];
470-
if (!mergeTarget) {
465+
if (!canMergeByRefreshToken(currentPreferred, source)) {
471466
remainingFallbacks.push(fallbackIndex);
472467
continue;
473468
}
474-
working[preferredOrgIndex] = mergeAccountRecords(mergeTarget, source);
469+
470+
working[preferredOrgIndex] = mergeAccountRecords(currentPreferred, source);
475471
indicesToRemove.add(fallbackIndex);
476472
}
477473
entry.fallbackIndices = remainingFallbacks;

test/accounts.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ describe("AccountManager", () => {
433433

434434
describe("removeAccount", () => {
435435
// Note: Tests in this block cover in-memory manager behavior.
436-
// Canonical persistence dedupes no-org entries; org-different entries are preserved.
436+
// No-org duplicates collapse only when accountId is same/missing; distinct accountId entries are preserved.
437437
it("removes an account and updates indices", () => {
438438
const now = Date.now();
439439
const stored = {
@@ -509,7 +509,7 @@ describe("AccountManager", () => {
509509
});
510510

511511
it("removes only targeted workspace when email/token are shared (manager-level, no orgId)", () => {
512-
// Note: In-memory manager can hold multiple entries; canonical dedupe would collapse no-org duplicates
512+
// Note: In-memory manager can hold multiple entries; canonical dedupe would collapse no-org duplicates only when accountId is same/missing
513513
const now = Date.now();
514514
const stored = {
515515
version: 3 as const,
@@ -936,7 +936,7 @@ describe("AccountManager", () => {
936936

937937
describe("setActiveIndex", () => {
938938
// Note: Tests in this block cover in-memory manager behavior.
939-
// Canonical persistence dedupes no-org entries; org-different entries are preserved.
939+
// No-org duplicates collapse only when accountId is same/missing; distinct accountId entries are preserved.
940940
it("sets active index and returns account", () => {
941941
const now = Date.now();
942942
const stored = {
@@ -973,7 +973,7 @@ describe("AccountManager", () => {
973973
});
974974

975975
it("switches between distinct workspace accounts sharing email and token (manager-level, no orgId)", () => {
976-
// Note: In-memory manager can hold multiple entries; canonical dedupe would collapse no-org duplicates
976+
// Note: In-memory manager can hold multiple entries; canonical dedupe would collapse no-org duplicates only when accountId is same/missing
977977
const now = Date.now();
978978
const stored = {
979979
version: 3 as const,

0 commit comments

Comments
 (0)