Skip to content

OAuth: oauth() resolver, @oauthClient providers, and varlock oauth login - #974

Open
theoephraim wants to merge 7 commits into
mainfrom
oauth-resolver
Open

OAuth: oauth() resolver, @oauthClient providers, and varlock oauth login#974
theoephraim wants to merge 7 commits into
mainfrom
oauth-resolver

Conversation

@theoephraim

@theoephraim theoephraim commented Jul 31, 2026

Copy link
Copy Markdown
Member

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 @internal item that is never injected.

What's included

oauth() resolver with three grants:

  • refresh_token: exchanges a refresh token for an access token
  • client_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. tokenUrl falls back to the key file's token_uri; subject supports impersonation.

Tokens are cached in the encrypted cache with the provider-reported expiry (refreshed skew early, default 60s) inside a forever-TTL entry, so rotated refresh tokens outlive any one access token. Refreshes serialize cross-process via a new CacheStore.withKeyLock (extracted from getOrSet). cache(oauth()) is rejected since oauth() manages its own expiry.

@oauthClient root decorator: define an OAuth client (your app registration) once, with provider=google|github|microsoft|slack filling 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=devoauth(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 omitting refreshToken use 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 under varlock run with no changes). Plus reference entries for oauth(), @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 statusprintenv resolving 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.

…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.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

bumpy-frog

The changes in this PR will be included in the next version bump.

minor Minor releases

  • varlock 1.16.0 → 1.17.0

patch Patch releases

  • env-spec-language 0.3.2 → 0.3.3

Bump files in this PR

Click here if you want to add another bump file to this PR


This comment is maintained by bumpy.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size

⚠️ grows the bundle by 180.5 KB (+3.6%)

Metric main This PR Δ
Total dist 5007.0 KB 5187.5 KB +180.5 KB (+3.6%)
JS 1716.6 KB 1772.9 KB +56.3 KB (+3.3%)
Sourcemaps 3213.8 KB 3335.0 KB +121.2 KB (+3.8%)
Type defs 76.6 KB 79.7 KB +3.0 KB (+4.0%)

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);
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 @oauthProvider instances 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/varlock@974

commit: 7932142

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.
@theoephraim theoephraim changed the title OAuth: oauth() resolver, @oauthProvider presets, and varlock oauth login OAuth: oauth() resolver, @oauthClient providers, and varlock oauth login Jul 31, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 @oauthProvider and preset terminology with @oauthClient and 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.

Pullfrog  | Fix it ➔View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants