From 3d6a8dbd393b0516e787f425ccf1a958bdab483b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 1 Apr 2026 19:07:24 -0400 Subject: [PATCH] fix(account): refresh console tokens before expiry --- packages/opencode/src/account/index.ts | 9 +++- .../opencode/test/account/service.test.ts | 50 +++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index e063eaab564f..947fadad07c3 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -119,6 +119,7 @@ class TokenRefreshRequest extends Schema.Class("TokenRefres }) {} const clientId = "opencode-cli" +const eagerRefreshThreshold = Duration.minutes(5) const mapAccountServiceError = (message = "Account service operation failed") => @@ -218,7 +219,9 @@ export namespace Account { const account = maybeAccount.value const now = yield* Clock.currentTimeMillis - if (account.token_expiry && account.token_expiry > now) return account.access_token + if (account.token_expiry && account.token_expiry > now + Duration.toMillis(eagerRefreshThreshold)) { + return account.access_token + } return yield* refreshToken(account) }), @@ -226,7 +229,9 @@ export namespace Account { const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { const now = yield* Clock.currentTimeMillis - if (row.token_expiry && row.token_expiry > now) return row.access_token + if (row.token_expiry && row.token_expiry > now + Duration.toMillis(eagerRefreshThreshold)) { + return row.access_token + } return yield* Cache.get(refreshTokenCache, row.id) }) diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index a08fce03ca97..e4c43a1f64f8 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -63,7 +63,7 @@ it.live("orgsByAccount groups orgs per account", () => url: "https://one.example.com", accessToken: AccessToken.make("at_1"), refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + 60_000, + expiry: Date.now() + 10 * 60_000, orgID: Option.none(), }), ) @@ -75,7 +75,7 @@ it.live("orgsByAccount groups orgs per account", () => url: "https://two.example.com", accessToken: AccessToken.make("at_2"), refreshToken: RefreshToken.make("rt_2"), - expiry: Date.now() + 60_000, + expiry: Date.now() + 10 * 60_000, orgID: Option.none(), }), ) @@ -148,6 +148,50 @@ it.live("token refresh persists the new token", () => }), ) +it.live("token refreshes before expiry when inside the eager refresh window", () => + Effect.gen(function* () { + const id = AccountID.make("user-1") + + yield* AccountRepo.use((r) => + r.persistAccount({ + id, + email: "user@example.com", + url: "https://one.example.com", + accessToken: AccessToken.make("at_old"), + refreshToken: RefreshToken.make("rt_old"), + expiry: Date.now() + 60_000, + orgID: Option.none(), + }), + ) + + let refreshCalls = 0 + const client = HttpClient.make((req) => + Effect.promise(async () => { + if (req.url === "https://one.example.com/auth/device/token") { + refreshCalls += 1 + return json(req, { + access_token: "at_new", + refresh_token: "rt_new", + expires_in: 60, + }) + } + + return json(req, {}, 404) + }), + ) + + const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) + + expect(String(Option.getOrThrow(token))).toBe("at_new") + expect(refreshCalls).toBe(1) + + const row = yield* AccountRepo.use((r) => r.getRow(id)) + const value = Option.getOrThrow(row) + expect(value.access_token).toBe(AccessToken.make("at_new")) + expect(value.refresh_token).toBe(RefreshToken.make("rt_new")) + }), +) + it.live("concurrent config and token requests coalesce token refresh", () => Effect.gen(function* () { const id = AccountID.make("user-1") @@ -223,7 +267,7 @@ it.live("config sends the selected org header", () => url: "https://one.example.com", accessToken: AccessToken.make("at_1"), refreshToken: RefreshToken.make("rt_1"), - expiry: Date.now() + 60_000, + expiry: Date.now() + 10 * 60_000, orgID: Option.none(), }), )