OAuth: oauth() resolver, @oauthClient providers, and varlock oauth login - #974
OAuth: oauth() resolver, @oauthClient providers, and varlock oauth login#974theoephraim wants to merge 7 commits into
Conversation
…ange Exchanges a long-lived OAuth credential for a short-lived access token at a provider token endpoint. Tokens are cached with the provider-reported expiry inside a forever-TTL entry so rotated refresh tokens survive access-token expiry. Refreshes are serialized cross-process via a new CacheStore.withKeyLock helper (extracted from getOrSet). cache(oauth()) is rejected since oauth() manages its own expiry.
@oauthProvider(id=..., preset=google|github|microsoft|slack, ...) defines a named provider that oauth() items reference positionally, sharing client config across items. When an item omits refreshToken, the resolver reads a provider-level cache entry provisioned by `varlock oauth login` (device code or PKCE loopback flow); rotated refresh tokens are written back to that shared entry under the provider key lock. `varlock oauth status` (also the bare command) shows providers and provisioning state. Login flows live in lib/oauth-login.ts as executor functions separate from the CLI driver, so a remote proxy can run them later.
Signs a short-lived RS256 assertion (RFC 7523) from a Google-style service account key JSON (serviceAccountKey) or a raw PEM key + issuer, and exchanges it at the token endpoint. tokenUrl falls back to the key file's token_uri; subject supports impersonation; audience overrides the aud claim. No refresh token exists in this flow, so no rotation or provider-entry machinery applies; the cache just avoids re-minting. RSA keys only for now (ES256 needs DER-to-JOSE conversion).
Covers the full workflow: defining providers with presets, provisioning via varlock oauth login (device + browser flows, per-provider app setup table) or explicit vault-stored refresh tokens for CI, the client_credentials and jwt_bearer grants, scope handling, and troubleshooting. Cross-linked from the oauth() and @oauthProvider reference sections.
|
The changes in this PR will be included in the next version bump.
|
📦 Bundle size
dist/ only; native binaries are versioned separately and not counted here. |
| /** key for the shared provider-level refresh-token entry, written by `varlock oauth login` */ | ||
| export function buildOauthProviderCacheKey(parts: { tokenUrl: string; clientId: string }): string { | ||
| const keyMaterial = [parts.tokenUrl, parts.clientId].join('\n'); | ||
| const digest = createHash('sha256').update(keyMaterial).digest('hex').slice(0, 16); |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
varlock-website | 7932142 | Commit Preview URL Branch Preview URL |
Aug 01 2026, 04:16 AM |
There was a problem hiding this comment.
Caution
The cache identity can return an access token for the wrong account or audience, and the documented Google and GitHub login paths do not work with the app types users are told to create.
Reviewed changes across the complete initial PR, including:
- OAuth resolver: refresh-token, client-credentials, and JWT-bearer exchanges with expiry-aware caching and rotation.
- Provider configuration: named
@oauthProviderinstances and Google, GitHub, Microsoft, and Slack presets. - Login flows: device-code and loopback PKCE provisioning plus encrypted provider-level refresh-token storage.
- Concurrency and persistence: per-key cross-process locking and split provider/item cache state.
- User surfaces: CLI login/status commands, reference docs, OAuth guide, changesets, and VS Code completion.
- Tests: resolver, token client, JWT signing, login flows, caching, and rotation coverage.
azure/gpt-5.6-sol | 𝕏
| /** jwt_bearer impersonation subject */ | ||
| subject?: string; | ||
| }): string { | ||
| const keyMaterial = [parts.tokenUrl, parts.grantType, parts.clientId, parts.scope ?? '', parts.refreshToken ?? '', parts.subject ?? ''].join('\n'); |
There was a problem hiding this comment.
These fields do not uniquely identify the credential or token request, so a fresh cache hit can return a token for the wrong account or audience. Logging a second account into the same endpoint/client overwrites the provider credential but still reuses the first account's item token, while two client_credentials calls that differ only by params.audience collide immediately.
Technical details
# Cache identity omits security-relevant request state
## Affected sites
- `packages/varlock/src/lib/oauth.ts:140` - item keys omit provider/account generation, `params`, JWT audience/key, client secret, and auth method
- `packages/varlock/src/lib/oauth.ts:147` - provider keys omit provider identity and account identity
- `packages/varlock/src/env-graph/lib/resolver.ts:1506` - the fast path returns the item token before reading the current provider entry
- `packages/varlock/src/cli/commands/oauth.command.ts:218` - a new login for the same endpoint/client writes the same provider key
## Required outcome
- Distinct provider instances, authenticated accounts, credentials, and semantically distinct token requests must not share cached refresh or access tokens.
- Re-login or credential replacement must invalidate or bypass access tokens minted from the prior credential.
## Suggested approach
- Include a stable provider namespace in provider storage and a credential generation/digest in the item identity.
- Canonically include all token-shaping inputs such as `params`, JWT audience, and client authentication state.| // rotated tokens are stored in the item entry only when the refresh | ||
| // token is item-configured; login-provisioned rotation goes to the | ||
| // shared provider entry below | ||
| refreshToken: usesProviderToken ? undefined : (result.refreshToken ?? entry?.refreshToken), |
There was a problem hiding this comment.
Storing a rotated configured token only in this scope-specific item entry breaks when multiple items bootstrap from the same rotating refresh token. The first scoped exchange invalidates the bootstrap and stores its replacement here, then the next scope's cache miss submits the already-consumed bootstrap and fails.
Technical details
# Rotated item credentials are partitioned by access-token scope
## Affected sites
- `packages/varlock/src/env-graph/lib/resolver.ts:1479` - the item key includes scope
- `packages/varlock/src/env-graph/lib/resolver.ts:1529` - a cache miss falls back to the configured bootstrap token
- `packages/varlock/src/env-graph/lib/resolver.ts:1598` - rotation is retained only in that item's scoped entry
## Required outcome
- Items sharing one configured rotating refresh token must share its latest rotation state while retaining separate access-token entries for different scopes.
## Suggested approach
- Store configured refresh-token rotation in a credential-level entry keyed independently from access-token dimensions such as scope.| // entry TTL is forever because it must outlive the access token - it | ||
| // can carry a rotated refresh token; freshness is checked via expiresAt | ||
| if (cacheStore) { | ||
| await cacheStore.set(itemCacheKey, newEntry, TTL_FOREVER); |
There was a problem hiding this comment.
Both cache writes are best effort and their results are ignored, so a provider can consume the old refresh token while Varlock reports success without persisting its replacement. For login-provisioned tokens this also writes the access token first, leaving a fresh item entry paired with the invalid old provider credential if the second write fails or the process stops between writes.
Technical details
# Refresh-token rotation is not durably committed
## Affected sites
- `packages/varlock/src/env-graph/lib/resolver.ts:1606` - item state is written before shared rotated credential state
- `packages/varlock/src/env-graph/lib/resolver.ts:1614` - provider write success is not checked
- `packages/varlock/src/lib/cache/cache-store.ts:450` - `set()` returns `undefined` on write failure
## Required outcome
- A successful rotating exchange must not silently leave cache state pointing at the consumed credential.
- Shared rotated credential state must be persisted before publishing dependent access-token state, and write failures must be surfaced with actionable recovery behavior.
- Add failure-path coverage for each persistence boundary.| label: 'Google', | ||
| tokenUrl: 'https://oauth2.googleapis.com/token', | ||
| authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', | ||
| deviceAuthorizationUrl: 'https://oauth2.googleapis.com/device/code', |
There was a problem hiding this comment.
Advertising this endpoint makes Google login default to device flow, but Google's device endpoint requires a TVs and Limited Input devices client while the new guide tells users to create a Desktop app client. Following the documented setup therefore fails with invalid_client; either default this preset to browser flow for Desktop clients or document and model the separate client type explicitly.
Technical details
# Google preset defaults to an incompatible client flow
## Affected sites
- `packages/varlock/src/lib/oauth-presets.ts:39` - endpoint presence selects device flow by default
- `packages/varlock/src/cli/commands/oauth.command.ts:144` - default selection prefers device flow
- `packages/varlock-website/src/content/docs/guides/oauth.mdx:66` - setup instructs users to create a Desktop client
## Required outcome
- The default Google flow must work with the client type the guide instructs users to create.
## Provider contract
- Google requires `TVs and Limited Input devices` credentials for this endpoint and recommends the desktop browser flow for command-line tools with browser access: https://developers.google.com/identity/protocols/oauth2/limited-input-device| | Provider | App setup | | ||
| |---|---| | ||
| | Google | Create a "Desktop app" OAuth client at [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials). Desktop clients allow loopback redirects implicitly, and the device flow works for a limited set of scopes. | | ||
| | GitHub | Create an OAuth app at [github.com/settings/developers](https://github.com/settings/developers) and enable device flow. Refresh tokens require "user token expiration" enabled on the app. | |
There was a problem hiding this comment.
GitHub OAuth Apps issue long-lived OAuth tokens and the documented device response has no refresh token, so this setup always reaches toLoginResult() and fails. Refreshable expiring user tokens are a GitHub App feature; the setup instructions and preset notes need to target that app type, or the login implementation must support the non-refreshing OAuth App result.
Technical details
# GitHub setup cannot produce the required refresh token
## Affected sites
- `packages/varlock-website/src/content/docs/guides/oauth.mdx:67` - instructs users to create an OAuth App
- `packages/varlock/src/lib/oauth-presets.ts:51` - attributes user-token expiration to OAuth Apps
- `packages/varlock/src/lib/oauth-login.ts:59` - rejects every result without a refresh token
## Required outcome
- The documented GitHub app type and settings must produce the refresh token required by `oauth()`.
## Provider contract
- GitHub OAuth App device responses contain `access_token`, `token_type`, and `scope`, but no refresh token: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
- Expiring user access tokens and refresh tokens are documented for GitHub Apps: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app
commit: |
In OAuth vocabulary a provider is Google/Okta/Auth0, and what you register with them is a client, so the decorator is now @oauthclient and the old preset= arg is provider= (built-in defs for google, github, microsoft, slack). Clients are addressed by provider name by default (oauth(google, ...)), and an explicit id nests under it: id=dev is addressed as google/dev. Provider-less clients use their id alone. Renamed throughout: EnvGraph.oauthClients, OauthClientRecord, buildOauthClientCacheKey, CLI copy, docs, intellisense.
There was a problem hiding this comment.
✅ No new issues found in this delta. The requested changes in the prior Pullfrog review remain open.
Reviewed changes since the prior Pullfrog review focused on the OAuth client model rename and addressing behavior:
- Renamed provider configuration: Replaced
@oauthProviderand preset terminology with@oauthClientand built-in provider definitions across the resolver, CLI, docs, tests, and VS Code completion. - Introduced client addresses: Derived default addresses from provider names and nested explicit IDs as
provider/id, with exact resolver and CLI lookup plus duplicate-address validation.
azure/gpt-5.6-sol | 𝕏
Tabbed examples per built-in provider showing the naming trick (call the item GH_TOKEN / CLOUDSDK_AUTH_ACCESS_TOKEN / SLACK_BOT_TOKEN and the CLI works under varlock run with no flags) plus minimal SDK snippets. Also a note on process lifetime vs token lifetime: short-lived processes are the standalone sweet spot, long-running servers point at the planned proxy integration.
There was a problem hiding this comment.
Important
The new guide promises per-process token minting that the resolver does not provide.
Reviewed changes since the prior Pullfrog review covered the new OAuth consumption guidance:
- Added provider examples: Documented CLI, SDK, and direct HTTP consumption for Google, GitHub, Microsoft, and Slack tokens.
- Added process-lifetime guidance: Explained the static-token limit for long-running processes and the planned credential proxy path.
azure/gpt-5.6-sol | 𝕏
| </Tabs> | ||
|
|
||
| :::note[Process lifetime vs token lifetime] | ||
| Tokens are minted fresh when your process starts, so short-lived processes are the sweet spot: CLI invocations, scripts, CI jobs, and agent sessions all finish well within a token's ~1 hour lifetime, and every new `varlock run` gets a fresh token automatically. A long-running server will eventually outlive its token; refreshing mid-run without a restart is part of the planned [credential proxy](/guides/proxy/) integration, where the running process holds only a placeholder and varlock swaps in a fresh token at the network boundary. |
There was a problem hiding this comment.
The resolver intentionally reuses a still-fresh cached access token across runs, so a process start does not necessarily mint a new token and a new varlock run may receive the same one. Please describe startup as receiving a valid cached-or-refreshed token instead, since the current wording creates a stronger token-rotation guarantee than the implementation provides.
Leak damage is scope-bounded as well as time-bounded; passing a token to an SDK is the same code with one line different (you only bypass its refresh plumbing); and the standalone vs proxy contrast in one line: standalone keeps durable credentials out of the process, the proxy keeps all credentials out of it.




Adds first-class OAuth token lifecycle to varlock: items resolve to fresh short-lived access tokens while the long-lived credential (refresh token, client secret, or service account key) stays in the vault as an
@internalitem that is never injected.What's included
oauth()resolver with three grants:refresh_token: exchanges a refresh token for an access tokenclient_credentials: M2M identities (Auth0/Okta style)jwt_bearer(RFC 7523): signs an RS256 assertion from a Google-style service account key (or raw PEM + issuer), so apps never hold the permanent key.tokenUrlfalls back to the key file'stoken_uri;subjectsupports impersonation.Tokens are cached in the encrypted cache with the provider-reported expiry (refreshed
skewearly, default 60s) inside a forever-TTL entry, so rotated refresh tokens outlive any one access token. Refreshes serialize cross-process via a newCacheStore.withKeyLock(extracted fromgetOrSet).cache(oauth())is rejected since oauth() manages its own expiry.@oauthClientroot decorator: define an OAuth client (your app registration) once, withprovider=google|github|microsoft|slackfilling in endpoints and vendor quirks, then mint tokens from it:oauth(google, scopes=...). Clients are addressed by provider name by default; an explicit id nests under it (id=dev→oauth(google/dev, ...)), and provider-less clients use their id alone.varlock oauth login/status: browser provisioning via device-code or PKCE-loopback flow. The minted refresh token goes straight into the encrypted cache as a client-level entry shared by every item using that client; no vault write-back involved. Items omittingrefreshTokenuse it automatically, and unprovisioned resolution fails fast with the exact login command. Rotation on refresh writes back to the shared entry under the client key lock. Flow executors are separate from the CLI driver (they own PKCE verifier, state, exchange, and cache write) so a remote proxy can run them later.Docs
New OAuth guide covering the full workflow: per-provider app registration, CI usage, and tabbed SDK/CLI consumption examples for each built-in provider (name the item what the tool reads, e.g.
GH_TOKEN, and it works undervarlock runwith no changes). Plus reference entries foroauth(),@oauthClient, and the CLI, and VSCode intellisense for the new decorator.Testing
58 new tests across four suites: token client, resolver (all grants, client instances and addressing, login-provisioned mode, rotation), assertion signing verified cryptographically against the public key, and both login flows against local mock endpoints. Also verified E2E against a local mock IdP:
oauth login(non-TTY device flow) →oauth status→printenvresolving a fresh token through the provisioned entry.Out of scope (future work): proxy integration (per-request refresh, token-endpoint interception), plugin-contributed provider defs, ES256 signing,
oauth revoke, TTY-gated inline login offer.