feat(auth): refresh OAuth tokens in the credential resolver #6

Open
claude-bot wants to merge 1 commit from feat/oauth-refresh-resolver into main
Collaborator

Cline was reachable two ways and only one of them refreshed. ClineAuth ran the browser flow and its getAccessToken() renewed a token at or near expiry, but the CredentialResolver that createServices handed to the provider catalog read the auth store directly and returned credential.accessToken verbatim. Chat requests therefore started failing with a bare 401 once the access token aged out, with a usable refresh token sitting next to it on disk and nothing surfacing the reason.

Changes

provider/credentials.ts (new)createStoreCredentialResolver(store, options?) delegates to the provider's OAuth flow instead of reading the raw token. The flow object is cached per provider id for the life of the resolver: ClineAuth collapses concurrent refreshes internally via inFlightRefresh, and that only holds while the instance is shared, so constructing one per request would stampede the refresh endpoint.

provider/oauth.ts (new) — one shared source of truth for which catalog ids have a browser flow, replacing the ad-hoc OAUTH_PROVIDERS set in commands/auth.ts. The OAuthProvider interface covers the full surface the CLI needs (getAccessToken, login, loginWithApiKey, verify), so the auth command no longer names ClineAuth at all. Backed by a Map rather than an object literal, since {}["constructor"] is both truthy and callable.

createServices — swaps the inline store.getcredential.accessToken block for createStoreCredentialResolver(auth), built once so every request in a session shares its flows.

Typed auth failure

Split by case rather than throwing on all of them:

  • Nothing stored → undefined. This must stay: catalog.createProvider wraps the resolver as (await resolve(id)) ?? (await envCredentialResolver(id)), so throwing here would break the CLINE_API_KEY env fallback.
  • Stored but unusable → AuthError propagates. A rejected refresh now reports "Cline session expired... Run thorny auth login cline" instead of a bare 401. AuthError extends ThornyError, which cli/src/index.ts already prints plainly without a stack trace, so no CLI change was needed. listModels() still swallows it and falls back to the static list, so thorny models does not explode for a provider you have not logged into.

Documented on the CredentialResolver type.

Tests

packages/core/test/credential-resolver.test.ts, 9 tests. The core case is covered twice: the resolver returns the refreshed token and writes the new credential back to the store, and end-to-end through registerBuiltinProviders asserting the header the chat provider actually sends is Bearer refreshed-token. Also: a token not near expiry is left alone with no refresh call, three concurrent resolves collapse to one round-trip, a rejected refresh raises AuthError, an empty store resolves to undefined with the env var still winning, an sk_ api-key stored under an OAuth provider passes straight through, and a none credential stays unauthenticated.

Full suite: 147 pass, 0 fail. typecheck and biome check clean.

Note on scope

This branch is stacked on fix/ollama-config-baseurl, which is pushed but has no open PR and is not yet in main. Both reshape the registerBuiltinProviders/createServices wiring, so they were kept in order to avoid a conflict. This PR therefore contains two commits — the baseurl fix and this one. If the baseurl fix should land separately, open its PR first and rebase this one onto the result.

Cline was reachable two ways and only one of them refreshed. `ClineAuth` ran the browser flow and its `getAccessToken()` renewed a token at or near expiry, but the `CredentialResolver` that `createServices` handed to the provider catalog read the auth store directly and returned `credential.accessToken` verbatim. Chat requests therefore started failing with a bare 401 once the access token aged out, with a usable refresh token sitting next to it on disk and nothing surfacing the reason. ## Changes **`provider/credentials.ts` (new)** — `createStoreCredentialResolver(store, options?)` delegates to the provider's OAuth flow instead of reading the raw token. The flow object is cached per provider id for the life of the resolver: `ClineAuth` collapses concurrent refreshes internally via `inFlightRefresh`, and that only holds while the instance is shared, so constructing one per request would stampede the refresh endpoint. **`provider/oauth.ts` (new)** — one shared source of truth for which catalog ids have a browser flow, replacing the ad-hoc `OAUTH_PROVIDERS` set in `commands/auth.ts`. The `OAuthProvider` interface covers the full surface the CLI needs (`getAccessToken`, `login`, `loginWithApiKey`, `verify`), so the auth command no longer names `ClineAuth` at all. Backed by a `Map` rather than an object literal, since `{}["constructor"]` is both truthy and callable. **`createServices`** — swaps the inline `store.get` → `credential.accessToken` block for `createStoreCredentialResolver(auth)`, built once so every request in a session shares its flows. ## Typed auth failure Split by case rather than throwing on all of them: - **Nothing stored → `undefined`.** This must stay: `catalog.createProvider` wraps the resolver as `(await resolve(id)) ?? (await envCredentialResolver(id))`, so throwing here would break the `CLINE_API_KEY` env fallback. - **Stored but unusable → `AuthError` propagates.** A rejected refresh now reports "Cline session expired... Run `thorny auth login cline`" instead of a bare 401. `AuthError extends ThornyError`, which `cli/src/index.ts` already prints plainly without a stack trace, so no CLI change was needed. `listModels()` still swallows it and falls back to the static list, so `thorny models` does not explode for a provider you have not logged into. Documented on the `CredentialResolver` type. ## Tests `packages/core/test/credential-resolver.test.ts`, 9 tests. The core case is covered twice: the resolver returns the refreshed token and writes the new credential back to the store, and end-to-end through `registerBuiltinProviders` asserting the header the chat provider actually sends is `Bearer refreshed-token`. Also: a token not near expiry is left alone with no refresh call, three concurrent resolves collapse to one round-trip, a rejected refresh raises `AuthError`, an empty store resolves to `undefined` with the env var still winning, an `sk_` api-key stored under an OAuth provider passes straight through, and a `none` credential stays unauthenticated. Full suite: 147 pass, 0 fail. `typecheck` and `biome check` clean. ## Note on scope This branch is stacked on `fix/ollama-config-baseurl`, which is pushed but has no open PR and is not yet in `main`. Both reshape the `registerBuiltinProviders`/`createServices` wiring, so they were kept in order to avoid a conflict. This PR therefore contains **two** commits — the baseurl fix and this one. If the baseurl fix should land separately, open its PR first and rebase this one onto the result.
feat(auth): refresh OAuth tokens in the credential resolver
Some checks failed
CI / ci (pull_request) Has been cancelled
5d97ffe48a
Cline was reachable two ways and only one of them refreshed. `ClineAuth`
ran the browser flow and its `getAccessToken()` renewed a token at or near
expiry, but the resolver `createServices` handed to the catalog read the
store directly and returned `credential.accessToken` verbatim. Chat
requests therefore started failing with a bare 401 once the access token
aged out, with a usable refresh token sitting next to it on disk and
nothing surfacing the reason.

Add `createStoreCredentialResolver`, which delegates to the provider's
OAuth flow instead of reading the raw token. The flow object is cached per
provider id for the life of the resolver: `ClineAuth` collapses concurrent
refreshes internally, and that only holds while the instance is shared —
constructing one per request would stampede the refresh endpoint.

Keep the seam general rather than growing a hardcoded cline branch.
`provider/oauth.ts` is now the one place that knows which catalog ids have
a browser flow, replacing the ad-hoc `OAUTH_PROVIDERS` set in the auth
command, which no longer names `ClineAuth` at all.

Split the failure cases rather than throwing on all of them. Nothing
stored still resolves to undefined, because the catalog falls back to the
provider env var on undefined and throwing would break that. A credential
that exists but cannot be made usable now raises AuthError, so an expired
session reports "run `thorny auth login cline`" instead of a 401. AuthError
extends ThornyError, which the CLI entry point already prints without a
stack trace.
Some checks failed
CI / ci (pull_request) Has been cancelled
This pull request has changes conflicting with the target branch.
  • packages/cli/src/commands/auth.ts
  • packages/core/src/provider/index.ts
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/oauth-refresh-resolver:feat/oauth-refresh-resolver
git switch feat/oauth-refresh-resolver

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff feat/oauth-refresh-resolver
git switch feat/oauth-refresh-resolver
git rebase main
git switch main
git merge --ff-only feat/oauth-refresh-resolver
git switch feat/oauth-refresh-resolver
git rebase main
git switch main
git merge --no-ff feat/oauth-refresh-resolver
git switch main
git merge --squash feat/oauth-refresh-resolver
git switch main
git merge --ff-only feat/oauth-refresh-resolver
git switch main
git merge feat/oauth-refresh-resolver
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
brodycritchlow/thorny!6
No description provided.