Skip to content

Commit 8ade465

Browse files
committed
fix(auth): refresh expired OAuth access tokens on demand
The agent and glue clients both captured the access token by value at startup, so once the OAuth token expired (~1h after launch) every subsequent request kept sending the stale token and the proxy returned 401 invalid_token. Manual \`codebase auth refresh\` was the only way out. Introduce TokenManager: a read-through getter that loads credentials from disk on each call, checks expiry with a 60s skew, and refreshes via the existing refresh-token flow before returning. Single-flight lock prevents concurrent requests from firing parallel refreshes. Wire the token manager into agent.ts (when source === "proxy") and make GlueClient take a getApiKey callback instead of a captured key. BYOK / explicit-env paths keep the static key — those don't expire.
1 parent 45f5e0a commit 8ade465

5 files changed

Lines changed: 248 additions & 5 deletions

File tree

src/agent/agent.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { homedir } from "node:os";
22
import { isAbsolute, join, resolve } from "node:path";
33
import { Agent, type AgentEvent } from "@earendil-works/pi-agent-core";
44
import type { Model } from "@earendil-works/pi-ai";
5+
import { defaultOAuthConfig } from "../auth/cli.js";
6+
import { CredentialsStore } from "../auth/credentials.js";
7+
import { TokenManager } from "../auth/token-manager.js";
58
import { CompactionEngine } from "../compaction/engine.js";
69
import { CompactionMonitor } from "../compaction/monitor.js";
710
import { ConfigStore } from "../config/store.js";
@@ -92,6 +95,15 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
9295
const cwd = opts.cwd ?? process.cwd();
9396
const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(cwd);
9497

98+
// OAuth-sourced credentials rotate ~hourly; build a refresh-aware getter
99+
// so the agent never sends a stale access token after the first refresh
100+
// window. BYOK / explicit / auto sources use the static key passed in.
101+
const tokenManager =
102+
source === "proxy"
103+
? new TokenManager({ store: new CredentialsStore(), oauthConfig: defaultOAuthConfig() })
104+
: null;
105+
const getApiKey = tokenManager ? () => tokenManager.getAccessToken() : () => apiKey;
106+
95107
const config = new ConfigStore({ cwd });
96108
const permissions = new PermissionStore({
97109
allowPatterns: config.allowPatterns(),
@@ -109,7 +121,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
109121
const glue = new GlueClient({
110122
fastModel: glueModels.fast,
111123
smartModel: glueModels.smart,
112-
apiKey: glueModels.apiKey,
124+
getApiKey,
113125
});
114126
const compaction = new CompactionEngine({ glue, modelId: model.id });
115127
const compactionMonitor = new CompactionMonitor();
@@ -127,7 +139,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
127139
spawnSubagent: ({ systemPrompt: subPrompt, tools: subTools }) =>
128140
new Agent({
129141
initialState: { model, systemPrompt: subPrompt, tools: subTools },
130-
getApiKey: () => apiKey,
142+
getApiKey,
131143
}),
132144
};
133145

src/auth/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const DEFAULT_AUTH_BASE = "https://codebase.design";
2020
* codebase.design and codebase.foundation both alias to the same Next
2121
* app; the user-facing brand is .design so it's the default.
2222
*/
23-
function defaultOAuthConfig(env: NodeJS.ProcessEnv = process.env): OAuthConfig {
23+
export function defaultOAuthConfig(env: NodeJS.ProcessEnv = process.env): OAuthConfig {
2424
const base = (env.CODEBASE_AUTH_BASE_URL ?? DEFAULT_AUTH_BASE).replace(/\/+$/, "");
2525
return {
2626
authorizationUrl: `${base}/login`,

src/auth/token-manager.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { type Credentials, CredentialsStore } from "./credentials.js";
6+
import type { OAuthConfig } from "./flow.js";
7+
import { TokenManager } from "./token-manager.js";
8+
9+
const OAUTH: OAuthConfig = {
10+
authorizationUrl: "https://example.test/login",
11+
tokenUrl: "https://example.test/token",
12+
refreshUrl: "https://example.test/refresh",
13+
revokeUrl: "https://example.test/revoke",
14+
clientId: "test-client",
15+
scopes: ["read", "write"],
16+
};
17+
18+
function freshCreds(overrides: Partial<Credentials> = {}): Omit<Credentials, "version"> {
19+
return {
20+
accessToken: "access-current",
21+
refreshToken: "refresh-current",
22+
expiresAt: Date.now() + 60 * 60 * 1000,
23+
scopes: ["read", "write"],
24+
source: "codebase",
25+
userId: "u-1",
26+
email: "u@example.test",
27+
...overrides,
28+
};
29+
}
30+
31+
describe("TokenManager", () => {
32+
let dataRoot: string;
33+
let store: CredentialsStore;
34+
35+
beforeEach(() => {
36+
dataRoot = mkdtempSync(join(tmpdir(), "tm-test-"));
37+
store = new CredentialsStore({ dataRoot });
38+
});
39+
40+
afterEach(() => {
41+
rmSync(dataRoot, { recursive: true, force: true });
42+
vi.restoreAllMocks();
43+
});
44+
45+
it("returns the cached access token when not near expiry", async () => {
46+
store.save(freshCreds());
47+
const tm = new TokenManager({ store, oauthConfig: OAUTH });
48+
const fetchSpy = vi.spyOn(globalThis, "fetch");
49+
await expect(tm.getAccessToken()).resolves.toBe("access-current");
50+
expect(fetchSpy).not.toHaveBeenCalled();
51+
});
52+
53+
it("refreshes and persists when the stored token is within the skew window", async () => {
54+
store.save(freshCreds({ expiresAt: Date.now() + 10_000 })); // 10s left, under 60s skew
55+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
56+
new Response(JSON.stringify({ access_token: "access-NEW", refresh_token: "refresh-NEW", expires_in: 3600 }), {
57+
status: 200,
58+
headers: { "Content-Type": "application/json" },
59+
}),
60+
);
61+
const tm = new TokenManager({ store, oauthConfig: OAUTH });
62+
await expect(tm.getAccessToken()).resolves.toBe("access-NEW");
63+
const persisted = store.load();
64+
expect(persisted?.accessToken).toBe("access-NEW");
65+
expect(persisted?.refreshToken).toBe("refresh-NEW");
66+
// Metadata that the refresh endpoint doesn't echo back is preserved.
67+
expect(persisted?.email).toBe("u@example.test");
68+
expect(persisted?.source).toBe("codebase");
69+
});
70+
71+
it("preserves the existing refresh token when the refresh response omits it", async () => {
72+
store.save(freshCreds({ expiresAt: Date.now() + 5_000 }));
73+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
74+
new Response(JSON.stringify({ access_token: "access-NEW", expires_in: 3600 }), {
75+
status: 200,
76+
headers: { "Content-Type": "application/json" },
77+
}),
78+
);
79+
const tm = new TokenManager({ store, oauthConfig: OAUTH });
80+
await tm.getAccessToken();
81+
expect(store.load()?.refreshToken).toBe("refresh-current");
82+
});
83+
84+
it("single-flights concurrent refresh calls into one network round-trip", async () => {
85+
store.save(freshCreds({ expiresAt: Date.now() + 5_000 }));
86+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
87+
() =>
88+
new Promise((resolve) => {
89+
setTimeout(
90+
() =>
91+
resolve(
92+
new Response(JSON.stringify({ access_token: "access-NEW", expires_in: 3600 }), {
93+
status: 200,
94+
headers: { "Content-Type": "application/json" },
95+
}),
96+
),
97+
20,
98+
);
99+
}),
100+
);
101+
const tm = new TokenManager({ store, oauthConfig: OAUTH });
102+
const results = await Promise.all([tm.getAccessToken(), tm.getAccessToken(), tm.getAccessToken()]);
103+
expect(results).toEqual(["access-NEW", "access-NEW", "access-NEW"]);
104+
expect(fetchSpy).toHaveBeenCalledTimes(1);
105+
});
106+
107+
it("subsequent calls AFTER a refresh resolve hit the cached path again", async () => {
108+
store.save(freshCreds({ expiresAt: Date.now() + 5_000 }));
109+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
110+
new Response(JSON.stringify({ access_token: "access-NEW", expires_in: 3600 }), {
111+
status: 200,
112+
headers: { "Content-Type": "application/json" },
113+
}),
114+
);
115+
const tm = new TokenManager({ store, oauthConfig: OAUTH });
116+
await tm.getAccessToken();
117+
// Now the stored token has a fresh 1h lifetime — should not re-refresh.
118+
await tm.getAccessToken();
119+
expect(fetchSpy).toHaveBeenCalledTimes(1);
120+
});
121+
122+
it("throws a sign-in-needed error when no credentials exist", async () => {
123+
const tm = new TokenManager({ store, oauthConfig: OAUTH });
124+
await expect(tm.getAccessToken()).rejects.toThrow(/codebase auth login/);
125+
});
126+
127+
it("throws when the token expired but no refresh token was saved", async () => {
128+
store.save(freshCreds({ expiresAt: Date.now() + 5_000, refreshToken: undefined }));
129+
const tm = new TokenManager({ store, oauthConfig: OAUTH });
130+
await expect(tm.getAccessToken()).rejects.toThrow(/codebase auth login/);
131+
});
132+
133+
it("treats undefined expiresAt as never-expires (no refresh attempted)", async () => {
134+
store.save(freshCreds({ expiresAt: undefined }));
135+
const fetchSpy = vi.spyOn(globalThis, "fetch");
136+
const tm = new TokenManager({ store, oauthConfig: OAUTH });
137+
await expect(tm.getAccessToken()).resolves.toBe("access-current");
138+
expect(fetchSpy).not.toHaveBeenCalled();
139+
});
140+
});

src/auth/token-manager.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { Credentials, CredentialsStore } from "./credentials.js";
2+
import { type OAuthConfig, refreshAccessToken } from "./flow.js";
3+
4+
export interface TokenManagerOptions {
5+
store: CredentialsStore;
6+
oauthConfig: OAuthConfig;
7+
/**
8+
* Refresh when the access token's remaining lifetime falls below this
9+
* many milliseconds. Default 60s — generous enough to ride out clock
10+
* skew + a slow refresh round-trip without ever sending an expired
11+
* token over the wire.
12+
*/
13+
refreshSkewMs?: number;
14+
}
15+
16+
/**
17+
* Read-through, refresh-aware accessor for the OAuth access token.
18+
*
19+
* Pi-mono's `getApiKey` runs on every API call, so we have to be fast in
20+
* the common case (token still valid). Slow path: refresh + persist before
21+
* returning the new token. A single in-flight promise (`pending`) prevents
22+
* a burst of concurrent calls from firing parallel refreshes at the same
23+
* moment — they all await the one refresh that's already running.
24+
*/
25+
export class TokenManager {
26+
private readonly store: CredentialsStore;
27+
private readonly oauthConfig: OAuthConfig;
28+
private readonly refreshSkewMs: number;
29+
private pending: Promise<string> | null = null;
30+
31+
constructor(options: TokenManagerOptions) {
32+
this.store = options.store;
33+
this.oauthConfig = options.oauthConfig;
34+
this.refreshSkewMs = options.refreshSkewMs ?? 60_000;
35+
}
36+
37+
/**
38+
* Return a valid access token, refreshing if the stored one is within
39+
* `refreshSkewMs` of expiry. Throws when no credentials exist or when
40+
* a refresh fails (network or expired refresh token); the caller turns
41+
* that into a user-facing "please run `codebase auth login`" message.
42+
*/
43+
async getAccessToken(): Promise<string> {
44+
const creds = this.store.load();
45+
if (!creds) throw new Error("not signed in — run `codebase auth login`");
46+
if (!this.needsRefresh(creds)) return creds.accessToken;
47+
if (!creds.refreshToken) {
48+
throw new Error("access token expired and no refresh token saved — run `codebase auth login`");
49+
}
50+
return this.refresh(creds.refreshToken);
51+
}
52+
53+
private needsRefresh(creds: Credentials): boolean {
54+
if (!creds.expiresAt) return false;
55+
return creds.expiresAt - this.refreshSkewMs <= Date.now();
56+
}
57+
58+
private refresh(refreshToken: string): Promise<string> {
59+
if (this.pending) return this.pending;
60+
this.pending = (async () => {
61+
try {
62+
const next = await refreshAccessToken(this.oauthConfig, refreshToken);
63+
// Preserve fields the refresh response doesn't echo back (source,
64+
// email, userId) so they survive every rotation. The refresh
65+
// response is authoritative for tokens + expiry; we layer the
66+
// stable metadata back on top.
67+
const existing = this.store.load();
68+
this.store.save({
69+
accessToken: next.accessToken,
70+
refreshToken: next.refreshToken ?? existing?.refreshToken ?? refreshToken,
71+
expiresAt: next.expiresAt,
72+
scopes: next.scopes,
73+
source: existing?.source ?? next.source,
74+
userId: existing?.userId ?? next.userId,
75+
email: existing?.email ?? next.email,
76+
provider: existing?.provider ?? next.provider,
77+
});
78+
return next.accessToken;
79+
} finally {
80+
this.pending = null;
81+
}
82+
})();
83+
return this.pending;
84+
}
85+
}

src/glue/client.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import { completeSimple, getModel, type KnownProvider, type Model } from "@earen
1818
export interface GlueOptions {
1919
fastModel: Model<string>;
2020
smartModel: Model<string>;
21-
apiKey: string;
21+
/**
22+
* Resolves the API key for each call. Async so callers can layer
23+
* OAuth refresh-on-demand: when the underlying access token is near
24+
* expiry, the getter swaps in a new one transparently.
25+
*/
26+
getApiKey: () => Promise<string> | string;
2227
/** Default 8000 — glue calls cap their reply length so a runaway summary doesn't hang the UI. */
2328
maxTokens?: number;
2429
}
@@ -40,14 +45,15 @@ export class GlueClient {
4045
system: string | undefined,
4146
signal: AbortSignal | undefined,
4247
): Promise<string> {
48+
const apiKey = await this.options.getApiKey();
4349
const message = await completeSimple(
4450
model,
4551
{
4652
systemPrompt: system,
4753
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
4854
},
4955
{
50-
apiKey: this.options.apiKey,
56+
apiKey,
5157
signal,
5258
maxTokens: this.options.maxTokens ?? 8000,
5359
},

0 commit comments

Comments
 (0)