From cc8f4e98a6cf4be1d93f8e82d7f89dc5dc691086 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 21:14:10 +0100 Subject: [PATCH] test: split oauth mirror policy coverage --- .../auth-profiles/oauth-identity.test.ts | 152 ++++++- src/agents/auth-profiles/oauth-identity.ts | 116 +++++ src/agents/auth-profiles/oauth-manager.ts | 23 +- .../oauth.mirror-refresh.test.ts | 413 ------------------ src/agents/auth-profiles/oauth.ts | 9 + 5 files changed, 287 insertions(+), 426 deletions(-) create mode 100644 src/agents/auth-profiles/oauth-identity.ts diff --git a/src/agents/auth-profiles/oauth-identity.test.ts b/src/agents/auth-profiles/oauth-identity.test.ts index 44f020c0566..cbfaff0c7cb 100644 --- a/src/agents/auth-profiles/oauth-identity.test.ts +++ b/src/agents/auth-profiles/oauth-identity.test.ts @@ -4,7 +4,9 @@ import { isSameOAuthIdentity, normalizeAuthEmailToken, normalizeAuthIdentityToken, -} from "./oauth.js"; + shouldMirrorRefreshedOAuthCredential, +} from "./oauth-identity.js"; +import type { AuthProfileCredential } from "./types.js"; // Direct unit + fuzz tests for the cross-agent credential-mirroring identity // gate introduced for #26322 (CWE-284). These helpers are on the hot-path of @@ -328,6 +330,154 @@ describe("isSafeToCopyOAuthIdentity (unified copy gate, used for mirror and adop }); }); }); + +describe("shouldMirrorRefreshedOAuthCredential", () => { + type MirrorCase = { + name: string; + existing: AuthProfileCredential | undefined; + shouldMirror: boolean; + reason: string; + }; + const refreshed = { + type: "oauth", + provider: "openai-codex", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 2_000, + accountId: "acct-1", + } as const; + + const cases: MirrorCase[] = [ + { + name: "empty main store", + existing: undefined, + shouldMirror: true, + reason: "no-existing-credential", + }, + { + name: "matching older oauth credential", + existing: { + type: "oauth", + provider: "openai-codex", + access: "old", + refresh: "old-refresh", + expires: 1_000, + accountId: "acct-1", + }, + shouldMirror: true, + reason: "incoming-fresher", + }, + { + name: "non-finite existing expiry", + existing: { + type: "oauth", + provider: "openai-codex", + access: "old", + refresh: "old-refresh", + expires: Number.NaN, + accountId: "acct-1", + }, + shouldMirror: true, + reason: "incoming-fresher", + }, + { + name: "identity upgrade", + existing: { + type: "oauth", + provider: "openai-codex", + access: "old", + refresh: "old-refresh", + expires: 1_000, + }, + shouldMirror: true, + reason: "incoming-fresher", + }, + { + name: "api key override", + existing: { + type: "api_key", + provider: "openai-codex", + key: "operator-key", + }, + shouldMirror: false, + reason: "non-oauth-existing-credential", + }, + { + name: "provider mismatch", + existing: { + type: "oauth", + provider: "anthropic", + access: "old", + refresh: "old-refresh", + expires: 1_000, + accountId: "acct-1", + }, + shouldMirror: false, + reason: "provider-mismatch", + }, + { + name: "identity mismatch", + existing: { + type: "oauth", + provider: "openai-codex", + access: "old", + refresh: "old-refresh", + expires: 1_000, + accountId: "acct-2", + }, + shouldMirror: false, + reason: "identity-mismatch-or-regression", + }, + { + name: "strictly fresher existing credential", + existing: { + type: "oauth", + provider: "openai-codex", + access: "main-fresh", + refresh: "main-fresh-refresh", + expires: 3_000, + accountId: "acct-1", + }, + shouldMirror: false, + reason: "incoming-not-fresher", + }, + ]; + + it.each(cases)("returns $reason for $name", ({ existing, shouldMirror, reason }) => { + expect( + shouldMirrorRefreshedOAuthCredential({ + existing, + refreshed, + }), + ).toEqual({ shouldMirror, reason }); + }); + + it("refuses identity regression from a known-account main credential", () => { + expect( + shouldMirrorRefreshedOAuthCredential({ + existing: { + type: "oauth", + provider: "openai-codex", + access: "main-identity-access", + refresh: "main-identity-refresh", + expires: 1_000, + accountId: "acct-main", + }, + refreshed: { + type: "oauth", + provider: "openai-codex", + access: "fresh-access", + refresh: "fresh-refresh", + expires: 2_000, + }, + }), + ).toEqual({ + shouldMirror: false, + reason: "identity-mismatch-or-regression", + }); + }); +}); + describe("isSafeToCopyOAuthIdentity fuzz", () => { function makeSeededRandom(seed: number): () => number { let t = seed >>> 0; diff --git a/src/agents/auth-profiles/oauth-identity.ts b/src/agents/auth-profiles/oauth-identity.ts new file mode 100644 index 00000000000..50703b6a08d --- /dev/null +++ b/src/agents/auth-profiles/oauth-identity.ts @@ -0,0 +1,116 @@ +import type { AuthProfileCredential, OAuthCredential } from "./types.js"; + +export function normalizeAuthIdentityToken(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeAuthEmailToken(value: string | undefined): string | undefined { + return normalizeAuthIdentityToken(value)?.toLowerCase(); +} + +/** + * Returns true if `existing` and `incoming` provably belong to the same + * account. Used to gate cross-agent credential mirroring. + */ +export function isSameOAuthIdentity( + existing: Pick, + incoming: Pick, +): boolean { + const aAcct = normalizeAuthIdentityToken(existing.accountId); + const bAcct = normalizeAuthIdentityToken(incoming.accountId); + const aEmail = normalizeAuthEmailToken(existing.email); + const bEmail = normalizeAuthEmailToken(incoming.email); + const aHasIdentity = aAcct !== undefined || aEmail !== undefined; + const bHasIdentity = bAcct !== undefined || bEmail !== undefined; + + if (aHasIdentity !== bHasIdentity) { + return false; + } + + if (aHasIdentity) { + if (aAcct !== undefined && bAcct !== undefined) { + return aAcct === bAcct; + } + if (aEmail !== undefined && bEmail !== undefined) { + return aEmail === bEmail; + } + return false; + } + + return true; +} + +/** + * One-sided copy gate for both directions: + * - mirror: sub-agent refresh -> main-agent store + * - adopt: main-agent store -> sub-agent store + */ +export function isSafeToCopyOAuthIdentity( + existing: Pick, + incoming: Pick, +): boolean { + const aAcct = normalizeAuthIdentityToken(existing.accountId); + const bAcct = normalizeAuthIdentityToken(incoming.accountId); + const aEmail = normalizeAuthEmailToken(existing.email); + const bEmail = normalizeAuthEmailToken(incoming.email); + + if (aAcct !== undefined && bAcct !== undefined) { + return aAcct === bAcct; + } + if (aEmail !== undefined && bEmail !== undefined) { + return aEmail === bEmail; + } + + const aHasIdentity = aAcct !== undefined || aEmail !== undefined; + if (aHasIdentity) { + return false; + } + + return true; +} + +export type OAuthMirrorDecisionReason = + | "no-existing-credential" + | "incoming-fresher" + | "non-oauth-existing-credential" + | "provider-mismatch" + | "identity-mismatch-or-regression" + | "incoming-not-fresher"; + +export type OAuthMirrorDecision = + | { + shouldMirror: true; + reason: Extract; + } + | { + shouldMirror: false; + reason: Exclude; + }; + +export function shouldMirrorRefreshedOAuthCredential(params: { + existing: AuthProfileCredential | undefined; + refreshed: OAuthCredential; +}): OAuthMirrorDecision { + const { existing, refreshed } = params; + if (!existing) { + return { shouldMirror: true, reason: "no-existing-credential" }; + } + if (existing.type !== "oauth") { + return { shouldMirror: false, reason: "non-oauth-existing-credential" }; + } + if (existing.provider !== refreshed.provider) { + return { shouldMirror: false, reason: "provider-mismatch" }; + } + if (!isSafeToCopyOAuthIdentity(existing, refreshed)) { + return { shouldMirror: false, reason: "identity-mismatch-or-regression" }; + } + if ( + Number.isFinite(existing.expires) && + Number.isFinite(refreshed.expires) && + existing.expires >= refreshed.expires + ) { + return { shouldMirror: false, reason: "incoming-not-fresher" }; + } + return { shouldMirror: true, reason: "incoming-fresher" }; +} diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 7b21990a92a..a3263eed251 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -18,6 +18,7 @@ import { shouldReplaceStoredOAuthCredential, type RuntimeExternalOAuthProfile, } from "./oauth-shared.js"; +import { shouldMirrorRefreshedOAuthCredential } from "./oauth-identity.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js"; import { ensureAuthProfileStore, @@ -304,10 +305,16 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { agentDir: undefined, updater: (store) => { const existing = store.profiles[params.profileId]; - if (existing && existing.type !== "oauth") { - return false; - } - if (existing && existing.provider !== params.refreshed.provider) { + const decision = shouldMirrorRefreshedOAuthCredential({ + existing, + refreshed: params.refreshed, + }); + if (!decision.shouldMirror) { + if (decision.reason === "identity-mismatch-or-regression") { + log.warn("refused to mirror OAuth credential: identity mismatch or regression", { + profileId: params.profileId, + }); + } return false; } if (existing && !isSafeToAdoptMainStoreOAuthIdentity(existing, params.refreshed)) { @@ -316,14 +323,6 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { }); return false; } - if ( - existing && - Number.isFinite(existing.expires) && - Number.isFinite(params.refreshed.expires) && - existing.expires >= params.refreshed.expires - ) { - return false; - } store.profiles[params.profileId] = { ...params.refreshed }; log.debug("mirrored refreshed OAuth credential to main agent store", { profileId: params.profileId, diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index 74c29cb0b49..27e3b5fd42d 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -240,302 +240,6 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); }); - it("refuses to mirror when main has a non-oauth entry for the same profileId", async () => { - // Exercises the `existing.type !== "oauth"` early-return in the mirror - // updater. If the operator has manually switched the main profile to - // an api_key, a secondary-agent's OAuth refresh must not clobber it. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-non-oauth", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore(createExpiredOauthStore({ profileId, provider }), subAgentDir); - saveAuthProfileStore( - { - version: 1, - profiles: { - [profileId]: { - type: "api_key", - provider, - key: "operator-key", - }, - }, - }, - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( - async () => - ({ - type: "oauth", - provider, - access: "sub-refreshed-access", - refresh: "sub-refreshed-refresh", - expires: freshExpiry, - }) as never, - ); - - const result = await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - expect(result?.apiKey).toBe("sub-refreshed-access"); - - // Main must still hold the operator's api_key, untouched. - const mainRaw = JSON.parse( - await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - expect(mainRaw.profiles[profileId]).toMatchObject({ - type: "api_key", - key: "operator-key", - }); - }); - - it("refuses to mirror when identity (accountId) mismatches", async () => { - // Exercises the CWE-284 identity gate: main carries acct-other, sub-agent - // refreshes as acct-mine — mirror must be refused. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-bad-identity", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - createExpiredOauthStore({ - profileId, - provider, - access: "sub-stale", - accountId: "acct-mine", - }), - subAgentDir, - ); - // Main has a different account for the same profileId — this is the - // cross-account-leak scenario that the gate must block. - saveAuthProfileStore( - { - version: 1, - profiles: { - [profileId]: { - type: "oauth", - provider, - access: "main-other-access", - refresh: "main-other-refresh", - expires: Date.now() - 60_000, - accountId: "acct-other", - }, - }, - }, - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( - async () => - ({ - type: "oauth", - provider, - access: "sub-refreshed-access", - refresh: "sub-refreshed-refresh", - expires: freshExpiry, - accountId: "acct-mine", - }) as never, - ); - - const result = await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - // Sub-agent gets its fresh token as usual. - expect(result?.apiKey).toBe("sub-refreshed-access"); - - // But main store must still hold acct-other's credential unchanged. - const mainRaw = JSON.parse( - await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - expect(mainRaw.profiles[profileId]).toMatchObject({ - access: "main-other-access", - accountId: "acct-other", - }); - }); - - it("refuses to mirror when main already has a strictly-fresher credential", async () => { - // Exercises the `existing.expires >= refreshed.expires` early-return. - // Scenario: main already completed a refresh (with a later expiry) while - // the sub-agent's refresh was in-flight; our mirror must not regress it. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const subFreshExpiry = Date.now() + 30 * 60 * 1000; - const mainFresherExpiry = Date.now() + 90 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-older", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - createExpiredOauthStore({ profileId, provider, accountId: "acct-shared" }), - subAgentDir, - ); - saveAuthProfileStore( - { - version: 1, - profiles: { - [profileId]: { - type: "oauth", - provider, - access: "main-already-fresh", - refresh: "main-already-fresh-refresh", - expires: mainFresherExpiry, - accountId: "acct-shared", - }, - }, - }, - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( - async () => - ({ - type: "oauth", - provider, - access: "sub-refreshed-older", - refresh: "sub-refreshed-older-refresh", - expires: subFreshExpiry, - accountId: "acct-shared", - }) as never, - ); - - // The sub-agent will actually adopt main's fresher creds via the inside- - // lock recheck (that's the whole point of #26322), so refresh may not - // even fire. We only care that the main store is not regressed. - await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - - const mainRaw = JSON.parse( - await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - expect(mainRaw.profiles[profileId]).toMatchObject({ - access: "main-already-fresh", - expires: mainFresherExpiry, - }); - }); - - it("refuses to mirror when main has a different provider for the same profileId", async () => { - // Exercises the `existing.provider !== params.refreshed.provider` branch - // in the mirror updater. Main holds a credential under the same profileId - // but for a different provider — mirror must refuse so we never silently - // rewrite a provider. - const profileId = "shared:default"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-provmismatch", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - createExpiredOauthStore({ profileId, provider: "openai-codex" }), - subAgentDir, - ); - saveAuthProfileStore( - { - version: 1, - profiles: { - [profileId]: { - type: "oauth", - provider: "anthropic", // deliberately different - access: "main-anthropic-access", - refresh: "main-anthropic-refresh", - expires: Date.now() + 60 * 60 * 1000, - }, - }, - }, - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( - async () => - ({ - type: "oauth", - provider: "openai-codex", - access: "sub-refreshed-access", - refresh: "sub-refreshed-refresh", - expires: freshExpiry, - }) as never, - ); - - const result = await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - expect(result?.apiKey).toBe("sub-refreshed-access"); - - const mainRaw = JSON.parse( - await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - // Main must still hold its anthropic entry, not the openai-codex one. - expect(mainRaw.profiles[profileId]).toMatchObject({ - provider: "anthropic", - access: "main-anthropic-access", - }); - }); - - it("mirrors when main's existing cred has a non-finite expires (treated as overwritable)", async () => { - // Exercises the `Number.isFinite(existing.expires)` branch — when main - // has a stored cred with NaN/missing expiry, we treat it as overwritable - // rather than refusing to write a fresh one. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const accountId = "acct-shared"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-nanexp", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore(createExpiredOauthStore({ profileId, provider, accountId }), subAgentDir); - saveAuthProfileStore( - { - version: 1, - profiles: { - [profileId]: { - type: "oauth", - provider, - access: "main-nan-access", - refresh: "main-nan-refresh", - expires: Number.NaN, - accountId, - }, - }, - }, - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( - async () => - ({ - type: "oauth", - provider, - access: "sub-refreshed-access", - refresh: "sub-refreshed-refresh", - expires: freshExpiry, - accountId, - }) as never, - ); - - await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - - const mainRaw = JSON.parse( - await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - expect(mainRaw.profiles[profileId]).toMatchObject({ - access: "sub-refreshed-access", - expires: freshExpiry, - }); - }); - it("inherits main-agent credentials via the pre-refresh adopt path when main is already fresher", async () => { // Exercises adoptNewerMainOAuthCredential at the top of // resolveApiKeyForProfile: main is fresher at flow start, so we adopt @@ -652,123 +356,6 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => }); }); - it("mirrors identity-bearing refreshes into a pre-capture main store", async () => { - // Pre-capture main credentials may lack account identity. Allow the - // refreshed sub-agent credential to upgrade the main store with identity. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-upgrade-mirror", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - // Sub has accountId (modern capture); stale. - saveAuthProfileStore( - createExpiredOauthStore({ profileId, provider, accountId: "acct-sub" }), - subAgentDir, - ); - // Main is pre-capture — no accountId at all. - saveAuthProfileStore( - { - version: 1, - profiles: { - [profileId]: { - type: "oauth", - provider, - access: "main-pre-capture-access", - refresh: "main-pre-capture-refresh", - expires: Date.now() - 60_000, - }, - }, - }, - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( - async () => - ({ - type: "oauth", - provider, - access: "sub-refreshed-access", - refresh: "sub-refreshed-refresh", - expires: freshExpiry, - accountId: "acct-sub", - }) as never, - ); - - const result = await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - expect(result?.apiKey).toBe("sub-refreshed-access"); - - const mainRaw = JSON.parse( - await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - expect(mainRaw.profiles[profileId]).toMatchObject({ - access: "sub-refreshed-access", - refresh: "sub-refreshed-refresh", - accountId: "acct-sub", - }); - }); - - it("refuses to mirror when incoming drops an identity field present on main (regression guard)", async () => { - // Inverse of the upgrade test: main has accountId, incoming refresh - // response lacks it. Mirror must refuse so the identity marker is - // preserved — dropping it would later let a different-account sub pass - // the relaxed adoption gate. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-regression", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore(createExpiredOauthStore({ profileId, provider }), subAgentDir); - saveAuthProfileStore( - { - version: 1, - profiles: { - [profileId]: { - type: "oauth", - provider, - access: "main-identity-access", - refresh: "main-identity-refresh", - expires: Date.now() + 30 * 60 * 1000, - accountId: "acct-main", - }, - }, - }, - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( - async () => - ({ - type: "oauth", - provider, - access: "sub-refreshed-no-identity", - refresh: "sub-refreshed-no-identity-refresh", - expires: freshExpiry, - // intentionally no accountId / no email — the regression case - }) as never, - ); - - await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - - // Main must still hold its accountId-bearing credential; mirror refused. - const mainRaw = JSON.parse( - await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - expect(mainRaw.profiles[profileId]).toMatchObject({ - access: "main-identity-access", - accountId: "acct-main", - }); - }); - it("mirrors refreshed credentials produced by the plugin-refresh path", async () => { // The plugin-refreshed branch in doRefreshOAuthTokenWithLock has its own // mirror call; cover it separately so the branch is not orphaned. diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 64f52bb839e..f637acf2458 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -25,6 +25,15 @@ import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; import { loadAuthProfileStoreForSecretsRuntime } from "./store.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; +export { + isSafeToCopyOAuthIdentity, + isSameOAuthIdentity, + normalizeAuthEmailToken, + normalizeAuthIdentityToken, + shouldMirrorRefreshedOAuthCredential, +} from "./oauth-identity.js"; +export type { OAuthMirrorDecision, OAuthMirrorDecisionReason } from "./oauth-identity.js"; + function listOAuthProviderIds(): string[] { if (typeof getOAuthProviders !== "function") { return [];