diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 347bf8bdde2..8919a782f31 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -12,12 +12,10 @@ import { } from "./store.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; -// Cross-account-leak defense-in-depth: the three adopt sites in oauth.ts -// now all call isSameOAuthIdentity before copying main-store credentials -// into the sub-agent store. This suite exercises each of those sites -// with a mismatched accountId on main vs. sub and asserts the adoption -// is refused (sub store keeps its own credential; main's creds do not -// leak through). +// Cross-account-leak defense-in-depth: each adopt site in oauth.ts calls the +// shared identity copy gate before copying main-store credentials into the +// sub-agent store. Unit tests cover policy variants; this suite proves each +// production branch refuses a mismatched accountId. function resolveApiKeyForProfileInTest( params: Omit[0], "cfg">, @@ -276,356 +274,6 @@ describe("OAuth credential adoption is identity-gated", () => { }); }); - it("adoptNewerMainOAuthCredential still adopts when sub has no identity but main does (upgrade tolerance)", async () => { - // Scenario: sub-agent stored its cred before accountId/email were - // captured. Main has fresh cred with accountId. Under the STRICT rule - // this would refuse (asymmetric). Under the relaxed rule used for - // adoption it must allow — otherwise #26322 breaks for existing - // installs on upgrade. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const subExpiry = Date.now() + 10 * 60 * 1000; - const mainFresher = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-upgrade", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "sub-own-access", - refresh: "sub-own-refresh", - expires: subExpiry, - // no accountId / email — pre-capture state - }), - ), - subAgentDir, - ); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "main-fresher-access", - refresh: "main-fresher-refresh", - expires: mainFresher, - accountId: "acct-main", - }), - ), - mainAgentDir, - ); - - const result = await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - - // Sub must have adopted main's fresher credential. - expect(result?.apiKey).toBe("main-fresher-access"); - - const subRaw = JSON.parse( - await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - expect(subRaw.profiles[profileId]).toMatchObject({ - access: "main-fresher-access", - accountId: "acct-main", - }); - }); - - it("inside-the-lock adopt tolerates sub-no-identity / main-has-identity (upgrade case)", async () => { - // Same upgrade scenario but entering via the inside-lock adopt path: - // sub cred is EXPIRED (forces entry into refreshOAuthTokenWithLock), - // main has FRESH cred with accountId, sub has no identity. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-insidelock-upgrade", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "sub-stale-access", - refresh: "sub-stale-refresh", - expires: Date.now() - 60_000, - // no identity metadata - }), - ), - subAgentDir, - ); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "main-fresh-access", - refresh: "main-fresh-refresh", - expires: freshExpiry, - accountId: "acct-main", - }), - ), - mainAgentDir, - ); - - // Plugin refresh must NOT be called — sub should adopt main's fresh - // cred rather than performing its own refresh. - const result = await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - - expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled(); - expect(result?.apiKey).toBe("main-fresh-access"); - }); - - it("adoptNewerMainOAuthCredential refuses non-overlapping identity fields (sub has accountId, main has email)", async () => { - // Reviewer-requested: with no COMPARABLE shared identity field there - // is no positive-match evidence, so adoption must refuse. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const subExpiry = Date.now() + 10 * 60 * 1000; - const mainFresher = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-nonoverlap-pre", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "sub-own-access", - refresh: "sub-own-refresh", - expires: subExpiry, - accountId: "acct-sub", - // NO email on sub - }), - ), - subAgentDir, - ); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "main-fresher-access", - refresh: "main-fresher-refresh", - expires: mainFresher, - email: "main@example.com", - // NO accountId on main - }), - ), - mainAgentDir, - ); - - const result = await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - - // Sub must keep its own credential; pre-refresh adopt is refused. - expect(result?.apiKey).toBe("sub-own-access"); - - const subRaw = JSON.parse( - await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"), - ) as AuthProfileStore; - expect(subRaw.profiles[profileId]).toMatchObject({ - access: "sub-own-access", - accountId: "acct-sub", - }); - }); - - it("inside-the-lock adopt refuses non-overlapping identity fields (sub has accountId, main has email)", async () => { - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-nonoverlap-inside", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "sub-stale-access", - refresh: "sub-refresh-token", - expires: Date.now() - 60_000, - accountId: "acct-sub", - }), - ), - subAgentDir, - ); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "main-fresh-access", - refresh: "main-fresh-refresh", - expires: freshExpiry, - email: "main@example.com", - }), - ), - 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, - }); - - // Sub performed its own refresh rather than adopting main's email-only cred. - expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); - expect(result?.apiKey).toBe("sub-refreshed-access"); - }); - - it("catch-block main-inherit refuses non-overlapping identity fields", async () => { - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-nonoverlap-catch", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "sub-stale-access", - refresh: "sub-refresh-token", - expires: Date.now() - 60_000, - accountId: "acct-sub", - }), - ), - subAgentDir, - ); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "main-stale-access", - refresh: "main-stale-refresh", - expires: Date.now() - 60_000, - email: "main@example.com", - }), - ), - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => { - // Another process writes a fresh email-only cred into main while - // our refresh is in-flight, then we throw a generic upstream error. - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "main-refreshed-access", - refresh: "main-refreshed-refresh", - expires: freshExpiry, - email: "main@example.com", - }), - ), - mainAgentDir, - ); - throw new Error("upstream 503 service unavailable"); - }); - - // Catch-block main-inherit must refuse the non-overlapping cred and - // propagate the original error rather than leaking main's credential. - await expect( - resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }), - ).rejects.toThrow(/OAuth token refresh failed for openai-codex/); - }); - - it("catch-block main-inherit tolerates sub-no-identity / main-has-identity (upgrade case)", async () => { - // Upgrade scenario hitting the catch-block fallback: sub refresh - // throws, main later carries fresh cred with identity. Sub must - // inherit rather than surface the error to the caller. - const profileId = "openai-codex:default"; - const provider = "openai-codex"; - const freshExpiry = Date.now() + 60 * 60 * 1000; - - const subAgentDir = path.join(tempRoot, "agents", "sub-catch-upgrade", "agent"); - await fs.mkdir(subAgentDir, { recursive: true }); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "sub-stale-access", - refresh: "sub-stale-refresh", - expires: Date.now() - 60_000, - // no identity metadata - }), - ), - subAgentDir, - ); - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "main-stale-access", - refresh: "main-stale-refresh", - expires: Date.now() - 60_000, - accountId: "acct-main", - }), - ), - mainAgentDir, - ); - - refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => { - // Another process refreshes main while our refresh is in flight. - saveAuthProfileStore( - storeWith( - profileId, - oauthCred({ - provider, - access: "main-refreshed-access", - refresh: "main-refreshed-refresh", - expires: freshExpiry, - accountId: "acct-main", - }), - ), - mainAgentDir, - ); - throw new Error("upstream 503 service unavailable"); - }); - - const result = await resolveApiKeyForProfileInTest({ - store: ensureAuthProfileStore(subAgentDir), - profileId, - agentDir: subAgentDir, - }); - - // Sub inherited main's fresh cred via the catch-block fallback. - expect(result?.apiKey).toBe("main-refreshed-access"); - }); - it("catch-block main-inherit refuses across accountId mismatch and surfaces the original error", async () => { // Scenario: sub-agent refresh throws a non-refresh_token_reused error. // Main has fresh creds for a DIFFERENT account. The catch-block