diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index 14a85afac0b..1fefa94399e 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -5,7 +5,7 @@ import { overlayRuntimeExternalOAuthProfiles, shouldPersistRuntimeExternalOAuthProfile, type RuntimeExternalOAuthProfile, -} from "./oauth-manager.js"; +} from "./oauth-shared.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; type ExternalAuthProfileMap = Map; diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index c2bdece7059..63ee725ad76 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -8,7 +8,7 @@ import { isSafeToOverwriteStoredOAuthIdentity, shouldBootstrapFromExternalCliCredential, shouldReplaceStoredOAuthCredential, -} from "./oauth-manager.js"; +} from "./oauth-shared.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; export { @@ -18,7 +18,7 @@ export { isSafeToOverwriteStoredOAuthIdentity, shouldBootstrapFromExternalCliCredential, shouldReplaceStoredOAuthCredential, -} from "./oauth-manager.js"; +} from "./oauth-shared.js"; export type ExternalCliResolvedProfile = { profileId: string; diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 99123f51885..6d027e3086a 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -124,7 +124,7 @@ describe("auth external oauth helpers", () => { expect(shouldPersist).toBe(true); }); - it("overlays external CLI OAuth only when the stored credential is no longer usable", () => { + it("does not use Codex CLI OAuth as a runtime overlay source", () => { readCodexCliCredentialsCachedMock.mockReturnValue( createCredential({ access: "fresh-cli-access-token", @@ -146,9 +146,9 @@ describe("auth external oauth helpers", () => { ); expect(overlaid.profiles["openai-codex:default"]).toMatchObject({ - access: "fresh-cli-access-token", - refresh: "fresh-cli-refresh-token", - expires: expect.any(Number), + access: "stale-store-access-token", + refresh: "stale-store-refresh-token", + accountId: "acct-cli", }); }); diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 48c7f9ddb42..4e30c02d423 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -6,7 +6,17 @@ import { OAUTH_REFRESH_LOCK_OPTIONS, log, } from "./constants.js"; -import { hasUsableOAuthCredential as hasUsableStoredOAuthCredential } from "./credential-state.js"; +import { + areOAuthCredentialsEquivalent, + hasUsableOAuthCredential, + isSafeToAdoptBootstrapOAuthIdentity, + isSafeToOverwriteStoredOAuthIdentity, + overlayRuntimeExternalOAuthProfiles, + shouldBootstrapFromExternalCliCredential, + shouldPersistRuntimeExternalOAuthProfile, + shouldReplaceStoredOAuthCredential, + type RuntimeExternalOAuthProfile, +} from "./oauth-shared.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js"; import { ensureAuthProfileStore, @@ -76,179 +86,17 @@ export class OAuthManagerRefreshError extends Error { } } -export type RuntimeExternalOAuthProfile = { - profileId: string; - credential: OAuthCredential; - persistence?: "runtime-only" | "persisted"; +export { + areOAuthCredentialsEquivalent, + hasUsableOAuthCredential, + isSafeToAdoptBootstrapOAuthIdentity, + isSafeToOverwriteStoredOAuthIdentity, + overlayRuntimeExternalOAuthProfiles, + shouldBootstrapFromExternalCliCredential, + shouldPersistRuntimeExternalOAuthProfile, + shouldReplaceStoredOAuthCredential, }; - -export function areOAuthCredentialsEquivalent( - a: OAuthCredential | undefined, - b: OAuthCredential, -): boolean { - if (!a || a.type !== "oauth") { - return false; - } - return ( - a.provider === b.provider && - a.access === b.access && - a.refresh === b.refresh && - a.expires === b.expires && - a.email === b.email && - a.enterpriseUrl === b.enterpriseUrl && - a.projectId === b.projectId && - a.accountId === b.accountId - ); -} - -function hasNewerStoredOAuthCredential( - existing: OAuthCredential | undefined, - incoming: OAuthCredential, -): boolean { - return Boolean( - existing && - existing.provider === incoming.provider && - Number.isFinite(existing.expires) && - (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), - ); -} - -export function shouldReplaceStoredOAuthCredential( - existing: OAuthCredential | undefined, - incoming: OAuthCredential, -): boolean { - if (!existing || existing.type !== "oauth") { - return true; - } - if (areOAuthCredentialsEquivalent(existing, incoming)) { - return false; - } - return !hasNewerStoredOAuthCredential(existing, incoming); -} - -export function hasUsableOAuthCredential( - credential: OAuthCredential | undefined, - now = Date.now(), -): boolean { - return hasUsableStoredOAuthCredential(credential, { now }); -} - -function normalizeAuthIdentityToken(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function normalizeAuthEmailToken(value: string | undefined): string | undefined { - return normalizeAuthIdentityToken(value)?.toLowerCase(); -} - -function hasOAuthIdentity(credential: Pick): boolean { - return ( - normalizeAuthIdentityToken(credential.accountId) !== undefined || - normalizeAuthEmailToken(credential.email) !== undefined - ); -} - -function hasMatchingOAuthIdentity( - existing: Pick, - incoming: Pick, -): boolean { - const existingAccountId = normalizeAuthIdentityToken(existing.accountId); - const incomingAccountId = normalizeAuthIdentityToken(incoming.accountId); - if (existingAccountId !== undefined && incomingAccountId !== undefined) { - return existingAccountId === incomingAccountId; - } - - const existingEmail = normalizeAuthEmailToken(existing.email); - const incomingEmail = normalizeAuthEmailToken(incoming.email); - if (existingEmail !== undefined && incomingEmail !== undefined) { - return existingEmail === incomingEmail; - } - - return false; -} - -export function isSafeToOverwriteStoredOAuthIdentity( - existing: OAuthCredential | undefined, - incoming: OAuthCredential, -): boolean { - if (!existing || existing.type !== "oauth") { - return true; - } - if (existing.provider !== incoming.provider) { - return false; - } - if (areOAuthCredentialsEquivalent(existing, incoming)) { - return true; - } - if (!hasOAuthIdentity(existing)) { - return false; - } - return hasMatchingOAuthIdentity(existing, incoming); -} - -export function isSafeToAdoptBootstrapOAuthIdentity( - existing: OAuthCredential | undefined, - incoming: OAuthCredential, -): boolean { - if (!existing || existing.type !== "oauth") { - return true; - } - if (existing.provider !== incoming.provider) { - return false; - } - if (areOAuthCredentialsEquivalent(existing, incoming)) { - return true; - } - if (!hasOAuthIdentity(existing)) { - return true; - } - return hasMatchingOAuthIdentity(existing, incoming); -} - -export function shouldBootstrapFromExternalCliCredential(params: { - existing: OAuthCredential | undefined; - imported: OAuthCredential; - now?: number; -}): boolean { - const now = params.now ?? Date.now(); - if (hasUsableOAuthCredential(params.existing, now)) { - return false; - } - return hasUsableOAuthCredential(params.imported, now); -} - -export function overlayRuntimeExternalOAuthProfiles( - store: AuthProfileStore, - profiles: Iterable, -): AuthProfileStore { - const externalProfiles = Array.from(profiles); - if (externalProfiles.length === 0) { - return store; - } - const next = structuredClone(store); - for (const profile of externalProfiles) { - next.profiles[profile.profileId] = profile.credential; - } - return next; -} - -export function shouldPersistRuntimeExternalOAuthProfile(params: { - profileId: string; - credential: OAuthCredential; - profiles: Iterable; -}): boolean { - for (const profile of params.profiles) { - if (profile.profileId !== params.profileId) { - continue; - } - if (profile.persistence === "persisted") { - return true; - } - return !areOAuthCredentialsEquivalent(profile.credential, params.credential); - } - return true; -} +export type { RuntimeExternalOAuthProfile }; function hasOAuthCredentialChanged( previous: Pick, diff --git a/src/agents/auth-profiles/oauth-shared.ts b/src/agents/auth-profiles/oauth-shared.ts new file mode 100644 index 00000000000..95ff67bbdad --- /dev/null +++ b/src/agents/auth-profiles/oauth-shared.ts @@ -0,0 +1,176 @@ +import { hasUsableOAuthCredential as hasUsableStoredOAuthCredential } from "./credential-state.js"; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; + +export type RuntimeExternalOAuthProfile = { + profileId: string; + credential: OAuthCredential; + persistence?: "runtime-only" | "persisted"; +}; + +export function areOAuthCredentialsEquivalent( + a: OAuthCredential | undefined, + b: OAuthCredential, +): boolean { + if (!a || a.type !== "oauth") { + return false; + } + return ( + a.provider === b.provider && + a.access === b.access && + a.refresh === b.refresh && + a.expires === b.expires && + a.email === b.email && + a.enterpriseUrl === b.enterpriseUrl && + a.projectId === b.projectId && + a.accountId === b.accountId + ); +} + +function hasNewerStoredOAuthCredential( + existing: OAuthCredential | undefined, + incoming: OAuthCredential, +): boolean { + return Boolean( + existing && + existing.provider === incoming.provider && + Number.isFinite(existing.expires) && + (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), + ); +} + +export function shouldReplaceStoredOAuthCredential( + existing: OAuthCredential | undefined, + incoming: OAuthCredential, +): boolean { + if (!existing || existing.type !== "oauth") { + return true; + } + if (areOAuthCredentialsEquivalent(existing, incoming)) { + return false; + } + return !hasNewerStoredOAuthCredential(existing, incoming); +} + +export function hasUsableOAuthCredential( + credential: OAuthCredential | undefined, + now = Date.now(), +): boolean { + return hasUsableStoredOAuthCredential(credential, { now }); +} + +function normalizeAuthIdentityToken(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeAuthEmailToken(value: string | undefined): string | undefined { + return normalizeAuthIdentityToken(value)?.toLowerCase(); +} + +function hasOAuthIdentity(credential: Pick): boolean { + return ( + normalizeAuthIdentityToken(credential.accountId) !== undefined || + normalizeAuthEmailToken(credential.email) !== undefined + ); +} + +function hasMatchingOAuthIdentity( + existing: Pick, + incoming: Pick, +): boolean { + const existingAccountId = normalizeAuthIdentityToken(existing.accountId); + const incomingAccountId = normalizeAuthIdentityToken(incoming.accountId); + if (existingAccountId !== undefined && incomingAccountId !== undefined) { + return existingAccountId === incomingAccountId; + } + + const existingEmail = normalizeAuthEmailToken(existing.email); + const incomingEmail = normalizeAuthEmailToken(incoming.email); + if (existingEmail !== undefined && incomingEmail !== undefined) { + return existingEmail === incomingEmail; + } + + return false; +} + +export function isSafeToOverwriteStoredOAuthIdentity( + existing: OAuthCredential | undefined, + incoming: OAuthCredential, +): boolean { + if (!existing || existing.type !== "oauth") { + return true; + } + if (existing.provider !== incoming.provider) { + return false; + } + if (areOAuthCredentialsEquivalent(existing, incoming)) { + return true; + } + if (!hasOAuthIdentity(existing)) { + return false; + } + return hasMatchingOAuthIdentity(existing, incoming); +} + +export function isSafeToAdoptBootstrapOAuthIdentity( + existing: OAuthCredential | undefined, + incoming: OAuthCredential, +): boolean { + if (!existing || existing.type !== "oauth") { + return true; + } + if (existing.provider !== incoming.provider) { + return false; + } + if (areOAuthCredentialsEquivalent(existing, incoming)) { + return true; + } + if (!hasOAuthIdentity(existing)) { + return true; + } + return hasMatchingOAuthIdentity(existing, incoming); +} + +export function shouldBootstrapFromExternalCliCredential(params: { + existing: OAuthCredential | undefined; + imported: OAuthCredential; + now?: number; +}): boolean { + const now = params.now ?? Date.now(); + if (hasUsableOAuthCredential(params.existing, now)) { + return false; + } + return hasUsableOAuthCredential(params.imported, now); +} + +export function overlayRuntimeExternalOAuthProfiles( + store: AuthProfileStore, + profiles: Iterable, +): AuthProfileStore { + const externalProfiles = Array.from(profiles); + if (externalProfiles.length === 0) { + return store; + } + const next = structuredClone(store); + for (const profile of externalProfiles) { + next.profiles[profile.profileId] = profile.credential; + } + return next; +} + +export function shouldPersistRuntimeExternalOAuthProfile(params: { + profileId: string; + credential: OAuthCredential; + profiles: Iterable; +}): boolean { + for (const profile of params.profiles) { + if (profile.profileId !== params.profileId) { + continue; + } + if (profile.persistence === "persisted") { + return true; + } + return !areOAuthCredentialsEquivalent(profile.credential, params.credential); + } + return true; +}