test: split oauth mirror policy coverage

This commit is contained in:
Peter Steinberger
2026-04-18 21:14:10 +01:00
parent d5f8f62ab2
commit cc8f4e98a6
5 changed files with 287 additions and 426 deletions

View File

@@ -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;

View File

@@ -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<OAuthCredential, "accountId" | "email">,
incoming: Pick<OAuthCredential, "accountId" | "email">,
): 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<OAuthCredential, "accountId" | "email">,
incoming: Pick<OAuthCredential, "accountId" | "email">,
): 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<OAuthMirrorDecisionReason, "no-existing-credential" | "incoming-fresher">;
}
| {
shouldMirror: false;
reason: Exclude<OAuthMirrorDecisionReason, "no-existing-credential" | "incoming-fresher">;
};
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" };
}

View File

@@ -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,

View File

@@ -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.

View File

@@ -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 [];