Skip to content

Commit 9afcbbe

Browse files
committed
refactor(auth): extract persisted auth store helpers
1 parent bbc7a09 commit 9afcbbe

12 files changed

Lines changed: 514 additions & 548 deletions

docs/help/faq.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2645,7 +2645,7 @@ Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-a
26452645
for one model can still be usable for a sibling model on the same provider,
26462646
while billing/disabled windows still block the whole profile.
26472647

2648-
You can also set a **per-agent** order override (stored in that agent's `auth-profiles.json`) via the CLI:
2648+
You can also set a **per-agent** order override (stored in that agent's `auth-state.json`) via the CLI:
26492649

26502650
```bash
26512651
# Defaults to the configured default agent (omit --agent)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js";
2+
import type { ProviderExternalAuthProfile } from "../../plugins/types.js";
3+
import type { AuthProfileStore, OAuthCredential } from "./types.js";
4+
5+
type ExternalAuthProfileMap = Map<string, ProviderExternalAuthProfile>;
6+
7+
function normalizeExternalAuthProfile(
8+
profile: ProviderExternalAuthProfile,
9+
): ProviderExternalAuthProfile | null {
10+
if (!profile?.profileId || !profile.credential) {
11+
return null;
12+
}
13+
return {
14+
...profile,
15+
persistence: profile.persistence ?? "runtime-only",
16+
};
17+
}
18+
19+
function resolveExternalAuthProfileMap(params: {
20+
store: AuthProfileStore;
21+
agentDir?: string;
22+
env?: NodeJS.ProcessEnv;
23+
}): ExternalAuthProfileMap {
24+
const env = params.env ?? process.env;
25+
const profiles = resolveExternalAuthProfilesWithPlugins({
26+
env,
27+
context: {
28+
config: undefined,
29+
agentDir: params.agentDir,
30+
workspaceDir: undefined,
31+
env,
32+
store: params.store,
33+
},
34+
});
35+
36+
const resolved: ExternalAuthProfileMap = new Map();
37+
for (const rawProfile of profiles) {
38+
const profile = normalizeExternalAuthProfile(rawProfile);
39+
if (!profile) {
40+
continue;
41+
}
42+
resolved.set(profile.profileId, profile);
43+
}
44+
return resolved;
45+
}
46+
47+
function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean {
48+
return (
49+
a.type === b.type &&
50+
a.provider === b.provider &&
51+
a.access === b.access &&
52+
a.refresh === b.refresh &&
53+
a.expires === b.expires &&
54+
a.clientId === b.clientId &&
55+
a.email === b.email &&
56+
a.displayName === b.displayName &&
57+
a.enterpriseUrl === b.enterpriseUrl &&
58+
a.projectId === b.projectId &&
59+
a.accountId === b.accountId
60+
);
61+
}
62+
63+
export function overlayExternalAuthProfiles(
64+
store: AuthProfileStore,
65+
params?: { agentDir?: string; env?: NodeJS.ProcessEnv },
66+
): AuthProfileStore {
67+
const profiles = resolveExternalAuthProfileMap({
68+
store,
69+
agentDir: params?.agentDir,
70+
env: params?.env,
71+
});
72+
if (profiles.size === 0) {
73+
return store;
74+
}
75+
76+
const next = structuredClone(store);
77+
for (const [profileId, profile] of profiles) {
78+
next.profiles[profileId] = profile.credential;
79+
}
80+
return next;
81+
}
82+
83+
export function shouldPersistExternalAuthProfile(params: {
84+
store: AuthProfileStore;
85+
profileId: string;
86+
credential: OAuthCredential;
87+
agentDir?: string;
88+
env?: NodeJS.ProcessEnv;
89+
}): boolean {
90+
const external = resolveExternalAuthProfileMap({
91+
store: params.store,
92+
agentDir: params.agentDir,
93+
env: params.env,
94+
}).get(params.profileId);
95+
if (!external || external.persistence === "persisted") {
96+
return true;
97+
}
98+
return !oauthCredentialMatches(external.credential, params.credential);
99+
}
100+
101+
// Compat aliases while file/function naming catches up.
102+
export const overlayExternalOAuthProfiles = overlayExternalAuthProfiles;
103+
export const shouldPersistExternalOAuthProfile = shouldPersistExternalAuthProfile;
Lines changed: 6 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,6 @@
1-
import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js";
2-
import type { ProviderExternalAuthProfile } from "../../plugins/types.js";
3-
import type { AuthProfileStore, OAuthCredential } from "./types.js";
4-
5-
type ExternalOAuthProfileMap = Map<string, ProviderExternalAuthProfile>;
6-
7-
function normalizeExternalOAuthProfile(
8-
profile: ProviderExternalAuthProfile,
9-
): ProviderExternalAuthProfile | null {
10-
if (!profile?.profileId || !profile.credential) {
11-
return null;
12-
}
13-
return {
14-
...profile,
15-
persistence: profile.persistence ?? "runtime-only",
16-
};
17-
}
18-
19-
function resolveExternalOAuthProfileMap(params: {
20-
store: AuthProfileStore;
21-
agentDir?: string;
22-
env?: NodeJS.ProcessEnv;
23-
}): ExternalOAuthProfileMap {
24-
const env = params.env ?? process.env;
25-
const profiles = resolveExternalAuthProfilesWithPlugins({
26-
env,
27-
context: {
28-
config: undefined,
29-
agentDir: params.agentDir,
30-
workspaceDir: undefined,
31-
env,
32-
store: params.store,
33-
},
34-
});
35-
36-
const resolved: ExternalOAuthProfileMap = new Map();
37-
for (const rawProfile of profiles) {
38-
const profile = normalizeExternalOAuthProfile(rawProfile);
39-
if (!profile) {
40-
continue;
41-
}
42-
resolved.set(profile.profileId, profile);
43-
}
44-
return resolved;
45-
}
46-
47-
function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean {
48-
return (
49-
a.type === b.type &&
50-
a.provider === b.provider &&
51-
a.access === b.access &&
52-
a.refresh === b.refresh &&
53-
a.expires === b.expires &&
54-
a.clientId === b.clientId &&
55-
a.email === b.email &&
56-
a.displayName === b.displayName &&
57-
a.enterpriseUrl === b.enterpriseUrl &&
58-
a.projectId === b.projectId &&
59-
a.accountId === b.accountId
60-
);
61-
}
62-
63-
export function overlayExternalOAuthProfiles(
64-
store: AuthProfileStore,
65-
params?: { agentDir?: string; env?: NodeJS.ProcessEnv },
66-
): AuthProfileStore {
67-
const profiles = resolveExternalOAuthProfileMap({
68-
store,
69-
agentDir: params?.agentDir,
70-
env: params?.env,
71-
});
72-
if (profiles.size === 0) {
73-
return store;
74-
}
75-
76-
const next = structuredClone(store);
77-
for (const [profileId, profile] of profiles) {
78-
next.profiles[profileId] = profile.credential;
79-
}
80-
return next;
81-
}
82-
83-
export function shouldPersistExternalOAuthProfile(params: {
84-
store: AuthProfileStore;
85-
profileId: string;
86-
credential: OAuthCredential;
87-
agentDir?: string;
88-
env?: NodeJS.ProcessEnv;
89-
}): boolean {
90-
const external = resolveExternalOAuthProfileMap({
91-
store: params.store,
92-
agentDir: params.agentDir,
93-
env: params.env,
94-
}).get(params.profileId);
95-
if (!external || external.persistence === "persisted") {
96-
return true;
97-
}
98-
return !oauthCredentialMatches(external.credential, params.credential);
99-
}
1+
export {
2+
overlayExternalAuthProfiles,
3+
overlayExternalOAuthProfiles,
4+
shouldPersistExternalAuthProfile,
5+
shouldPersistExternalOAuthProfile,
6+
} from "./external-auth.js";

src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
202202
});
203203
});
204204

205-
it("keeps throwing when expired Codex-managed credentials have no fresh fallback source", async () => {
205+
it("prefers fresh Codex CLI credentials when the stored default profile is expired", async () => {
206206
const profileId = "openai-codex:default";
207207
saveAuthProfileStore(
208208
{
@@ -234,9 +234,13 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
234234
profileId,
235235
agentDir,
236236
}),
237-
).rejects.toThrow(/OAuth token refresh failed for openai-codex/);
237+
).resolves.toEqual({
238+
apiKey: "fresh-cli-access-token",
239+
provider: "openai-codex",
240+
email: undefined,
241+
});
238242

239-
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
243+
expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled();
240244
expect(writeCodexCliCredentialsMock).not.toHaveBeenCalled();
241245
});
242246

@@ -285,7 +289,17 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
285289
provider: "openai-codex",
286290
email: undefined,
287291
});
288-
expect(writeCodexCliCredentialsMock).not.toHaveBeenCalled();
292+
expect(writeCodexCliCredentialsMock).toHaveBeenCalledTimes(1);
293+
expect(writeCodexCliCredentialsMock).toHaveBeenCalledWith(
294+
expect.objectContaining({
295+
type: "oauth",
296+
provider: "openai-codex",
297+
access: "rotated-cli-access-token",
298+
refresh: "rotated-cli-refresh-token",
299+
accountId: "acct-rotated",
300+
managedBy: "codex-cli",
301+
}),
302+
);
289303

290304
const persisted = await readPersistedStore(agentDir);
291305
expect(persisted.profiles[profileId]).toMatchObject({

0 commit comments

Comments
 (0)