diff --git a/.bumpy/oauth-jwt-bearer.md b/.bumpy/oauth-jwt-bearer.md
new file mode 100644
index 000000000..5c9d11b45
--- /dev/null
+++ b/.bumpy/oauth-jwt-bearer.md
@@ -0,0 +1,5 @@
+---
+varlock: minor
+---
+
+oauth() now supports the jwt_bearer grant (RFC 7523): sign an RS256 assertion from a Google-style service account key (or a raw private key + issuer) and exchange it for a short-lived access token, so apps and agents never hold the permanent key
diff --git a/.bumpy/oauth-provider-login.md b/.bumpy/oauth-provider-login.md
new file mode 100644
index 000000000..fc0d145b1
--- /dev/null
+++ b/.bumpy/oauth-provider-login.md
@@ -0,0 +1,6 @@
+---
+varlock: minor
+env-spec-language: patch
+---
+
+New @oauthClient root decorator (with built-in provider defs for google, github, microsoft, slack) and varlock oauth login/status commands: define an OAuth client once, provision a refresh token via a browser or device-code login flow, and mint access tokens from it with oauth() without storing a refresh token anywhere
diff --git a/.bumpy/oauth-resolver.md b/.bumpy/oauth-resolver.md
new file mode 100644
index 000000000..1b24c3e09
--- /dev/null
+++ b/.bumpy/oauth-resolver.md
@@ -0,0 +1,5 @@
+---
+varlock: minor
+---
+
+New oauth() resolver function: exchange a refresh token or client credentials at a provider token endpoint for a short-lived access token, cached until the provider-reported expiry, with automatic handling of rotating refresh tokens
diff --git a/packages/varlock-website/src/content/docs/guides/oauth.mdx b/packages/varlock-website/src/content/docs/guides/oauth.mdx
new file mode 100644
index 000000000..1d4bb188e
--- /dev/null
+++ b/packages/varlock-website/src/content/docs/guides/oauth.mdx
@@ -0,0 +1,240 @@
+---
+title: OAuth tokens
+description: Mint short-lived OAuth access tokens from refresh tokens, client credentials, or service account keys, without handing the long-lived credential to your app
+---
+
+import { Tabs, TabItem } from '@astrojs/starlight/components';
+
+Many APIs (Google, Slack, GitHub Apps, Microsoft, Auth0, and others) issue short-lived access tokens that expire after about an hour. To keep working, something has to hold a long-lived credential (a refresh token, client secret, or service account key) and exchange it for fresh tokens. Usually that something is your app's SDK, which means the long-lived credential sits in your process env.
+
+Varlock moves that exchange into config resolution. The long-lived credential stays in your vault as an [`@internal`](/reference/item-decorators/#internal) item that is never injected, and your app only ever receives a fresh short-lived access token. If the token leaks (a log line, a compromised dependency, an agent running `printenv`), the damage is bounded to that token's scopes for its remaining lifetime, instead of a permanent credential that can mint anything.
+
+```env-spec
+# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)
+# ---
+# @internal
+GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com
+# @internal @sensitive
+GOOGLE_CLIENT_SECRET=op("op://dev/google-oauth/client secret")
+
+# resolves to a fresh access token, refreshed automatically as it expires
+# @sensitive
+DRIVE_TOKEN=oauth(google, scopes="https://www.googleapis.com/auth/drive.readonly")
+```
+
+```bash
+varlock oauth login google # one-time browser login, stores the refresh token
+varlock run -- your-app # DRIVE_TOKEN is a valid access token
+```
+
+## How it works
+
+The [`oauth()`](/reference/functions/#oauth) function calls the provider's token endpoint during resolution. Tokens are cached in the [encrypted cache](/guides/caching/) with the expiry the provider reported, so repeated runs reuse the same token until shortly before it expires (60s early by default, tunable via `skew`). Parallel `varlock run` invocations coordinate through a lock so the provider sees one exchange, not a stampede.
+
+Providers that rotate refresh tokens on every use (Google and Slack do) are handled automatically: the rotated token is stored in the cache and used for the next refresh.
+
+## Defining a client
+
+The [`@oauthClient`](/reference/root-decorators/#oauthclient) root decorator holds client config in one place so several items can mint tokens from it. The `provider=` arg fills in endpoints and quirks for known providers:
+
+```env-spec
+# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)
+# @oauthClient(provider=github, clientId=$GH_CLIENT_ID, clientSecret=$GH_CLIENT_SECRET)
+```
+
+Known providers: `google`, `github`, `microsoft`, `slack`. For anything else, set `tokenUrl` (and `authorizationUrl` / `deviceAuthorizationUrl` if you want browser login) explicitly. Item-level args always override provider-level ones.
+
+For a one-off token you can skip the provider entirely and pass everything inline to `oauth()`; see the [function reference](/reference/functions/#oauth).
+
+## Getting the initial credential
+
+The token exchange needs a long-lived credential to start from. There are two ways to provide it.
+
+### Option 1: `varlock oauth login` (local development)
+
+```bash
+varlock oauth login google
+```
+
+This runs a browser login flow and stores the resulting refresh token in the encrypted cache. Every item referencing that provider *without* an explicit `refreshToken` uses it from then on. Two flows are supported:
+
+- **Device code** (default when the provider supports it): the terminal shows a short code, you enter it on the provider's site. No redirect configuration needed at all.
+- **Browser** (`--flow browser`): opens the provider's consent page and catches the redirect on a local loopback server. Requires the OAuth app to allow loopback redirects.
+
+You need an OAuth app registered with the provider first. This is a one-time setup per team:
+
+| 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. |
+| Microsoft | Register a public client (mobile & desktop) app. The provider def uses the "common" tenant; set `tokenUrl`/`authorizationUrl` to pin a tenant. |
+| Slack | Neither flow works locally (no device flow, https-only redirects). Provision a refresh token elsewhere and use option 2. Token rotation must be enabled on the app. |
+
+Login-provisioned tokens live in this machine's encrypted cache. Clearing the cache means logging in again, and each machine logs in separately. `varlock oauth status` (or bare `varlock oauth`) shows what is provisioned.
+
+### Option 2: explicit `refreshToken` (CI and servers)
+
+Store a refresh token in your vault and reference it directly:
+
+```env-spec
+# @internal @sensitive
+GOOGLE_REFRESH_TOKEN=op("op://ci/google-oauth/refresh token")
+# @sensitive
+DRIVE_TOKEN=oauth(google, refreshToken=$GOOGLE_REFRESH_TOKEN, scopes="https://www.googleapis.com/auth/drive.readonly")
+```
+
+This is the right form for CI, where there is no browser and no persistent login. Note that in CI without a persistent cache, a provider that rotates refresh tokens will invalidate the stored one after the first exchange; varlock prints a warning when this happens. Either use a non-rotating provider credential, or set up a [persistent CI cache](/guides/caching/) via `_VARLOCK_CACHE_KEY`.
+
+## Using the token
+
+The resolved item is a plain env var holding a valid access token, which makes consumption a naming exercise:
+
+- **CLIs**: name the schema item whatever the tool already reads (`GH_TOKEN`, `CLOUDSDK_AUTH_ACCESS_TOKEN`, `SLACK_BOT_TOKEN`), and it just works under `varlock run` with no flags or wrapper scripts.
+- **SDKs**: read the env var and pass it as a static token. Nearly every SDK accepts one as an alternative to managing credentials itself, so this is the same code with one line different: the SDK keeps doing everything else, and the only part you bypass is its refresh plumbing, which is exactly the part that needed the permanent credential in your process.
+
+
+
+
+`gcloud` reads `CLOUDSDK_AUTH_ACCESS_TOKEN` directly:
+
+```env-spec
+# @sensitive
+CLOUDSDK_AUTH_ACCESS_TOKEN=oauth(google, scopes="https://www.googleapis.com/auth/cloud-platform")
+```
+
+```bash
+varlock run -- gcloud storage ls
+```
+
+With the `googleapis` SDK, hand the token to an `OAuth2` client instead of configuring client secrets in code:
+
+```js
+import { google } from 'googleapis';
+
+const auth = new google.auth.OAuth2();
+auth.setCredentials({ access_token: process.env.CLOUDSDK_AUTH_ACCESS_TOKEN });
+const drive = google.drive({ version: 'v3', auth });
+```
+
+Any Google REST API also accepts the token as a bearer header:
+
+```bash
+varlock run -- sh -c 'curl -H "Authorization: Bearer $CLOUDSDK_AUTH_ACCESS_TOKEN" https://www.googleapis.com/drive/v3/files'
+```
+
+
+
+
+The `gh` CLI (and most GitHub tooling) reads `GH_TOKEN`:
+
+```env-spec
+# @sensitive
+GH_TOKEN=oauth(github)
+```
+
+```bash
+varlock run -- gh pr list
+```
+
+With Octokit:
+
+```js
+import { Octokit } from 'octokit';
+
+const octokit = new Octokit({ auth: process.env.GH_TOKEN });
+```
+
+
+
+
+```env-spec
+# @sensitive
+MS_GRAPH_TOKEN=oauth(microsoft, scopes="User.Read Mail.Read")
+```
+
+Call Microsoft Graph directly:
+
+```bash
+varlock run -- sh -c 'curl -H "Authorization: Bearer $MS_GRAPH_TOKEN" https://graph.microsoft.com/v1.0/me'
+```
+
+With the Graph SDK, the auth provider is one line:
+
+```js
+import { Client } from '@microsoft/microsoft-graph-client';
+
+const client = Client.init({
+ authProvider: (done) => done(null, process.env.MS_GRAPH_TOKEN),
+});
+```
+
+
+
+
+Bolt and most Slack tooling read `SLACK_BOT_TOKEN` (remember Slack needs the [explicit refreshToken form](#option-2-explicit-refreshtoken-ci-and-servers)):
+
+```env-spec
+# @internal @sensitive
+SLACK_REFRESH_TOKEN=op("op://dev/slack-app/refresh token")
+# @sensitive
+SLACK_BOT_TOKEN=oauth(slack, refreshToken=$SLACK_REFRESH_TOKEN)
+```
+
+```js
+import { WebClient } from '@slack/web-api';
+
+const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
+```
+
+Or call the Web API directly:
+
+```bash
+varlock run -- sh -c 'curl -H "Authorization: Bearer $SLACK_BOT_TOKEN" https://slack.com/api/auth.test'
+```
+
+
+
+
+:::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. Put simply: standalone keeps the durable credentials out of your process, and the proxy keeps all credentials out of it.
+:::
+
+## Machine-to-machine grants
+
+Not everything starts from a user consent flow. Two more grants cover service identities:
+
+**`client_credentials`**: for providers where the client id + secret *is* the identity (Auth0, Okta, and most "M2M applications"):
+
+```env-spec
+# @sensitive
+API_TOKEN=oauth(tokenUrl="https://myorg.auth0.com/oauth/token", grant="client_credentials", clientId=$AUTH0_CLIENT_ID, clientSecret=$AUTH0_CLIENT_SECRET, params={ audience="https://api.myorg.com" })
+```
+
+**`jwt_bearer`**: for providers that give you a signing key instead of a secret, most commonly Google service accounts. Varlock signs a short-lived RS256 assertion with the key and exchanges it. The key file supplies the endpoint and identity, so config is minimal:
+
+```env-spec
+# @internal @sensitive
+GCP_SA_KEY=op("op://infra/gcp-sa/key json")
+# @sensitive
+GCP_TOKEN=oauth(grant="jwt_bearer", serviceAccountKey=$GCP_SA_KEY, scopes="https://www.googleapis.com/auth/cloud-platform")
+```
+
+This replaces the usual pattern of handing the entire service account JSON (a permanent credential) to your app so its SDK can sign. The key stays `@internal`; the app gets a one-hour token. Non-Google providers use the `privateKey` + `issuer` form instead, and `subject` supports impersonation (e.g. Google domain-wide delegation). See the [function reference](/reference/functions/#oauth) for all args.
+
+## Scopes
+
+Each item requests its own scopes, and items sharing a provider get separately-scoped access tokens from one shared refresh token. `varlock oauth login` requests the union of every scope used in your schema (plus any provider-required ones, like Microsoft's `offline_access`), so one login covers all items. If you add an item with new scopes later, run login again.
+
+## Troubleshooting
+
+- **`invalid_grant` on refresh**: the refresh token is expired or revoked. Run `varlock oauth login` again, or re-provision the vault-stored token. For `jwt_bearer` this usually means the key was revoked, the subject is not authorized, or your clock is off.
+- **Login succeeds but no refresh token is returned**: the provider needs opt-in. GitHub apps need "user token expiration" enabled; Google needs the `access_type=offline` and `prompt=consent` params (varlock sends them for the google provider).
+- **`no refresh token has been provisioned`**: an item references a provider without `refreshToken`, and this machine has not run `varlock oauth login` (or the cache was cleared).
+- **Wrapping in `cache()` is an error**: `oauth()` already caches tokens according to their real expiry; a generic TTL would serve expired tokens.
+
+## Related
+
+- [`oauth()` function reference](/reference/functions/#oauth)
+- [`@oauthClient` decorator reference](/reference/root-decorators/#oauthclient)
+- [`varlock oauth` CLI reference](/reference/cli/project/#oauth)
+- [Caching guide](/guides/caching/) for where token state lives and how to inspect or clear it
diff --git a/packages/varlock-website/src/content/docs/reference/cli/project.mdx b/packages/varlock-website/src/content/docs/reference/cli/project.mdx
index 1ad9acd3b..8d5d1bbae 100644
--- a/packages/varlock-website/src/content/docs/reference/cli/project.mdx
+++ b/packages/varlock-website/src/content/docs/reference/cli/project.mdx
@@ -170,6 +170,47 @@ The output directory is a generated artifact: add it to `.gitignore`, and rerun
+## `varlock oauth` ||oauth||
+
+Manages OAuth clients defined with [`@oauthClient`](/reference/root-decorators/#oauthclient) and the refresh tokens used by [`oauth()`](/reference/functions/#oauth) items.
+
+```bash
+varlock oauth [status|login] [client-id]
+```
+
+### `varlock oauth login`
+
+Runs a browser login flow against a provider and stores the resulting refresh token in the encrypted cache. Items using `oauth(, ...)` without an explicit `refreshToken` resolve using this stored token from then on. Requires a persistent (disk) cache.
+
+The requested scopes default to the union of scopes used by items referencing the provider, plus any client-level `scopes` and provider-required scopes (e.g. `offline_access` for Microsoft).
+
+**Positional arguments:**
+- `[client-id]`: which `@oauthClient` to log in to, e.g. `google` or `google/dev` (optional when only one is defined)
+
+**Flags:**
+- `--flow `: `device` shows a short code to enter on the provider's site (default when supported); `browser` opens the provider's consent page and catches the redirect on a local loopback server. The browser flow requires the OAuth app to allow loopback redirects (register it as a native/desktop app type).
+- `--scopes `: override the requested scopes
+- `--path / -p`: env file entry point, same as other commands
+
+**Examples:**
+```bash
+# log in (single provider defined)
+varlock oauth login
+
+# specific provider, forcing the loopback browser flow
+varlock oauth login google --flow browser
+```
+
+Login-provisioned tokens live in this machine's encrypted cache: clearing the cache means logging in again. For CI, store a refresh token in your vault and pass it to `oauth()` via `refreshToken` instead.
+
+### `varlock oauth status`
+
+Shows each defined provider, which items use it, and whether a refresh token has been provisioned. Bare `varlock oauth` does the same.
+
+
+
+
+
## `varlock telemetry` ||telemetry||
Opts in/out of anonymous usage analytics. This command creates/updates a configuration file at `$XDG_CONFIG_HOME/varlock/config.json` (defaults to `~/.config/varlock/config.json`) saving your preference.
diff --git a/packages/varlock-website/src/content/docs/reference/functions.mdx b/packages/varlock-website/src/content/docs/reference/functions.mdx
index 2f4b9c2e6..7be24e7cd 100644
--- a/packages/varlock-website/src/content/docs/reference/functions.mdx
+++ b/packages/varlock-website/src/content/docs/reference/functions.mdx
@@ -22,7 +22,7 @@ CONFIG=exec(`./scripts/load-config.sh ${APP_ENV}`)
```
-There are built-in utility functions, [random value generators](#random-value-generators), [`generateOtp()`](#generateotp) for 2FA codes, a [`cache()`](#cache) function for reusing values according to your global cache mode, encryption functions for device-local secrets, and plugin-provided resolver functions that can fetch data from external providers. See the [Plugins guide](/guides/plugins/) for more information on plugin-provided functions.
+There are built-in utility functions, [random value generators](#random-value-generators), [`generateOtp()`](#generateotp) for 2FA codes, [`oauth()`](#oauth) for exchanging long-lived OAuth credentials for fresh access tokens, a [`cache()`](#cache) function for reusing values according to your global cache mode, encryption functions for device-local secrets, and plugin-provided resolver functions that can fetch data from external providers. See the [Plugins guide](/guides/plugins/) for more information on plugin-provided functions.
## Core
@@ -415,6 +415,80 @@ A few other things to know:
+## OAuth tokens
+
+
+
+### `oauth()`
+
+Exchanges a long-lived OAuth credential for a short-lived access token by calling the provider's token endpoint. The item resolves to a fresh access token, and only that token is injected. The refresh token and client secret stay in your vault, referenced as [`@internal`](/reference/item-decorators/#internal) items that never reach your app or child processes. See the [OAuth guide](/guides/oauth/) for the full workflow.
+
+Tokens are cached (encrypted, according to your [cache mode](/reference/root-decorators/#cache)) and reused until the provider-reported expiry, so repeated invocations do not hit the token endpoint. When a provider rotates refresh tokens on each use (Google and Slack do), the rotated token is stored in the cache and used for the next refresh automatically; the configured refresh token is just the bootstrap.
+
+An optional first positional arg references an [`@oauthClient`](/reference/root-decorators/#oauthclient) instance by id, which supplies `tokenUrl`, `clientId`, `clientSecret`, and `clientAuth` so several items can share one client config. Item-level args override client-level ones.
+
+Options:
+
+- `tokenUrl=S`: the provider's token endpoint (required unless a client instance or service account key supplies it). Must be https (plain http is allowed for localhost).
+- `grant=S` option: `refresh_token` (default), `client_credentials`, or `jwt_bearer`
+- `refreshToken=R`: the refresh token, usually a reference to another item. Required for the `refresh_token` grant unless a client instance is referenced, in which case omitting it means "use the login-provisioned token" (see below).
+- `clientId=R`: the OAuth client id (required unless a client instance supplies it; optional for `jwt_bearer`)
+- `clientSecret=R` option: the OAuth client secret. Not needed for public (PKCE) clients.
+- `clientAuth=S` option: how client credentials are sent, `body` (default) or `basic` for HTTP basic auth. Some providers (e.g. Notion) require `basic`.
+- `scopes=R` option: a space-delimited string or an array of scope strings
+- `params={...}` option: extra form params for the token request, e.g. `params={ audience="..." }` for Auth0
+- `skew=N` option: refresh this long before the reported expiry, in seconds or a duration string (default: `60s`)
+
+`jwt_bearer`-only options (RFC 7523, e.g. Google service accounts): instead of a stored credential, varlock signs a short-lived RS256 assertion with a private key and exchanges it for an access token.
+
+- `serviceAccountKey=R`: a Google-style service account key JSON (supplies the signing key, issuer, and token endpoint)
+- `privateKey=R` + `issuer=R`: raw PEM key and `iss` claim, for non-Google providers
+- `subject=R` option: `sub` claim, for providers that support impersonation (e.g. Google domain-wide delegation)
+- `audience=S` option: `aud` claim override (defaults to the token endpoint)
+
+```env-spec "oauth"
+# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)
+# ---
+# @internal
+GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com
+# @internal @sensitive
+GOOGLE_CLIENT_SECRET=op("op://dev/google-oauth/client secret")
+
+# no refreshToken: provision once with `varlock oauth login google`
+# @sensitive
+DRIVE_TOKEN=oauth(google, scopes="https://www.googleapis.com/auth/drive.readonly")
+
+# or pass a vault-stored refresh token explicitly (e.g. for CI)
+# @internal @sensitive
+GOOGLE_REFRESH_TOKEN=op("op://dev/google-oauth/refresh token")
+# @sensitive
+SHEETS_TOKEN=oauth(google, refreshToken=$GOOGLE_REFRESH_TOKEN, scopes="https://www.googleapis.com/auth/spreadsheets.readonly")
+
+# fully inline, no client instance
+# @sensitive
+API_TOKEN=oauth(tokenUrl="https://myorg.auth0.com/oauth/token", grant="client_credentials", clientId=$AUTH0_CLIENT_ID, clientSecret=$AUTH0_CLIENT_SECRET, params={ audience="https://api.myorg.com" })
+
+# Google service account (jwt_bearer): the key file supplies everything
+# @internal @sensitive
+GCP_SA_KEY=op("op://infra/gcp-sa/key json")
+# @sensitive
+GCP_TOKEN=oauth(grant="jwt_bearer", serviceAccountKey=$GCP_SA_KEY, scopes="https://www.googleapis.com/auth/cloud-platform")
+```
+
+A few things to know:
+
+- **The initial refresh token has to come from somewhere.** Either run [`varlock oauth login`](/reference/cli/project/#oauth) once (stores it in the encrypted cache, per machine), or run your provider's authorization flow elsewhere and store the token in your vault, passing it via `refreshToken`. The explicit form is the right one for CI.
+- **Login-provisioned tokens are shared per provider.** Items referencing the same provider without their own `refreshToken` share one refresh token; each item still gets its own access token scoped to its `scopes`.
+- **Rotation needs a persistent cache.** With caching disabled (`--skip-cache`, or no cache store available), a provider that rotates refresh tokens will invalidate the configured one after the first exchange. varlock prints a warning when this happens.
+- **Concurrent invocations share one refresh.** Parallel `varlock run` processes on the same machine coordinate through a lock, so a rotating provider sees one exchange, not a stampede.
+- **If a refresh fails with `invalid_grant`**, the refresh token is expired or revoked. Re-run `varlock oauth login` (or re-provision the vault-stored token).
+
+:::caution[Do not wrap in cache()]
+Wrapping this in [`cache()`](#cache) is an error. `oauth()` already caches tokens according to their provider-reported expiry; a generic cache TTL would serve expired tokens. Wrapping the *inputs* in `cache()` is fine.
+:::
+
+
+
## Caching
diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
index 133aa796e..09ada793f 100644
--- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
+++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx
@@ -526,6 +526,34 @@ WEBHOOK_SECRET=yourPreferredPlugin()
```
+
+### `@oauthClient()`
+**Arg types:** `(provider?: string, id?: string, tokenUrl?: string, authorizationUrl?: string, deviceAuthorizationUrl?: string, clientAuth?: string, clientId, clientSecret?, scopes?)`
+
+Defines an OAuth client (your app registration with a provider) that [`oauth()`](/reference/functions/#oauth) items reference, so client config is written once and shared by every token minted from it. Can be declared multiple times. See the [OAuth guide](/guides/oauth/) for the full workflow.
+
+- `provider`: fills in endpoints and quirks for a known provider: `google`, `github`, `microsoft`, or `slack`. Explicit args override provider values.
+- `id`: distinguishes multiple clients for one provider. Clients are addressed by provider name by default (`oauth(google, ...)`), and an explicit id nests under it: `id=dev` is addressed as `google/dev`. Without a provider, the id stands alone.
+- `tokenUrl`: token endpoint (required unless the provider supplies it)
+- `authorizationUrl` / `deviceAuthorizationUrl`: authorization endpoints, used by [`varlock oauth login`](/reference/cli/project/#oauth)
+- `clientAuth`: how client credentials are sent to the token endpoint, `body` (default) or `basic`
+- `clientId` (required) and `clientSecret`: usually references to other items
+- `scopes`: default scopes for items that don't specify their own
+
+```env-spec "@oauthClient"
+# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)
+# ---
+# @internal
+GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com
+# @internal @sensitive
+GOOGLE_CLIENT_SECRET=varlock(local:abc123...)
+# @sensitive
+DRIVE_TOKEN=oauth(google, scopes="https://www.googleapis.com/auth/drive.readonly")
+```
+
+Items referencing a client may omit `refreshToken` entirely: run [`varlock oauth login google`](/reference/cli/project/#oauth) once and the resulting refresh token is stored in the encrypted cache, shared by every item using that client.
+
+
## Code generation ||code-generation||
diff --git a/packages/varlock-website/src/sidebar.ts b/packages/varlock-website/src/sidebar.ts
index 7043c262b..f6fd0e393 100644
--- a/packages/varlock-website/src/sidebar.ts
+++ b/packages/varlock-website/src/sidebar.ts
@@ -47,6 +47,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
{ label: 'Local encryption', slug: 'guides/local-encryption' },
{ label: 'Encrypted deployments', slug: 'guides/encrypted-deployments' },
{ label: 'Caching', slug: 'guides/caching' },
+ { label: 'OAuth tokens', slug: 'guides/oauth', badge: 'new' },
{ label: 'OIDC Workload Identity', slug: 'guides/oidc' },
],
},
diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts
index 9cd7ba924..0d3c26eb0 100644
--- a/packages/varlock/src/cli/cli-executable.ts
+++ b/packages/varlock/src/cli/cli-executable.ts
@@ -36,6 +36,7 @@ import { commandSpec as generateKeyCommandSpec } from './commands/generate-key.c
import { commandSpec as cacheCommandSpec } from './commands/cache.command';
import { commandSpec as keychainCommandSpec } from './commands/keychain.command';
import { commandSpec as proxyCommandSpec } from './commands/proxy.command';
+import { commandSpec as oauthCommandSpec } from './commands/oauth.command';
// import { commandSpec as loginCommandSpec } from './commands/login.command';
// import { commandSpec as pluginCommandSpec } from './commands/plugin.command';
@@ -83,6 +84,7 @@ subCommands.set('generate-key', buildLazyCommand(generateKeyCommandSpec, async (
subCommands.set('cache', buildLazyCommand(cacheCommandSpec, async () => await import('./commands/cache.command')));
subCommands.set('keychain', buildLazyCommand(keychainCommandSpec, async () => await import('./commands/keychain.command')));
subCommands.set('proxy', buildLazyCommand(proxyCommandSpec, async () => await import('./commands/proxy.command')));
+subCommands.set('oauth', buildLazyCommand(oauthCommandSpec, async () => await import('./commands/oauth.command')));
// subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command')));
// subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command')));
diff --git a/packages/varlock/src/cli/commands/oauth.command.ts b/packages/varlock/src/cli/commands/oauth.command.ts
new file mode 100644
index 000000000..919c67c61
--- /dev/null
+++ b/packages/varlock/src/cli/commands/oauth.command.ts
@@ -0,0 +1,314 @@
+import ansis from 'ansis';
+import { define } from 'gunshi';
+
+import { loadVarlockEnvGraph } from '../../lib/load-graph';
+import { checkForSchemaErrors } from '../helpers/error-checks';
+import { CliExitError } from '../helpers/exit-error';
+import { openUrl } from '../helpers/open-url';
+import { keyPressed } from '../helpers/key-press';
+import { trackCommand } from '../helpers/telemetry';
+import { logLines } from '../helpers/pretty-format';
+import { runDeviceCodeLogin, runPkceLogin, OauthLoginError } from '../../lib/oauth-login';
+import {
+ buildOauthClientCacheKey, formatOauthScopesForDisplay,
+ type OauthClientCacheEntry,
+} from '../../lib/oauth';
+import { TTL_FOREVER } from '../../lib/cache/ttl-parser';
+import { InMemoryCacheStore } from '../../lib/cache';
+import { formatTimeAgo } from '../../lib/formatting';
+import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';
+import type { OauthClientRecord } from '../../env-graph';
+
+const PATH_ARG = {
+ type: 'string',
+ short: 'p',
+ multiple: true,
+ description: 'Path to a specific .env file or directory (with trailing slash) to use as the entry point (can be specified multiple times)',
+} as const;
+
+async function loadGraphWithProviders(paths?: Array) {
+ const envGraph = await loadVarlockEnvGraph({ entryFilePaths: paths });
+ checkForSchemaErrors(envGraph);
+ if (!Object.keys(envGraph.oauthClients).length) {
+ throw new CliExitError('No oauth clients are defined in your schema', {
+ suggestion: 'Define one with a root decorator, e.g. `# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)`',
+ });
+ }
+ return envGraph;
+}
+
+function requirePersistentStore(envGraph: Awaited>) {
+ const store = envGraph._cacheStore;
+ if (!store || store instanceof InMemoryCacheStore) {
+ throw new CliExitError('oauth login requires a persistent (disk) cache to store the refresh token', {
+ suggestion: 'Caching is currently disabled or memory-only. Remove --skip-cache / @cache=off|memory, and make sure local encryption is set up (see `varlock cache status`).',
+ });
+ }
+ return store;
+}
+
+function pickProvider(
+ envGraph: Awaited>,
+ requestedId: string | undefined,
+): OauthClientRecord {
+ const clients = envGraph.oauthClients;
+ const ids = Object.keys(clients);
+ if (requestedId) {
+ const record = clients[requestedId];
+ if (!record) {
+ throw new CliExitError(`Unknown oauth client "${requestedId}"`, {
+ suggestion: `Defined clients: ${ids.join(', ')}`,
+ });
+ }
+ return record;
+ }
+ if (ids.length === 1) return clients[ids[0]];
+ throw new CliExitError('Multiple oauth clients are defined - specify which one to log in to', {
+ suggestion: `e.g. \`varlock oauth login ${ids[0]}\` (defined clients: ${ids.join(', ')})`,
+ });
+}
+
+/** union of client-level scopes, provider-required scopes, and every login-provisioned item's scopes */
+async function collectLoginScopes(
+ envGraph: Awaited>,
+ record: OauthClientRecord,
+): Promise {
+ const delim = record.scopesDelimiter;
+ const scopeSet = new Set();
+ const addScopes = (val: unknown) => {
+ if (typeof val === 'string') {
+ val.split(delim).map((s) => s.trim()).filter(Boolean).forEach((s) => scopeSet.add(s));
+ } else if (Array.isArray(val)) {
+ val.forEach((s) => typeof s === 'string' && s && scopeSet.add(s));
+ }
+ };
+
+ addScopes(record.resolved?.scope);
+ record.requiredLoginScopes.forEach((s) => scopeSet.add(s));
+
+ for (const usage of record.usedBy) {
+ // items with their own refresh token (or a non-refresh grant) don't consume the login-provisioned token
+ if (usage.hasOwnRefreshToken || usage.grantType !== 'refresh_token' || !usage.scopesResolver) continue;
+ for (const depKey of usage.scopesResolver.deps) {
+ await envGraph.resolveItemWithDeps(depKey);
+ }
+ addScopes(await usage.scopesResolver.resolve());
+ }
+
+ return scopeSet.size ? [...scopeSet].join(delim) : undefined;
+}
+
+// --- `varlock oauth login` --------------------------------------------------
+
+const loginCommand = define({
+ name: 'login',
+ description: 'Run a browser login flow and store the resulting refresh token in the encrypted cache',
+ args: {
+ client: {
+ type: 'positional',
+ required: false,
+ description: 'The @oauthClient id to log in to, e.g. google or google/dev (optional when only one is defined)',
+ },
+ flow: {
+ type: 'string',
+ description: 'Login flow to use: "device" (enter a code) or "browser" (loopback redirect). Defaults to device when the provider supports it.',
+ },
+ scopes: {
+ type: 'string',
+ description: 'Override the scopes to request (defaults to the union of scopes used in your schema)',
+ },
+ path: PATH_ARG,
+ },
+ examples: `
+ varlock oauth login # log in (single client defined)
+ varlock oauth login google # log in to a specific client
+ varlock oauth login google --flow browser
+ varlock oauth login google --scopes "scope-a scope-b"
+`.trim(),
+ run: async (ctx) => {
+ await trackCommand('oauth login', { command: 'oauth login' });
+
+ const envGraph = await loadGraphWithProviders(ctx.values.path);
+ const store = requirePersistentStore(envGraph);
+ const record = pickProvider(envGraph, ctx.values.client);
+ if (!record.resolved) {
+ throw new CliExitError(`oauth client "${record.id}" failed to initialize - fix schema errors first`);
+ }
+
+ const scope = ctx.values.scopes ?? await collectLoginScopes(envGraph, record);
+
+ let flow = ctx.values.flow;
+ if (flow && flow !== 'device' && flow !== 'browser') {
+ throw new CliExitError('--flow must be "device" or "browser"');
+ }
+ flow ||= record.deviceAuthorizationUrl ? 'device' : 'browser';
+ if (flow === 'device' && !record.deviceAuthorizationUrl) {
+ throw new CliExitError(`Provider "${record.id}" has no device authorization endpoint`, {
+ suggestion: 'Use --flow browser, or set deviceAuthorizationUrl on the @oauthClient',
+ });
+ }
+ if (flow === 'browser' && !record.authorizationUrl) {
+ throw new CliExitError(`Provider "${record.id}" has no authorization endpoint configured`, {
+ suggestion: [
+ 'Set authorizationUrl on the @oauthClient (or use a provider that provides one)',
+ ...record.notes ? [`Note for this provider: ${record.notes}`] : [],
+ ].join('\n'),
+ });
+ }
+
+ // state intent up front so the terminal can be compared against the provider's consent screen
+ logLines([
+ `๐ Logging in to oauth client ${ansis.bold(record.id)}`,
+ '',
+ ` token endpoint: ${record.tokenUrl}`,
+ ` client id: ${record.resolved.clientId}`,
+ ` scopes: ${formatOauthScopesForDisplay(scope)}`,
+ '',
+ ]);
+
+ const loginConfig = {
+ tokenUrl: record.tokenUrl,
+ authorizationUrl: record.authorizationUrl,
+ deviceAuthorizationUrl: record.deviceAuthorizationUrl,
+ clientId: record.resolved.clientId,
+ clientSecret: record.resolved.clientSecret,
+ clientAuth: record.clientAuth,
+ scope,
+ extraAuthParams: record.extraAuthParams,
+ };
+
+ let loginResult;
+ try {
+ if (flow === 'device') {
+ loginResult = await runDeviceCodeLogin(loginConfig, {
+ onUserCode: async (info) => {
+ logLines([
+ `First please copy this code: ${ansis.bold.magenta(info.userCode)}`,
+ '',
+ `Then log in @ ${info.verificationUri}`,
+ ]);
+ if (process.stdin.isTTY) {
+ console.log('\nPress ENTER to open in your default browser...');
+ await keyPressed(['\r']);
+ openUrl(info.verificationUriComplete ?? info.verificationUri);
+ }
+ console.log(ansis.italic.gray('... waiting for you to complete login ...'));
+ },
+ });
+ } else {
+ loginResult = await runPkceLogin(loginConfig, {
+ onAuthorizationUrl: async (url) => {
+ logLines([
+ 'Complete the login in your browser:',
+ '',
+ ansis.cyan(url),
+ ]);
+ openUrl(url);
+ console.log(ansis.italic.gray('... waiting for you to complete login ...'));
+ },
+ });
+ }
+ } catch (err) {
+ if (err instanceof OauthLoginError) {
+ throw new CliExitError(`Login failed: ${err.message}`, err.tip ? { suggestion: err.tip } : undefined);
+ }
+ throw err;
+ }
+
+ const clientEntryCacheKey = buildOauthClientCacheKey({
+ tokenUrl: record.tokenUrl,
+ clientId: record.resolved.clientId,
+ });
+ const entry: OauthClientCacheEntry = {
+ refreshToken: loginResult.refreshToken,
+ grantedScope: loginResult.grantedScope ?? scope,
+ updatedAt: Date.now(),
+ source: 'login',
+ };
+ const stored = await store.set(clientEntryCacheKey, entry, TTL_FOREVER);
+ if (!stored) {
+ throw new CliExitError('Login succeeded but the refresh token could not be written to the cache', {
+ suggestion: 'Check `varlock cache status` - local encryption may not be set up',
+ });
+ }
+
+ logLines([
+ '',
+ `โ
Logged in to ${ansis.bold(record.id)} - refresh token stored in the encrypted cache`,
+ ...loginResult.grantedScope ? [ansis.gray(` granted scopes: ${formatOauthScopesForDisplay(loginResult.grantedScope)}`)] : [],
+ '',
+ `Items using ${ansis.cyan(`oauth(${record.id === '_default' ? '' : record.id}...)`)} without an explicit refreshToken will now resolve.`,
+ ]);
+ },
+});
+
+// --- `varlock oauth status` ---------------------------------------------------
+
+const statusCommand = define({
+ name: 'status',
+ description: 'Show defined oauth clients and whether a refresh token has been provisioned',
+ args: {
+ path: PATH_ARG,
+ },
+ run: async (ctx) => {
+ await trackCommand('oauth status', { command: 'oauth status' });
+ const envGraph = await loadGraphWithProviders(ctx.values.path);
+ const store = envGraph._cacheStore;
+
+ for (const record of Object.values(envGraph.oauthClients)) {
+ console.log(`${ansis.bold(record.id)}${record.providerName ? ansis.gray(` (provider: ${record.providerName})`) : ''}`);
+ console.log(ansis.gray(` token endpoint: ${record.tokenUrl}`));
+ const loginConsumers = record.usedBy.filter((u) => !u.hasOwnRefreshToken && u.grantType === 'refresh_token');
+ if (record.usedBy.length) {
+ console.log(ansis.gray(` used by: ${record.usedBy.map((u) => u.itemKey).join(', ')}`));
+ }
+
+ if (!record.resolved) {
+ console.log(ansis.red(' โ ๏ธ failed to initialize'));
+ } else if (store && !(store instanceof InMemoryCacheStore)) {
+ const clientEntryCacheKey = buildOauthClientCacheKey({
+ tokenUrl: record.tokenUrl,
+ clientId: record.resolved.clientId,
+ });
+ const cached = await store.get(clientEntryCacheKey);
+ const entry = cached?.value as OauthClientCacheEntry | undefined;
+ if (entry?.refreshToken) {
+ const sourceLabel = entry.source === 'login' ? 'via login' : 'rotated';
+ console.log(` โ
refresh token provisioned ${ansis.gray(`(${sourceLabel}, updated ${formatTimeAgo(entry.updatedAt)})`)}`);
+ if (entry.grantedScope) console.log(ansis.gray(` scopes: ${formatOauthScopesForDisplay(entry.grantedScope)}`));
+ } else if (loginConsumers.length) {
+ console.log(` โ no refresh token provisioned - run ${ansis.cyan(`varlock oauth login ${record.id === '_default' ? '' : record.id}`.trim())}`);
+ } else {
+ console.log(ansis.gray(' no login-provisioned token needed (items pass refreshToken explicitly)'));
+ }
+ } else {
+ console.log(ansis.gray(' cache is disabled or memory-only - login provisioning unavailable'));
+ }
+ console.log('');
+ }
+ },
+});
+
+// --- `varlock oauth` (parent) -------------------------------------------------
+
+export const commandSpec = define({
+ name: 'oauth',
+ description: 'Manage OAuth clients and login-provisioned refresh tokens',
+ subCommands: {
+ login: loginCommand,
+ status: statusCommand,
+ },
+ examples: `
+Provision and inspect refresh tokens for @oauthClient instances used by oauth().
+
+Examples:
+ varlock oauth status # show clients and provisioning state
+ varlock oauth login # run the login flow (single client defined)
+ varlock oauth login google # log in to a specific client
+`.trim(),
+});
+
+/** bare `varlock oauth` behaves like `varlock oauth status` */
+export const commandFn: TypedGunshiCommandFn = async (ctx) => {
+ await statusCommand.run!(ctx as any);
+};
diff --git a/packages/varlock/src/env-graph/index.ts b/packages/varlock/src/env-graph/index.ts
index c3eb6e878..ee8fa135d 100644
--- a/packages/varlock/src/env-graph/index.ts
+++ b/packages/varlock/src/env-graph/index.ts
@@ -5,6 +5,7 @@ export {
FileBasedDataSource, DotEnvFileDataSource, DirectoryDataSource, MultiplePathsContainerDataSource,
} from './lib/data-source';
export { Resolver, StaticValueResolver } from './lib/resolver';
+export { type OauthClientRecord } from './lib/decorators';
export { ConfigItem, type TypeGenItemInfo } from './lib/config-item';
export {
VarlockError,
diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts
index 674ea5a10..b20164f5d 100644
--- a/packages/varlock/src/env-graph/lib/decorators.ts
+++ b/packages/varlock/src/env-graph/lib/decorators.ts
@@ -15,6 +15,10 @@ import type { EnvGraph } from './env-graph';
import { parseKeyFilterArgs, applyKeyFilter, type KeyFilter } from './key-filter';
import { parseDuration } from '../../lib/duration';
import { PROXY_APPROVAL_EACH_VALUES, parseProxySubstitutionTarget } from '../../proxy/types';
+import {
+ assertValidTokenUrl, OAUTH_CLIENT_AUTH_METHODS, type OauthClientAuthMethod, type OauthGrantType,
+} from '../../lib/oauth';
+import { OAUTH_PROVIDERS, OAUTH_PROVIDER_NAMES } from '../../lib/oauth-providers';
export abstract class DecoratorInstance {
@@ -310,6 +314,44 @@ function parseEnvBulkValues(
}
// ~ Root decorators ----------------------------------------
+/**
+ * A registered `@oauthClient(...)` instance, stored on `EnvGraph.oauthClients`
+ * keyed by address: the provider name for a provider's default client
+ * (`google`), `provider/id` when an explicit id nests under a provider
+ * (`google/dev`), or the bare id for provider-less clients. Static config is
+ * captured at process time; dynamic args (client credentials, default scopes)
+ * are resolved once during the decorator's execute() and stored in `resolved`.
+ * The `oauth()` resolver and the `varlock oauth login` CLI both read from here.
+ */
+export type OauthClientRecord = {
+ /** full address (`google`, `google/dev`, or a bare id) */
+ id: string;
+ providerName?: string;
+ tokenUrl: string;
+ authorizationUrl?: string;
+ deviceAuthorizationUrl?: string;
+ clientAuth: OauthClientAuthMethod;
+ extraAuthParams: Record;
+ requiredLoginScopes: Array;
+ scopesDelimiter: string;
+ appSetupUrl?: string;
+ notes?: string;
+ clientIdResolver: Resolver;
+ clientSecretResolver?: Resolver;
+ scopesResolver?: Resolver;
+ /** the decorator's FunctionArgsResolver - its .deps drive item dependency wiring */
+ argsResolver: Resolver;
+ /** items referencing this provider, populated by the oauth() resolver's process() */
+ usedBy: Array<{
+ itemKey: string;
+ grantType: OauthGrantType;
+ scopesResolver?: Resolver;
+ hasOwnRefreshToken: boolean;
+ }>;
+ /** populated by execute() during finishLoad */
+ resolved?: { clientId: string; clientSecret?: string; scope?: string };
+};
+
export type RootDecoratorDef = {
name: string,
description?: string;
@@ -674,6 +716,142 @@ export const builtInRootDecorators: Array> = [
useFnArgsResolver: true,
process: (argsVal) => validateProxyFunctionArgs(argsVal),
},
+ {
+ name: 'oauthClient',
+ isFunction: true,
+ process(argsVal) {
+ const graph = argsVal.dataSource!.graph!;
+ if (argsVal.arrArgs?.length) {
+ throw new SchemaError('@oauthClient expects only key-value args, e.g. `@oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID)`');
+ }
+ const objArgs = argsVal.objArgs ?? {};
+
+ const knownArgs = [
+ 'id',
+ 'provider',
+ 'tokenUrl',
+ 'authorizationUrl',
+ 'deviceAuthorizationUrl',
+ 'clientAuth',
+ 'clientId',
+ 'clientSecret',
+ 'scopes',
+ ];
+ for (const argKey of Object.keys(objArgs)) {
+ if (!knownArgs.includes(argKey)) {
+ throw new SchemaError(`@oauthClient: unknown arg "${argKey}" (expected one of: ${knownArgs.join(', ')})`);
+ }
+ }
+
+ const getStaticString = (argKey: string): string | undefined => {
+ const r = objArgs[argKey];
+ if (!r) return undefined;
+ if (!r.isStatic || typeof r.staticValue !== 'string' || !r.staticValue) {
+ throw new SchemaError(`@oauthClient: ${argKey} must be a static string`);
+ }
+ return r.staticValue;
+ };
+
+ const providerName = getStaticString('provider');
+ let provider;
+ if (providerName) {
+ provider = OAUTH_PROVIDERS[providerName];
+ if (!provider) {
+ throw new SchemaError(`@oauthClient: unknown provider "${providerName}" (known providers: ${OAUTH_PROVIDER_NAMES.join(', ')})`);
+ }
+ }
+
+ // clients are addressed by provider name: `google` is the provider's
+ // default client, and an explicit id nests under it (`id=dev` โ `google/dev`).
+ // clients without a provider use their id alone.
+ const rawId = getStaticString('id');
+ if (rawId && !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(rawId)) {
+ throw new SchemaError('@oauthClient: id must start with a letter and contain only letters, numbers, dashes, underscores');
+ }
+ let id: string;
+ if (providerName) {
+ id = rawId ? `${providerName}/${rawId}` : providerName;
+ } else {
+ id = rawId ?? '_default';
+ }
+ if (graph.oauthClients[id]) {
+ throw new SchemaError(`@oauthClient: a client addressed "${id}" is already defined`, {
+ tip: 'Multiple clients for one provider need distinct ids, e.g. `id=dev` / `id=prod` (addressed as `google/dev` / `google/prod`)',
+ });
+ }
+
+ const tokenUrl = getStaticString('tokenUrl') ?? provider?.tokenUrl;
+ if (!tokenUrl) {
+ throw new SchemaError('@oauthClient: tokenUrl is required (or use a provider that provides one)');
+ }
+ const authorizationUrl = getStaticString('authorizationUrl') ?? provider?.authorizationUrl;
+ const deviceAuthorizationUrl = getStaticString('deviceAuthorizationUrl') ?? provider?.deviceAuthorizationUrl;
+ try {
+ assertValidTokenUrl(tokenUrl, 'tokenUrl');
+ if (authorizationUrl) assertValidTokenUrl(authorizationUrl, 'authorizationUrl');
+ if (deviceAuthorizationUrl) assertValidTokenUrl(deviceAuthorizationUrl, 'deviceAuthorizationUrl');
+ } catch (err) {
+ throw new SchemaError(`@oauthClient: ${err instanceof Error ? err.message : err}`);
+ }
+
+ const clientAuthArg = getStaticString('clientAuth');
+ if (clientAuthArg && !(OAUTH_CLIENT_AUTH_METHODS as ReadonlyArray).includes(clientAuthArg)) {
+ throw new SchemaError(`@oauthClient: clientAuth must be one of: ${OAUTH_CLIENT_AUTH_METHODS.join(', ')}`);
+ }
+ const clientAuth = (clientAuthArg ?? provider?.clientAuth ?? 'body') as OauthClientAuthMethod;
+
+ if (!objArgs.clientId) {
+ throw new SchemaError('@oauthClient: clientId is required');
+ }
+
+ const record: OauthClientRecord = {
+ id,
+ providerName,
+ tokenUrl,
+ authorizationUrl,
+ deviceAuthorizationUrl,
+ clientAuth,
+ extraAuthParams: provider?.extraAuthParams ?? {},
+ requiredLoginScopes: provider?.requiredLoginScopes ?? [],
+ scopesDelimiter: provider?.scopesDelimiter ?? ' ',
+ appSetupUrl: provider?.appSetupUrl,
+ notes: provider?.notes,
+ clientIdResolver: objArgs.clientId,
+ clientSecretResolver: objArgs.clientSecret,
+ scopesResolver: objArgs.scopes,
+ argsResolver: argsVal,
+ usedBy: [],
+ };
+ graph.oauthClients[id] = record;
+ return record;
+ },
+ async execute(record: OauthClientRecord) {
+ const clientId = await record.clientIdResolver.resolve();
+ if (typeof clientId !== 'string' || !clientId) {
+ throw new ResolutionError('@oauthClient: clientId resolved to an empty value');
+ }
+ let clientSecret: string | undefined;
+ if (record.clientSecretResolver) {
+ const resolved = await record.clientSecretResolver.resolve();
+ if (typeof resolved !== 'string' || !resolved) {
+ throw new ResolutionError('@oauthClient: clientSecret resolved to an empty value');
+ }
+ clientSecret = resolved;
+ }
+ let scope: string | undefined;
+ if (record.scopesResolver) {
+ const resolved = await record.scopesResolver.resolve();
+ if (typeof resolved === 'string') {
+ scope = resolved;
+ } else if (Array.isArray(resolved) && resolved.every((s) => typeof s === 'string')) {
+ scope = resolved.join(record.scopesDelimiter);
+ } else {
+ throw new ResolutionError('@oauthClient: scopes must resolve to a string or an array of strings');
+ }
+ }
+ record.resolved = { clientId, clientSecret, scope };
+ },
+ },
{
name: 'auditIgnorePaths',
isFunction: true,
diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts
index 592db7d67..038dc2414 100644
--- a/packages/varlock/src/env-graph/lib/env-graph.ts
+++ b/packages/varlock/src/env-graph/lib/env-graph.ts
@@ -22,6 +22,7 @@ import {
builtInItemDecorators, builtInRootDecorators,
RootDecoratorInstance,
type ItemDecoratorDef,
+ type OauthClientRecord,
type RootDecoratorDef,
} from './decorators';
import { getErrorLocation } from './error-location';
@@ -113,6 +114,9 @@ export class EnvGraph {
basePath?: string;
+ /** registered `@oauthClient(...)` instances, keyed by id */
+ oauthClients: Record = {};
+
// -- Cache --
/** @internal cache store instance, initialized during loading */
_cacheStore?: import('../../lib/cache/cache-store').CacheStoreLike;
diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts
index 8de38080e..333a57478 100644
--- a/packages/varlock/src/env-graph/lib/resolver.ts
+++ b/packages/varlock/src/env-graph/lib/resolver.ts
@@ -20,8 +20,15 @@ import {
type GeneratedTotp, type OtpAlgorithm, type OtpSecretEncoding,
} from '../../lib/otp';
import { assertValidCacheKey, hasInvalidCacheKeyChars, MAX_CACHE_KEY_LENGTH } from '../../lib/cache/cache-store';
+import {
+ assertValidTokenUrl, requestOauthToken, OauthTokenRequestError,
+ buildOauthItemCacheKey, buildOauthClientCacheKey,
+ OAUTH_GRANT_TYPES, OAUTH_CLIENT_AUTH_METHODS, OAUTH_RESERVED_PARAMS,
+ type OauthGrantType, type OauthClientAuthMethod, type OauthTokenResult,
+ type OauthItemCacheEntry, type OauthClientCacheEntry,
+} from '../../lib/oauth';
import type { EnvGraphDataSource } from './data-source';
-import { DecoratorInstance } from './decorators';
+import { DecoratorInstance, type OauthClientRecord } from './decorators';
import { getErrorLocation } from './error-location';
import { isBuiltinVar } from './builtin-vars';
@@ -223,6 +230,11 @@ export class Resolver {
}
}
+ /** key of the config item this resolver belongs to (undefined for decorator-attached resolvers) */
+ get parentItemKey(): string | undefined {
+ return this.parent instanceof ConfigItem ? this.parent.key : undefined;
+ }
+
// meant to be used by subclass _resolve methods
protected getDepValue(key: string) {
// NOTE - this should not be called if the dependency is invalid
@@ -1047,6 +1059,14 @@ export const CacheResolver: typeof Resolver = createResolver({
});
}
+ // oauth() manages its own cache keyed on the provider-reported token expiry;
+ // wrapping it would serve expired access tokens
+ if (childResolver?.fnName === 'oauth') {
+ throw new SchemaError('cannot cache oauth(), since it already caches tokens according to their expiry', {
+ tip: 'Cache the inputs instead, e.g. `oauth(refreshToken=cache(op("op://vault/item/refresh token")), ...)`',
+ });
+ }
+
// optional explicit cache key
const keyResolver = this.objArgs?.key;
let customKey: string | undefined;
@@ -1125,6 +1145,495 @@ export const CacheResolver: typeof Resolver = createResolver({
},
});
+// โโ OAuth โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+/** refresh this long before the provider-reported expiry */
+const OAUTH_DEFAULT_SKEW_MS = 60_000;
+/** assumed token lifetime when a provider omits expires_in from its response */
+const OAUTH_FALLBACK_EXPIRES_IN_MS = 10 * 60 * 1000;
+
+let warnedOauthRotationNotPersisted = false;
+
+export const OauthResolver: typeof Resolver = createResolver({
+ name: 'oauth',
+ description: 'Exchange a refresh token or client credentials for a fresh OAuth access token',
+ icon: 'mdi:key-chain-variant',
+ inferredType: 'string',
+ impliesSensitive: true,
+ argsSchema: {
+ type: 'mixed',
+ arrayMaxLength: 1,
+ },
+ process() {
+ // optional positional arg references an @oauthClient instance by id
+ // (`google`, or `google/dev` when multiple clients share a provider)
+ let client: OauthClientRecord | undefined;
+ const clientRefResolver = this.arrArgs?.[0];
+ if (clientRefResolver) {
+ if (!clientRefResolver.isStatic || typeof clientRefResolver.staticValue !== 'string') {
+ throw new SchemaError('client reference must be a static id, e.g. `oauth(google, ...)`');
+ }
+ const clientId = clientRefResolver.staticValue as string;
+ const knownIds = Object.keys(this.envGraph?.oauthClients ?? {});
+ client = this.envGraph?.oauthClients[clientId];
+ if (!client) {
+ throw new SchemaError(
+ `unknown oauth client "${clientId}"${knownIds.length ? ` (defined clients: ${knownIds.join(', ')})` : ''}`,
+ { tip: 'Define it with a root decorator, e.g. `# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID)`' },
+ );
+ }
+ // wire the client's arg dependencies ($REFS to other items) into this
+ // item's dep graph so ordering and cycle detection account for them
+ for (const depKey of client.argsResolver.deps) {
+ this.addDep(depKey);
+ }
+ }
+
+ let grantType: OauthGrantType = 'refresh_token';
+ const grantResolver = this.objArgs?.grant;
+ if (grantResolver) {
+ if (!grantResolver.isStatic || typeof grantResolver.staticValue !== 'string') {
+ throw new SchemaError('grant must be a static string');
+ }
+ if (!(OAUTH_GRANT_TYPES as ReadonlyArray).includes(grantResolver.staticValue)) {
+ throw new SchemaError(`grant must be one of: ${OAUTH_GRANT_TYPES.join(', ')}`);
+ }
+ grantType = grantResolver.staticValue as OauthGrantType;
+ }
+
+ // jwt_bearer signs an assertion with a private key instead of presenting
+ // a stored credential - key material comes from a Google-style service
+ // account key JSON, or a raw PEM key + issuer
+ const serviceAccountKeyResolver = this.objArgs?.serviceAccountKey;
+ const privateKeyResolver = this.objArgs?.privateKey;
+ const issuerResolver = this.objArgs?.issuer;
+ const subjectResolver = this.objArgs?.subject;
+ if (grantType === 'jwt_bearer') {
+ if (!serviceAccountKeyResolver && !privateKeyResolver) {
+ throw new SchemaError('jwt_bearer grant requires serviceAccountKey (Google-style key JSON) or privateKey + issuer');
+ }
+ if (serviceAccountKeyResolver && privateKeyResolver) {
+ throw new SchemaError('pass either serviceAccountKey or privateKey, not both');
+ }
+ if (privateKeyResolver && !issuerResolver) {
+ throw new SchemaError('issuer is required when using privateKey');
+ }
+ } else {
+ for (const [argKey, argResolver] of Object.entries({
+ serviceAccountKey: serviceAccountKeyResolver,
+ privateKey: privateKeyResolver,
+ issuer: issuerResolver,
+ subject: subjectResolver,
+ })) {
+ if (argResolver) throw new SchemaError(`${argKey} only applies to the jwt_bearer grant`);
+ }
+ }
+
+ let audience: string | undefined;
+ const audienceResolver = this.objArgs?.audience;
+ if (audienceResolver) {
+ if (grantType !== 'jwt_bearer') throw new SchemaError('audience only applies to the jwt_bearer grant');
+ if (!audienceResolver.isStatic || typeof audienceResolver.staticValue !== 'string') {
+ throw new SchemaError('audience must be a static string');
+ }
+ audience = audienceResolver.staticValue as string;
+ }
+
+ const tokenUrlResolver = this.objArgs?.tokenUrl;
+ if (tokenUrlResolver && (!tokenUrlResolver.isStatic || typeof tokenUrlResolver.staticValue !== 'string')) {
+ throw new SchemaError('tokenUrl must be a static string');
+ }
+ const tokenUrl = (tokenUrlResolver?.staticValue as string | undefined) ?? client?.tokenUrl;
+ // a service account key file carries its own token_uri, discovered at resolve time
+ if (!tokenUrl && !serviceAccountKeyResolver) {
+ throw new SchemaError('tokenUrl is required (or reference an @oauthClient instance that provides one)');
+ }
+ if (tokenUrl) {
+ try {
+ assertValidTokenUrl(tokenUrl);
+ } catch (err) {
+ throw new SchemaError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ let clientAuth: OauthClientAuthMethod = client?.clientAuth ?? 'body';
+ const clientAuthResolver = this.objArgs?.clientAuth;
+ if (clientAuthResolver) {
+ if (!clientAuthResolver.isStatic || typeof clientAuthResolver.staticValue !== 'string') {
+ throw new SchemaError('clientAuth must be a static string');
+ }
+ if (!(OAUTH_CLIENT_AUTH_METHODS as ReadonlyArray).includes(clientAuthResolver.staticValue)) {
+ throw new SchemaError(`clientAuth must be one of: ${OAUTH_CLIENT_AUTH_METHODS.join(', ')}`);
+ }
+ clientAuth = clientAuthResolver.staticValue as OauthClientAuthMethod;
+ }
+
+ // bare numbers are seconds (matching expires_in and generateOtp's period)
+ let skewMs = OAUTH_DEFAULT_SKEW_MS;
+ const skewResolver = this.objArgs?.skew;
+ if (skewResolver) {
+ if (!skewResolver.isStatic) throw new SchemaError('skew must be a static value');
+ const skewVal = skewResolver.staticValue;
+ if (typeof skewVal === 'number') {
+ skewMs = skewVal * 1000;
+ } else if (typeof skewVal === 'string') {
+ try {
+ skewMs = parseDuration(skewVal);
+ } catch (err) {
+ throw new SchemaError(err instanceof Error ? err.message : String(err));
+ }
+ } else {
+ throw new SchemaError('skew must be a number of seconds or a duration string like "90s"');
+ }
+ if (!Number.isFinite(skewMs) || skewMs < 0) {
+ throw new SchemaError('skew must be a non-negative duration');
+ }
+ }
+
+ const refreshTokenResolver = this.objArgs?.refreshToken;
+ if (grantType === 'refresh_token' && !refreshTokenResolver && !client) {
+ throw new SchemaError('refreshToken is required for the refresh_token grant', {
+ tip: 'Or reference an @oauthClient instance and provision a refresh token with `varlock oauth login`',
+ });
+ }
+ if (grantType !== 'refresh_token' && refreshTokenResolver) {
+ throw new SchemaError(`refreshToken does not apply to the ${grantType} grant`);
+ }
+
+ const clientIdResolver = this.objArgs?.clientId;
+ // jwt_bearer identifies via the signed assertion; client_id is optional there
+ if (!clientIdResolver && !client && grantType !== 'jwt_bearer') {
+ throw new SchemaError('clientId is required');
+ }
+ const clientSecretResolver = this.objArgs?.clientSecret;
+
+ const scopesResolver = this.objArgs?.scopes;
+
+ // register usage on the client record so `varlock oauth login` can
+ // compute the union of scopes needed at provisioning time
+ const itemKey = this.parentItemKey;
+ if (client && itemKey) {
+ client.usedBy.push({
+ itemKey,
+ grantType,
+ scopesResolver,
+ hasOwnRefreshToken: !!refreshTokenResolver,
+ });
+ }
+
+ const paramsResolver = this.objArgs?.params;
+ if (paramsResolver) {
+ if (!(paramsResolver instanceof ObjectLiteralResolver)) {
+ throw new SchemaError('params must be an object literal, e.g. `params={ audience="..." }`');
+ }
+ for (const paramKey of Object.keys(paramsResolver.objArgs ?? {})) {
+ if (OAUTH_RESERVED_PARAMS.includes(paramKey)) {
+ throw new SchemaError(`params may not override reserved param "${paramKey}"`);
+ }
+ }
+ }
+
+ const knownArgs = [
+ 'tokenUrl',
+ 'grant',
+ 'clientAuth',
+ 'skew',
+ 'refreshToken',
+ 'clientId',
+ 'clientSecret',
+ 'scopes',
+ 'params',
+ 'serviceAccountKey',
+ 'privateKey',
+ 'issuer',
+ 'subject',
+ 'audience',
+ ];
+ for (const argKey of Object.keys(this.objArgs ?? {})) {
+ if (!knownArgs.includes(argKey)) {
+ throw new SchemaError(`unknown arg "${argKey}" (expected one of: ${knownArgs.join(', ')})`);
+ }
+ }
+
+ return {
+ client,
+ tokenUrl,
+ grantType,
+ clientAuth,
+ skewMs,
+ audience,
+ refreshTokenResolver,
+ clientIdResolver,
+ clientSecretResolver,
+ scopesResolver,
+ paramsResolver,
+ serviceAccountKeyResolver,
+ privateKeyResolver,
+ issuerResolver,
+ subjectResolver,
+ };
+ },
+ async resolve(state) {
+ const { getResolutionContext } = await import('./resolution-context');
+ const ctx = getResolutionContext();
+ const cacheStore = ctx?.cacheStore;
+ const { client } = state;
+
+ // client dynamic args (client credentials) are resolved once during
+ // the decorator's execute() at load time
+ if (client && !client.resolved) {
+ throw new ResolutionError(`@oauthClient "${client.id}" failed to initialize`);
+ }
+
+ const resolveRequiredString = async (resolver: Resolver, argName: string): Promise => {
+ const resolved = await resolver.resolve();
+ if (typeof resolved !== 'string' || !resolved) {
+ throw new ResolutionError(`${argName} resolved to an empty value`);
+ }
+ return resolved;
+ };
+
+ // jwt_bearer key material - from a service account key file or raw PEM + issuer
+ let jwtKeyMaterial: import('../../lib/oauth-jwt').JwtBearerKeyMaterial | undefined;
+ if (state.grantType === 'jwt_bearer') {
+ const { parseServiceAccountKey } = await import('../../lib/oauth-jwt');
+ if (state.serviceAccountKeyResolver) {
+ const keyJson = await resolveRequiredString(state.serviceAccountKeyResolver, 'serviceAccountKey');
+ try {
+ jwtKeyMaterial = parseServiceAccountKey(keyJson);
+ } catch (err) {
+ throw new ResolutionError(err instanceof Error ? err.message : String(err));
+ }
+ } else {
+ jwtKeyMaterial = {
+ issuer: await resolveRequiredString(state.issuerResolver!, 'issuer'),
+ privateKeyPem: await resolveRequiredString(state.privateKeyResolver!, 'privateKey'),
+ };
+ }
+ if (state.subjectResolver) {
+ jwtKeyMaterial.subject = await resolveRequiredString(state.subjectResolver, 'subject');
+ }
+ }
+
+ const tokenUrl = state.tokenUrl ?? jwtKeyMaterial?.tokenUrl;
+ if (!tokenUrl) {
+ throw new ResolutionError('the service account key has no token_uri - set tokenUrl explicitly');
+ }
+ if (!state.tokenUrl) {
+ // statically-declared tokenUrls were validated at schema load
+ try {
+ assertValidTokenUrl(tokenUrl);
+ } catch (err) {
+ throw new ResolutionError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ let clientId: string | undefined;
+ if (state.clientIdResolver) {
+ clientId = await resolveRequiredString(state.clientIdResolver, 'clientId');
+ } else {
+ clientId = client?.resolved?.clientId;
+ }
+ let clientSecret = client?.resolved?.clientSecret;
+ if (state.clientSecretResolver) {
+ const resolved = await state.clientSecretResolver.resolve();
+ if (typeof resolved !== 'string' || !resolved) {
+ throw new ResolutionError('clientSecret resolved to an empty value');
+ }
+ clientSecret = resolved;
+ }
+ let configuredRefreshToken: string | undefined;
+ if (state.refreshTokenResolver) {
+ const resolved = await state.refreshTokenResolver.resolve();
+ if (typeof resolved !== 'string' || !resolved) {
+ throw new ResolutionError('refreshToken resolved to an empty value');
+ }
+ configuredRefreshToken = resolved;
+ }
+ let scope = client?.resolved?.scope;
+ if (state.scopesResolver) {
+ const resolved = await state.scopesResolver.resolve();
+ if (typeof resolved === 'string') {
+ scope = resolved;
+ } else if (Array.isArray(resolved) && resolved.every((s) => typeof s === 'string')) {
+ // OAuth wire format is a single delimiter-joined string (space for most providers)
+ scope = resolved.join(client?.scopesDelimiter ?? ' ');
+ } else {
+ throw new ResolutionError('scopes must resolve to a string or an array of strings');
+ }
+ }
+ let extraParams: Record | undefined;
+ if (state.paramsResolver) {
+ const resolved = await state.paramsResolver.resolve();
+ extraParams = {};
+ for (const [paramKey, paramVal] of Object.entries(resolved ?? {})) {
+ if (paramVal === undefined || paramVal === null) continue;
+ if (typeof paramVal === 'object') {
+ throw new ResolutionError(`params.${paramKey} must resolve to a primitive value`);
+ }
+ extraParams[paramKey] = String(paramVal);
+ }
+ }
+
+ // keyed on the *configured* credentials - a rotated refresh token stored in
+ // the entry maps back to the same key, a re-provisioned bootstrap gets a new one
+ const itemCacheKey = buildOauthItemCacheKey({
+ tokenUrl,
+ grantType: state.grantType,
+ clientId: clientId ?? jwtKeyMaterial?.issuer ?? '',
+ scope,
+ refreshToken: configuredRefreshToken,
+ subject: jwtKeyMaterial?.subject,
+ });
+
+ // no item-level refresh token + refresh_token grant means the refresh token
+ // was provisioned via `varlock oauth login` and lives in a client-level
+ // cache entry shared by every item using this client
+ const usesLoginToken = state.grantType === 'refresh_token' && !configuredRefreshToken;
+ const clientEntryCacheKey = usesLoginToken && clientId
+ ? buildOauthClientCacheKey({ tokenUrl, clientId })
+ : undefined;
+ const loginTip = `Run \`varlock oauth login${client && client.id !== '_default' ? ` ${client.id}` : ''}\` to provision a refresh token`;
+ if (usesLoginToken && !cacheStore) {
+ throw new ResolutionError('a login-provisioned refresh token requires a persistent cache, but caching is disabled', {
+ tip: 'Enable caching (remove --skip-cache / @cache=off), or pass refreshToken explicitly from a vault',
+ });
+ }
+
+ const entryIsFresh = (entry: OauthItemCacheEntry | undefined): entry is OauthItemCacheEntry => (
+ !!entry?.accessToken && Date.now() < entry.expiresAt - state.skewMs
+ );
+
+ // fast path - fresh cached token, no lock needed
+ if (cacheStore && !ctx?.skipCache) {
+ const cached = await cacheStore.get(itemCacheKey);
+ const entry = cached?.value as OauthItemCacheEntry | undefined;
+ if (entryIsFresh(entry)) {
+ ctx?.cacheHits.push({ cacheKey: itemCacheKey, cachedAt: entry.lastRefreshedAt, expiresAt: entry.expiresAt });
+ return entry.accessToken;
+ }
+ }
+
+ const doRefresh = async (): Promise => {
+ // re-read inside the lock - a parallel process may have just refreshed;
+ // also needed for the rotated refresh token even when skipCache is set
+ const cached = cacheStore ? await cacheStore.get(itemCacheKey) : undefined;
+ const entry = cached?.value as OauthItemCacheEntry | undefined;
+ if (!ctx?.skipCache && entryIsFresh(entry)) {
+ ctx?.cacheHits.push({ cacheKey: itemCacheKey, cachedAt: entry.lastRefreshedAt, expiresAt: entry.expiresAt });
+ return entry.accessToken;
+ }
+
+ // pick the refresh token: login-provisioned tokens live in the shared
+ // client entry, item-configured ones rotate within the item entry
+ let clientEntry: OauthClientCacheEntry | undefined;
+ let refreshToken = entry?.refreshToken || configuredRefreshToken;
+ if (usesLoginToken) {
+ clientEntry = (await cacheStore!.get(clientEntryCacheKey!))?.value as OauthClientCacheEntry | undefined;
+ if (!clientEntry?.refreshToken) {
+ throw new ResolutionError('no refresh token has been provisioned for this oauth client', { tip: loginTip });
+ }
+ refreshToken = clientEntry.refreshToken;
+ }
+
+ // jwt_bearer signs a fresh short-lived assertion per exchange
+ let assertion: string | undefined;
+ if (jwtKeyMaterial) {
+ const { buildJwtBearerAssertion } = await import('../../lib/oauth-jwt');
+ try {
+ assertion = buildJwtBearerAssertion({
+ keyMaterial: jwtKeyMaterial,
+ audience: state.audience ?? tokenUrl,
+ scope,
+ });
+ } catch (err) {
+ throw new ResolutionError(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ let result: OauthTokenResult;
+ try {
+ result = await requestOauthToken({
+ tokenUrl,
+ grantType: state.grantType,
+ clientId,
+ clientSecret,
+ clientAuth: state.clientAuth,
+ refreshToken,
+ assertion,
+ scope,
+ extraParams,
+ });
+ } catch (err) {
+ if (err instanceof OauthTokenRequestError) {
+ const tip: Array = [];
+ if (err.details.oauthErrorCode === 'invalid_grant') {
+ if (state.grantType === 'jwt_bearer') {
+ tip.push('The signed assertion was rejected - check that the key is still valid, the issuer/subject are authorized, and your clock is in sync');
+ } else if (state.grantType === 'refresh_token') {
+ tip.push('The refresh token is likely expired or revoked');
+ if (usesLoginToken) {
+ tip.push(loginTip);
+ } else {
+ tip.push('Re-provision it from the provider');
+ if (entry?.refreshToken) {
+ tip.push('A previously rotated refresh token from the varlock cache was used - clearing the cache will retry with the configured one');
+ }
+ }
+ }
+ }
+ throw new ResolutionError(err.message, tip.length ? { tip } : undefined);
+ }
+ throw err;
+ }
+
+ const refreshedAt = Date.now();
+ const newEntry: OauthItemCacheEntry = {
+ accessToken: result.accessToken,
+ expiresAt: refreshedAt + (
+ result.expiresInSeconds !== undefined ? result.expiresInSeconds * 1000 : OAUTH_FALLBACK_EXPIRES_IN_MS
+ ),
+ // rotated tokens are stored in the item entry only when the refresh
+ // token is item-configured; login-provisioned rotation goes to the
+ // shared client entry below
+ refreshToken: usesLoginToken ? undefined : (result.refreshToken ?? entry?.refreshToken),
+ scope: result.scope ?? scope,
+ lastRefreshedAt: refreshedAt,
+ refreshCount: (entry?.refreshCount ?? 0) + 1,
+ };
+ // 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);
+ if (usesLoginToken && result.refreshToken) {
+ const updatedClientEntry: OauthClientCacheEntry = {
+ refreshToken: result.refreshToken,
+ grantedScope: clientEntry?.grantedScope,
+ updatedAt: refreshedAt,
+ source: 'rotation',
+ };
+ await cacheStore.set(clientEntryCacheKey!, updatedClientEntry, TTL_FOREVER);
+ }
+ } else if (
+ result.refreshToken && result.refreshToken !== configuredRefreshToken && !warnedOauthRotationNotPersisted
+ ) {
+ warnedOauthRotationNotPersisted = true;
+ // eslint-disable-next-line no-console
+ console.error('oauth(): provider rotated the refresh token but caching is disabled - the rotated token cannot be persisted, and the configured refresh token may stop working');
+ }
+ return result.accessToken;
+ };
+
+ // serialize refreshes across processes when the store supports locking, so
+ // parallel invocations share one token exchange (rotation makes this matter).
+ // login-provisioned refreshes lock on the shared client key since they
+ // read and rotate the client-level refresh token.
+ const lockKey = clientEntryCacheKey ?? itemCacheKey;
+ if (cacheStore?.withKeyLock) return await cacheStore.withKeyLock(lockKey, doRefresh);
+ return await doRefresh();
+ },
+});
+
// Special function for `@defaultSensitive=inferFromPrefix(PUBLIC_)`
// we may want to formalize this pattern of a resolver function used in a root decorator
// but resolved within the context of a specific item
@@ -1164,6 +1673,7 @@ export const BaseResolvers: Array = [
RandomStringResolver,
GenerateOtpResolver,
CacheResolver,
+ OauthResolver,
RemapResolver,
IfsResolver,
ForEnvResolver,
diff --git a/packages/varlock/src/env-graph/test/oauth-resolver.test.ts b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts
new file mode 100644
index 000000000..7b7f09877
--- /dev/null
+++ b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts
@@ -0,0 +1,540 @@
+/**
+ * Tests for the oauth() resolver function.
+ * The token endpoint client itself is covered by src/lib/test/oauth.test.ts.
+ */
+
+import http from 'node:http';
+import { generateKeyPairSync } from 'node:crypto';
+import {
+ describe, it, expect, beforeEach, afterEach,
+} from 'vitest';
+import { outdent } from 'outdent';
+import { DotEnvFileDataSource, EnvGraph } from '../index';
+import { InMemoryCacheStore } from '../../lib/cache';
+import type { CacheStoreLike } from '../../lib/cache/cache-store';
+import { buildOauthClientCacheKey, type OauthClientCacheEntry } from '../../lib/oauth';
+import { TTL_FOREVER } from '../../lib/cache/ttl-parser';
+
+/** Minimal token endpoint that issues sequential tokens and records requests */
+class MockTokenEndpoint {
+ requests: Array = [];
+ /** overridable response factory - defaults to sequential tokens (index is 0-based) */
+ respond: (index: number) => { status: number; body: any } = (index) => ({
+ status: 200,
+ body: { access_token: `at-${index}`, expires_in: 3600 },
+ });
+
+ private server?: http.Server;
+ url = '';
+
+ async start() {
+ this.server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (chunk) => {
+ raw += chunk;
+ });
+ req.on('end', () => {
+ this.requests.push(new URLSearchParams(raw));
+ const { status, body } = this.respond(this.requests.length - 1);
+ res.writeHead(status, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(body));
+ });
+ });
+ await new Promise((resolve) => {
+ this.server!.listen(0, '127.0.0.1', resolve);
+ });
+ const address = this.server!.address() as import('node:net').AddressInfo;
+ this.url = `http://127.0.0.1:${address.port}/token`;
+ }
+
+ async stop() {
+ await new Promise((resolve) => {
+ if (this.server) this.server.close(() => resolve());
+ else resolve();
+ });
+ }
+}
+
+async function loadAndResolveWithHeader(headerContent: string, envContent: string, cacheStore?: CacheStoreLike) {
+ const g = new EnvGraph();
+ const source = new DotEnvFileDataSource('.env.schema', {
+ overrideContents: outdent`
+ # @defaultRequired=false
+ ${headerContent}
+ # ---
+ ${envContent}
+ `,
+ });
+ await g.setRootDataSource(source);
+ if (cacheStore) g._cacheStore = cacheStore;
+ await g.finishLoad();
+ await g.resolveEnvValues();
+ return g;
+}
+
+async function loadAndResolve(envContent: string, cacheStore?: CacheStoreLike) {
+ return loadAndResolveWithHeader('', envContent, cacheStore);
+}
+
+describe('oauth()', () => {
+ let endpoint: MockTokenEndpoint;
+ beforeEach(async () => {
+ endpoint = new MockTokenEndpoint();
+ await endpoint.start();
+ });
+ afterEach(async () => {
+ await endpoint.stop();
+ });
+
+ function refreshGrantSchema(extraArgs = '') {
+ return outdent`
+ # @internal @sensitive
+ REFRESH_TOKEN=rt-bootstrap
+ TOKEN=oauth(tokenUrl="${endpoint.url}", refreshToken=$REFRESH_TOKEN, clientId="client-1", clientSecret="secret-1"${extraArgs})
+ `;
+ }
+
+ describe('resolution', () => {
+ it('exchanges a refresh token for an access token', async () => {
+ const g = await loadAndResolve(refreshGrantSchema());
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ expect(g.configSchema.TOKEN.resolvedValue).toBe('at-0');
+
+ const req = endpoint.requests[0];
+ expect(req.get('grant_type')).toBe('refresh_token');
+ expect(req.get('refresh_token')).toBe('rt-bootstrap');
+ expect(req.get('client_id')).toBe('client-1');
+ expect(req.get('client_secret')).toBe('secret-1');
+ });
+
+ it('is implicitly sensitive', async () => {
+ const g = await loadAndResolve(refreshGrantSchema());
+ expect(g.configSchema.TOKEN.isSensitive).toBe(true);
+ });
+
+ it('supports the client_credentials grant with array scopes', async () => {
+ const g = await loadAndResolve(outdent`
+ TOKEN=oauth(tokenUrl="${endpoint.url}", grant="client_credentials", clientId="c", clientSecret="s", scopes=["read", "write"])
+ `);
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ expect(g.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ expect(endpoint.requests[0].get('grant_type')).toBe('client_credentials');
+ expect(endpoint.requests[0].get('scope')).toBe('read write');
+ });
+
+ it('passes extra params through', async () => {
+ const g = await loadAndResolve(outdent`
+ TOKEN=oauth(tokenUrl="${endpoint.url}", grant="client_credentials", clientId="c", clientSecret="s", params={ audience="https://api.example.com" })
+ `);
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ expect(endpoint.requests[0].get('audience')).toBe('https://api.example.com');
+ });
+
+ it('surfaces provider errors as resolution errors with a tip on invalid_grant', async () => {
+ endpoint.respond = () => ({
+ status: 400 as const,
+ body: { error: 'invalid_grant', error_description: 'revoked' },
+ });
+ const g = await loadAndResolve(refreshGrantSchema());
+ expect(g.configSchema.TOKEN.resolutionError?.message).toContain('invalid_grant');
+ const tip = g.configSchema.TOKEN.resolutionError?.more?.tip;
+ expect(String(tip).toLowerCase()).toContain('re-provision');
+ });
+ });
+
+ describe('caching', () => {
+ it('reuses a cached token across resolutions until expiry', async () => {
+ const store = new InMemoryCacheStore();
+ const g1 = await loadAndResolve(refreshGrantSchema(), store);
+ expect(g1.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ expect(endpoint.requests.length).toBe(1);
+
+ const g2 = await loadAndResolve(refreshGrantSchema(), store);
+ expect(g2.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ expect(endpoint.requests.length).toBe(1); // no second exchange
+ expect(g2.configSchema.TOKEN._cacheHits?.length).toBe(1);
+ });
+
+ it('refreshes when the cached token is within the skew window', async () => {
+ // provider-reported lifetime shorter than the skew โ always considered stale
+ endpoint.respond = (index) => ({
+ status: 200,
+ body: { access_token: `at-${index}`, expires_in: 30 },
+ });
+ const store = new InMemoryCacheStore();
+ const g1 = await loadAndResolve(refreshGrantSchema(), store);
+ expect(g1.configSchema.TOKEN.resolvedValue).toBe('at-0');
+
+ const g2 = await loadAndResolve(refreshGrantSchema(), store);
+ expect(g2.configSchema.TOKEN.resolvedValue).toBe('at-1');
+ expect(endpoint.requests.length).toBe(2);
+ });
+
+ it('uses the rotated refresh token on subsequent refreshes', async () => {
+ endpoint.respond = (index) => ({
+ status: 200,
+ body: {
+ access_token: `at-${index}`,
+ refresh_token: `rt-rotated-${index}`,
+ expires_in: 30, // always stale, forcing a refresh each resolution
+ },
+ });
+ const store = new InMemoryCacheStore();
+ await loadAndResolve(refreshGrantSchema(), store);
+ expect(endpoint.requests[0].get('refresh_token')).toBe('rt-bootstrap');
+
+ await loadAndResolve(refreshGrantSchema(), store);
+ expect(endpoint.requests[1].get('refresh_token')).toBe('rt-rotated-0');
+ });
+
+ it('works without any cache store (refreshes every resolution)', async () => {
+ const g1 = await loadAndResolve(refreshGrantSchema());
+ const g2 = await loadAndResolve(refreshGrantSchema());
+ expect(g1.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ expect(g2.configSchema.TOKEN.resolvedValue).toBe('at-1');
+ });
+
+ it('scopes cache entries to the configured credentials', async () => {
+ const store = new InMemoryCacheStore();
+ await loadAndResolve(refreshGrantSchema(), store);
+ // different refresh token โ different cache entry โ new exchange
+ await loadAndResolve(outdent`
+ # @internal @sensitive
+ REFRESH_TOKEN=rt-other
+ TOKEN=oauth(tokenUrl="${endpoint.url}", refreshToken=$REFRESH_TOKEN, clientId="client-1", clientSecret="secret-1")
+ `, store);
+ expect(endpoint.requests.length).toBe(2);
+ });
+ });
+
+ describe('@oauthClient instances', () => {
+ function providerHeader(extraArgs = '') {
+ return `# @oauthClient(id=test, tokenUrl="${endpoint.url}", clientId=$CLIENT_ID, clientSecret=$CLIENT_SECRET${extraArgs})`;
+ }
+ const clientItems = outdent`
+ # @internal
+ CLIENT_ID=client-1
+ # @internal @sensitive
+ CLIENT_SECRET=secret-1
+ `;
+
+ function seedProviderEntry(store: CacheStoreLike, refreshToken: string) {
+ const key = buildOauthClientCacheKey({ tokenUrl: endpoint.url, clientId: 'client-1' });
+ const entry: OauthClientCacheEntry = {
+ refreshToken, grantedScope: 'read write', updatedAt: Date.now(), source: 'login',
+ };
+ return store.set(key, entry, TTL_FOREVER).then(() => key);
+ }
+
+ it('supplies client config from the provider, with explicit refreshToken', async () => {
+ const g = await loadAndResolveWithHeader(providerHeader(), outdent`
+ ${clientItems}
+ # @internal @sensitive
+ RT=rt-bootstrap
+ TOKEN=oauth(test, refreshToken=$RT, scopes="read")
+ `);
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ expect(g.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ const req = endpoint.requests[0];
+ expect(req.get('client_id')).toBe('client-1');
+ expect(req.get('client_secret')).toBe('secret-1');
+ expect(req.get('refresh_token')).toBe('rt-bootstrap');
+ expect(req.get('scope')).toBe('read');
+ });
+
+ it('uses a login-provisioned refresh token from the provider cache entry', async () => {
+ const store = new InMemoryCacheStore();
+ await seedProviderEntry(store, 'rt-from-login');
+ const g = await loadAndResolveWithHeader(providerHeader(), outdent`
+ ${clientItems}
+ TOKEN=oauth(test, scopes="read")
+ `, store);
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ expect(g.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ expect(endpoint.requests[0].get('refresh_token')).toBe('rt-from-login');
+ });
+
+ it('items with different scopes share the provider refresh token but cache tokens separately', async () => {
+ const store = new InMemoryCacheStore();
+ await seedProviderEntry(store, 'rt-from-login');
+ const g = await loadAndResolveWithHeader(providerHeader(), outdent`
+ ${clientItems}
+ TOKEN_A=oauth(test, scopes="read")
+ TOKEN_B=oauth(test, scopes="write")
+ `, store);
+ expect(g.configSchema.TOKEN_A.errors).toEqual([]);
+ expect(g.configSchema.TOKEN_B.errors).toEqual([]);
+ // two separate exchanges (different scopes), both using the shared token
+ expect(endpoint.requests.length).toBe(2);
+ expect(endpoint.requests[0].get('refresh_token')).toBe('rt-from-login');
+ expect(endpoint.requests[1].get('refresh_token')).toBe('rt-from-login');
+ expect(g.configSchema.TOKEN_A.resolvedValue).not.toBe(g.configSchema.TOKEN_B.resolvedValue);
+ });
+
+ it('stores rotated refresh tokens back into the shared provider entry', async () => {
+ endpoint.respond = (index) => ({
+ status: 200,
+ body: {
+ access_token: `at-${index}`,
+ refresh_token: `rt-rotated-${index}`,
+ expires_in: 30, // always stale, forcing a refresh each resolution
+ },
+ });
+ const store = new InMemoryCacheStore();
+ const providerKey = await seedProviderEntry(store, 'rt-from-login');
+
+ await loadAndResolveWithHeader(providerHeader(), outdent`
+ ${clientItems}
+ TOKEN=oauth(test, scopes="read")
+ `, store);
+ expect(endpoint.requests[0].get('refresh_token')).toBe('rt-from-login');
+
+ const updated = (await store.get(providerKey))?.value as OauthClientCacheEntry;
+ expect(updated.refreshToken).toBe('rt-rotated-0');
+ expect(updated.source).toBe('rotation');
+
+ // next resolution uses the rotated token
+ await loadAndResolveWithHeader(providerHeader(), outdent`
+ ${clientItems}
+ TOKEN=oauth(test, scopes="read")
+ `, store);
+ expect(endpoint.requests[1].get('refresh_token')).toBe('rt-rotated-0');
+ });
+
+ it('fails with a login tip when no refresh token has been provisioned', async () => {
+ const store = new InMemoryCacheStore();
+ const g = await loadAndResolveWithHeader(providerHeader(), outdent`
+ ${clientItems}
+ TOKEN=oauth(test)
+ `, store);
+ expect(g.configSchema.TOKEN.resolutionError?.message).toContain('no refresh token has been provisioned');
+ const tip = g.configSchema.TOKEN.resolutionError?.more?.tip;
+ expect(String(tip)).toContain('varlock oauth login');
+ });
+
+ it('registers item usage on the provider record', async () => {
+ const store = new InMemoryCacheStore();
+ await seedProviderEntry(store, 'rt-from-login');
+ const g = await loadAndResolveWithHeader(providerHeader(), outdent`
+ ${clientItems}
+ # @internal @sensitive
+ RT=rt-own
+ TOKEN_A=oauth(test, scopes="read")
+ TOKEN_B=oauth(test, refreshToken=$RT)
+ `, store);
+ const record = g.oauthClients.test;
+ expect(record.usedBy.map((u) => u.itemKey).sort()).toEqual(['TOKEN_A', 'TOKEN_B']);
+ expect(record.usedBy.find((u) => u.itemKey === 'TOKEN_A')?.hasOwnRefreshToken).toBe(false);
+ expect(record.usedBy.find((u) => u.itemKey === 'TOKEN_B')?.hasOwnRefreshToken).toBe(true);
+ });
+
+ it('applies provider endpoints and clientAuth, with explicit args overriding', async () => {
+ const g = await loadAndResolveWithHeader(
+ // tokenUrl overrides the provider def so resolution hits the mock endpoint
+ `# @oauthClient(id=goog, provider=google, tokenUrl="${endpoint.url}", clientId=$CLIENT_ID, clientSecret=$CLIENT_SECRET)`,
+ outdent`
+ ${clientItems}
+ # @internal @sensitive
+ RT=rt-1
+ TOKEN=oauth(google/goog, refreshToken=$RT)
+ `,
+ );
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ const record = g.oauthClients['google/goog'];
+ expect(record.tokenUrl).toBe(endpoint.url);
+ expect(record.authorizationUrl).toBe('https://accounts.google.com/o/oauth2/v2/auth');
+ expect(record.deviceAuthorizationUrl).toBe('https://oauth2.googleapis.com/device/code');
+ expect(record.extraAuthParams.access_type).toBe('offline');
+ });
+
+ it('defaults the address to the provider name when no id is given', async () => {
+ const g = await loadAndResolveWithHeader(
+ `# @oauthClient(provider=google, tokenUrl="${endpoint.url}", clientId=$CLIENT_ID, clientSecret=$CLIENT_SECRET)`,
+ outdent`
+ ${clientItems}
+ # @internal @sensitive
+ RT=rt-1
+ TOKEN=oauth(google, refreshToken=$RT)
+ `,
+ );
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ expect(g.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ expect(Object.keys(g.oauthClients)).toEqual(['google']);
+ });
+
+ it('rejects two default clients for the same provider, suggesting explicit ids', async () => {
+ const g = await loadAndResolveWithHeader(outdent`
+ # @oauthClient(provider=google, clientId="c1")
+ # @oauthClient(provider=google, clientId="c2")
+ `, 'A=1');
+ const dupError = g.rootDataSource!.schemaErrors.find((e) => e.message.includes('already defined'));
+ expect(dupError).toBeTruthy();
+ expect(String(dupError?.more?.tip)).toContain('google/dev');
+ });
+
+ it('rejects unknown client ids, listing defined ones', async () => {
+ const g = await loadAndResolveWithHeader(providerHeader(), outdent`
+ ${clientItems}
+ TOKEN=oauth(nope)
+ `);
+ expect(g.configSchema.TOKEN.errors[0]?.message).toMatch(/unknown oauth client "nope".*test/);
+ });
+
+ it('rejects duplicate client ids and unknown providers/args', async () => {
+ const dupG = await loadAndResolveWithHeader(outdent`
+ # @oauthClient(id=test, tokenUrl="${endpoint.url}", clientId="c")
+ # @oauthClient(id=test, tokenUrl="${endpoint.url}", clientId="c")
+ `, 'A=1');
+ const rootErrors = dupG.rootDataSource!.schemaErrors;
+ expect(rootErrors.some((e) => e.message.includes('already defined'))).toBe(true);
+
+ const presetG = await loadAndResolveWithHeader(
+ '# @oauthClient(id=x, provider=bogus, clientId="c")',
+ 'A=1',
+ );
+ expect(presetG.rootDataSource!.schemaErrors.some((e) => e.message.includes('unknown provider'))).toBe(true);
+
+ const argG = await loadAndResolveWithHeader(
+ `# @oauthClient(id=x, tokenUrl="${endpoint.url}", clientId="c", bogus=1)`,
+ 'A=1',
+ );
+ expect(argG.rootDataSource!.schemaErrors.some((e) => e.message.includes('unknown arg "bogus"'))).toBe(true);
+ });
+ });
+
+ describe('jwt_bearer grant', () => {
+ const PRIVATE_KEY_PEM = generateKeyPairSync('rsa', { modulusLength: 2048 })
+ .privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
+
+ function decodeAssertionClaims(assertion: string) {
+ return JSON.parse(Buffer.from(assertion.split('.')[1], 'base64url').toString());
+ }
+
+ function serviceAccountKeyItem(tokenUri: string) {
+ // single-quoted values are literal, so the JSON's own \n escapes survive
+ // for JSON.parse to expand
+ const keyJson = JSON.stringify({
+ client_email: 'sa@proj.iam.gserviceaccount.com',
+ private_key: PRIVATE_KEY_PEM,
+ token_uri: tokenUri,
+ });
+ return outdent`
+ # @internal @sensitive
+ SA_KEY='${keyJson}'
+ `;
+ }
+
+ it('signs an assertion from a service account key, using its token_uri', async () => {
+ const g = await loadAndResolve(outdent`
+ ${serviceAccountKeyItem(endpoint.url)}
+ TOKEN=oauth(grant="jwt_bearer", serviceAccountKey=$SA_KEY, scopes="cloud.readonly")
+ `);
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ expect(g.configSchema.TOKEN.resolvedValue).toBe('at-0');
+
+ const req = endpoint.requests[0];
+ expect(req.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer');
+ const claims = decodeAssertionClaims(req.get('assertion')!);
+ expect(claims.iss).toBe('sa@proj.iam.gserviceaccount.com');
+ expect(claims.aud).toBe(endpoint.url);
+ expect(claims.scope).toBe('cloud.readonly');
+ });
+
+ it('supports raw privateKey + issuer + subject with an explicit tokenUrl', async () => {
+ // double-quoted values expand \n escapes into real newlines for the PEM
+ const g = await loadAndResolveWithHeader('', outdent`
+ # @internal @sensitive
+ SIGNING_KEY="${PRIVATE_KEY_PEM.replaceAll('\n', '\\n')}"
+ TOKEN=oauth(grant="jwt_bearer", tokenUrl="${endpoint.url}", privateKey=$SIGNING_KEY, issuer="client-abc", subject="user@example.com")
+ `);
+ expect(g.configSchema.TOKEN.errors).toEqual([]);
+ expect(g.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ const claims = decodeAssertionClaims(endpoint.requests[0].get('assertion')!);
+ expect(claims.iss).toBe('client-abc');
+ expect(claims.sub).toBe('user@example.com');
+ });
+
+ it('caches minted tokens until expiry', async () => {
+ const store = new InMemoryCacheStore();
+ const schema = outdent`
+ ${serviceAccountKeyItem(endpoint.url)}
+ TOKEN=oauth(grant="jwt_bearer", serviceAccountKey=$SA_KEY, scopes="s1")
+ `;
+ const g1 = await loadAndResolve(schema, store);
+ const g2 = await loadAndResolve(schema, store);
+ expect(g1.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ expect(g2.configSchema.TOKEN.resolvedValue).toBe('at-0');
+ expect(endpoint.requests.length).toBe(1);
+ });
+
+ it('fails with a clear error when the key file has no token_uri and none is set', async () => {
+ const keyJson = JSON.stringify({ client_email: 'sa@x', private_key: PRIVATE_KEY_PEM });
+ const g = await loadAndResolve(outdent`
+ # @internal @sensitive
+ SA_KEY='${keyJson}'
+ TOKEN=oauth(grant="jwt_bearer", serviceAccountKey=$SA_KEY)
+ `);
+ expect(g.configSchema.TOKEN.resolutionError?.message).toContain('token_uri');
+ });
+
+ it('validates jwt args at schema load', async () => {
+ const cases: Array<[string, RegExp]> = [
+ [`TOKEN=oauth(grant="jwt_bearer", tokenUrl="${endpoint.url}")`, /requires serviceAccountKey/],
+ [`TOKEN=oauth(grant="jwt_bearer", tokenUrl="${endpoint.url}", privateKey="pk")`, /issuer is required/],
+ [`TOKEN=oauth(grant="jwt_bearer", tokenUrl="${endpoint.url}", serviceAccountKey="k", refreshToken="rt")`, /refreshToken does not apply/],
+ [`TOKEN=oauth(tokenUrl="${endpoint.url}", clientId="c", refreshToken="rt", serviceAccountKey="k")`, /only applies to the jwt_bearer grant/],
+ ];
+ for (const [envContent, errMatch] of cases) {
+ const g = await loadAndResolve(envContent);
+ expect(g.configSchema.TOKEN.errors[0]?.message).toMatch(errMatch);
+ }
+ });
+ });
+
+ describe('schema validation', () => {
+ async function expectSchemaError(envContent: string, messageMatch: RegExp) {
+ const g = await loadAndResolve(envContent);
+ const errors = g.configSchema.TOKEN.errors;
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors[0].message).toMatch(messageMatch);
+ }
+
+ it('requires tokenUrl', async () => {
+ await expectSchemaError('TOKEN=oauth(clientId="c", refreshToken="rt")', /tokenUrl is required/);
+ });
+
+ it('requires https tokenUrl (except localhost)', async () => {
+ await expectSchemaError('TOKEN=oauth(tokenUrl="http://example.com/token", clientId="c", refreshToken="rt")', /https/);
+ });
+
+ it('requires refreshToken for the refresh_token grant', async () => {
+ await expectSchemaError(`TOKEN=oauth(tokenUrl="${endpoint.url}", clientId="c")`, /refreshToken is required/);
+ });
+
+ it('rejects refreshToken with the client_credentials grant', async () => {
+ await expectSchemaError(
+ `TOKEN=oauth(tokenUrl="${endpoint.url}", grant="client_credentials", clientId="c", refreshToken="rt")`,
+ /does not apply/,
+ );
+ });
+
+ it('rejects unknown grants and args', async () => {
+ await expectSchemaError(`TOKEN=oauth(tokenUrl="${endpoint.url}", grant="password", clientId="c")`, /grant must be one of/);
+ await expectSchemaError(`TOKEN=oauth(tokenUrl="${endpoint.url}", clientId="c", refreshToken="rt", bogus=1)`, /unknown arg "bogus"/);
+ });
+
+ it('rejects reserved keys in params', async () => {
+ await expectSchemaError(
+ `TOKEN=oauth(tokenUrl="${endpoint.url}", clientId="c", refreshToken="rt", params={ client_secret="x" })`,
+ /reserved param/,
+ );
+ });
+
+ it('cannot be wrapped in cache()', async () => {
+ await expectSchemaError(
+ `TOKEN=cache(oauth(tokenUrl="${endpoint.url}", clientId="c", refreshToken="rt"))`,
+ /already caches/,
+ );
+ });
+ });
+});
diff --git a/packages/varlock/src/lib/cache/cache-store.ts b/packages/varlock/src/lib/cache/cache-store.ts
index c69015b4e..da2c917dc 100644
--- a/packages/varlock/src/lib/cache/cache-store.ts
+++ b/packages/varlock/src/lib/cache/cache-store.ts
@@ -288,6 +288,13 @@ export type CacheStoreLike = {
set(cacheKey: string, value: any, ttlMs: number): Promise<{ cachedAt: number; expiresAt: number } | undefined>;
delete(cacheKey: string): Promise;
clearAll(): Promise;
+ /**
+ * Run `fn` holding this key's cross-process lock, for callers that need a
+ * read-check-write critical section that getOrSet's fixed TTL can't express
+ * (e.g. oauth() refreshing based on its own stored expiry). Optional -
+ * single-process stores get correct (if unserialized) behavior without it.
+ */
+ withKeyLock?(cacheKey: string, fn: () => Promise | T): Promise;
};
/** Compute a concrete expiry timestamp from a TTL (Infinity โ far-future) */
@@ -396,6 +403,13 @@ export class CacheStore {
* Uses a per-key lock so concurrent callers (including across processes)
* don't stampede the producer for the same cache key.
*/
+ /** Run `fn` holding the cross-process lock for a single cache key */
+ async withKeyLock(cacheKey: string, fn: () => Promise | T): Promise {
+ const keyHash = createHash('sha256').update(cacheKey).digest('hex');
+ const lockPath = path.join(`${this.filePath}.keylocks`, `${keyHash}.lock`);
+ return await withDirLock(lockPath, KEY_LOCK_OPTS, fn);
+ }
+
async getOrSet(
cacheKey: string,
ttlMs: number,
@@ -408,10 +422,7 @@ export class CacheStore {
return { ...existing, cacheHit: true };
}
- const keyHash = createHash('sha256').update(cacheKey).digest('hex');
- const lockPath = path.join(`${this.filePath}.keylocks`, `${keyHash}.lock`);
-
- return await withDirLock(lockPath, KEY_LOCK_OPTS, async () => {
+ return await this.withKeyLock(cacheKey, async () => {
const latest = await this.get(cacheKey);
if (latest) {
return { ...latest, cacheHit: true };
diff --git a/packages/varlock/src/lib/oauth-jwt.ts b/packages/varlock/src/lib/oauth-jwt.ts
new file mode 100644
index 000000000..3a63e888c
--- /dev/null
+++ b/packages/varlock/src/lib/oauth-jwt.ts
@@ -0,0 +1,100 @@
+/**
+ * JWT assertion building/signing for the OAuth jwt_bearer grant (RFC 7523).
+ *
+ * The dominant use is Google service accounts: the downloaded JSON key holds a
+ * private key that signs a short-lived assertion, exchanged at the token
+ * endpoint for an access token. No refresh token exists in this flow - the
+ * key is the credential.
+ *
+ * RS256 only for now (covers Google, Salesforce, Box). ES256 needs DER-to-JOSE
+ * signature conversion - add it when a real provider requires it.
+ *
+ * Error messages here must never echo key material.
+ */
+
+import { createPrivateKey, sign as cryptoSign } from 'node:crypto';
+
+/** default assertion lifetime - only bounds the exchange window, not the resulting token */
+const DEFAULT_ASSERTION_LIFETIME_SECONDS = 300;
+/** backdate iat slightly so minor clock drift doesn't invalidate the assertion */
+const CLOCK_SKEW_SECONDS = 30;
+
+export type JwtBearerKeyMaterial = {
+ /** `iss` claim - the identity doing the signing (e.g. service account email) */
+ issuer: string;
+ /** `sub` claim - identity to impersonate, when the provider supports it */
+ subject?: string;
+ privateKeyPem: string;
+ /** token endpoint discovered from a service account key file, used when the schema doesn't set one */
+ tokenUrl?: string;
+};
+
+function base64UrlJson(value: any): string {
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
+}
+
+/**
+ * Parses a Google-style service account key JSON (`client_email`, `private_key`,
+ * `token_uri`). Throws on anything else; never echoes file contents.
+ */
+export function parseServiceAccountKey(keyJson: string): JwtBearerKeyMaterial {
+ let parsed: any;
+ try {
+ parsed = JSON.parse(keyJson);
+ } catch {
+ throw new Error('serviceAccountKey is not valid JSON');
+ }
+ if (!parsed || typeof parsed !== 'object') {
+ throw new Error('serviceAccountKey must be a JSON object');
+ }
+ if (typeof parsed.client_email !== 'string' || !parsed.client_email) {
+ throw new Error('serviceAccountKey is missing client_email - expected a service account key file');
+ }
+ if (typeof parsed.private_key !== 'string' || !parsed.private_key) {
+ throw new Error('serviceAccountKey is missing private_key - expected a service account key file');
+ }
+ return {
+ issuer: parsed.client_email,
+ privateKeyPem: parsed.private_key,
+ tokenUrl: typeof parsed.token_uri === 'string' && parsed.token_uri ? parsed.token_uri : undefined,
+ };
+}
+
+/**
+ * Builds and signs the RS256 assertion JWT.
+ * `scope` goes into the claims (Google reads it there); callers may also send
+ * it as a form param, which RFC 7523 servers that ignore the claim expect.
+ */
+export function buildJwtBearerAssertion(opts: {
+ keyMaterial: JwtBearerKeyMaterial;
+ /** `aud` claim - the token endpoint unless overridden */
+ audience: string;
+ scope?: string;
+ lifetimeSeconds?: number;
+}): string {
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const claims: Record = {
+ iss: opts.keyMaterial.issuer,
+ aud: opts.audience,
+ iat: nowSeconds - CLOCK_SKEW_SECONDS,
+ exp: nowSeconds + (opts.lifetimeSeconds ?? DEFAULT_ASSERTION_LIFETIME_SECONDS),
+ };
+ if (opts.keyMaterial.subject) claims.sub = opts.keyMaterial.subject;
+ if (opts.scope) claims.scope = opts.scope;
+
+ const signingInput = `${base64UrlJson({ alg: 'RS256', typ: 'JWT' })}.${base64UrlJson(claims)}`;
+
+ let privateKey;
+ try {
+ privateKey = createPrivateKey(opts.keyMaterial.privateKeyPem);
+ } catch {
+ throw new Error('private key is not a valid PEM key');
+ }
+ if (privateKey.asymmetricKeyType !== 'rsa') {
+ throw new Error(`private key type "${privateKey.asymmetricKeyType}" is not supported - only RSA (RS256) keys work with the jwt_bearer grant currently`);
+ }
+
+ // node's default RSA signing is PKCS#1 v1.5, which is what RS256 means
+ const signature = cryptoSign('sha256', Buffer.from(signingInput), privateKey);
+ return `${signingInput}.${signature.toString('base64url')}`;
+}
diff --git a/packages/varlock/src/lib/oauth-login.ts b/packages/varlock/src/lib/oauth-login.ts
new file mode 100644
index 000000000..598643b96
--- /dev/null
+++ b/packages/varlock/src/lib/oauth-login.ts
@@ -0,0 +1,272 @@
+/**
+ * OAuth provisioning flows for `varlock oauth login`: device code (RFC 8628)
+ * and authorization code + PKCE with a loopback redirect (RFC 8252).
+ *
+ * These are the "flow executor" half of login - they own the PKCE verifier,
+ * state, code exchange, and produce the refresh token. The CLI is a thin UI
+ * driver over them, which keeps the door open for running the executor inside
+ * a remote proxy later while the CLI only displays URLs/codes.
+ *
+ * Error messages here must never echo token values.
+ */
+
+import http from 'node:http';
+import { createHash, randomBytes } from 'node:crypto';
+import { setTimeout as delay } from 'node:timers/promises';
+
+import {
+ assertValidTokenUrl, requestOauthToken, OauthTokenRequestError,
+ OAUTH_DEVICE_CODE_GRANT,
+ type OauthClientAuthMethod, type OauthTokenResult,
+} from './oauth';
+
+export type OauthLoginConfig = {
+ tokenUrl: string;
+ authorizationUrl?: string;
+ deviceAuthorizationUrl?: string;
+ clientId: string;
+ clientSecret?: string;
+ clientAuth?: OauthClientAuthMethod;
+ /** already delimiter-joined per the provider's wire format */
+ scope?: string;
+ /** extra params for the authorization request (e.g. access_type=offline) */
+ extraAuthParams?: Record;
+};
+
+export type OauthLoginResult = {
+ refreshToken: string;
+ accessToken?: string;
+ expiresInSeconds?: number;
+ /** scopes actually granted, when reported */
+ grantedScope?: string;
+};
+
+export class OauthLoginError extends Error {
+ constructor(message: string, readonly tip?: string) {
+ super(message);
+ this.name = 'OauthLoginError';
+ }
+}
+
+function base64Url(buf: Buffer) {
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+/**
+ * A login response without a refresh token cannot power oauth() refresh -
+ * fail with provider-appropriate guidance rather than storing something useless.
+ */
+function toLoginResult(result: OauthTokenResult): OauthLoginResult {
+ if (!result.refreshToken) {
+ throw new OauthLoginError(
+ 'the provider did not return a refresh token',
+ 'Some providers need explicit opt-in (e.g. GitHub apps need "user token expiration" enabled; Google needs access_type=offline). Check the app settings and preset notes.',
+ );
+ }
+ return {
+ refreshToken: result.refreshToken,
+ accessToken: result.accessToken,
+ expiresInSeconds: result.expiresInSeconds,
+ grantedScope: result.scope,
+ };
+}
+
+// โโ Device code flow (RFC 8628) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+export type DeviceAuthorizationInfo = {
+ deviceCode: string;
+ userCode: string;
+ verificationUri: string;
+ /** some providers include a URI with the code embedded */
+ verificationUriComplete?: string;
+ expiresInSeconds: number;
+ pollIntervalSeconds: number;
+};
+
+export async function requestDeviceAuthorization(config: OauthLoginConfig): Promise {
+ if (!config.deviceAuthorizationUrl) {
+ throw new OauthLoginError('this provider has no device authorization endpoint configured');
+ }
+ assertValidTokenUrl(config.deviceAuthorizationUrl, 'deviceAuthorizationUrl');
+
+ const body = new URLSearchParams();
+ body.set('client_id', config.clientId);
+ if (config.scope) body.set('scope', config.scope);
+
+ let res: Response;
+ try {
+ res = await fetch(config.deviceAuthorizationUrl, {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
+ body: body.toString(),
+ signal: AbortSignal.timeout(30_000),
+ });
+ } catch (err) {
+ const cause = (err as any)?.cause?.code ?? (err instanceof Error ? err.message : String(err));
+ throw new OauthLoginError(`device authorization request failed: ${cause}`);
+ }
+ const parsed: any = await res.json().catch(() => undefined);
+ if (!res.ok || !parsed || typeof parsed !== 'object' || !parsed.device_code) {
+ const code = typeof parsed?.error === 'string' ? ` (${parsed.error})` : '';
+ throw new OauthLoginError(
+ `device authorization request returned HTTP ${res.status}${code}`,
+ 'Check that the OAuth app supports the device flow (some providers require enabling it)',
+ );
+ }
+ return {
+ deviceCode: parsed.device_code,
+ userCode: parsed.user_code,
+ // google spells it verification_url
+ verificationUri: parsed.verification_uri ?? parsed.verification_url,
+ verificationUriComplete: parsed.verification_uri_complete,
+ expiresInSeconds: Number(parsed.expires_in) || 900,
+ pollIntervalSeconds: Number(parsed.interval) || 5,
+ };
+}
+
+/**
+ * Poll the token endpoint until the user approves (or the code expires).
+ * `onUserCode` fires once with what to show the user before polling begins.
+ */
+export async function runDeviceCodeLogin(
+ config: OauthLoginConfig,
+ hooks: {
+ onUserCode: (info: DeviceAuthorizationInfo) => void | Promise;
+ signal?: AbortSignal;
+ },
+): Promise {
+ const deviceAuth = await requestDeviceAuthorization(config);
+ await hooks.onUserCode(deviceAuth);
+
+ const deadline = Date.now() + deviceAuth.expiresInSeconds * 1000;
+ let intervalMs = deviceAuth.pollIntervalSeconds * 1000;
+
+ while (Date.now() < deadline) {
+ if (hooks.signal?.aborted) throw new OauthLoginError('login cancelled');
+ await delay(intervalMs);
+ try {
+ const result = await requestOauthToken({
+ tokenUrl: config.tokenUrl,
+ grantType: OAUTH_DEVICE_CODE_GRANT,
+ deviceCode: deviceAuth.deviceCode,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret,
+ clientAuth: config.clientAuth,
+ });
+ return toLoginResult(result);
+ } catch (err) {
+ if (err instanceof OauthTokenRequestError) {
+ const code = err.details.oauthErrorCode;
+ if (code === 'authorization_pending') continue;
+ if (code === 'slow_down') {
+ intervalMs += 5000;
+ continue;
+ }
+ if (code === 'access_denied') throw new OauthLoginError('login was denied by the user');
+ if (code === 'expired_token') break;
+ }
+ throw err;
+ }
+ }
+ throw new OauthLoginError('the device code expired before login was completed - try again');
+}
+
+// โโ Authorization code + PKCE with loopback redirect (RFC 8252) โโโโโโโโ
+
+const PKCE_CALLBACK_PATH = '/oauth/callback';
+const DEFAULT_PKCE_TIMEOUT_MS = 5 * 60 * 1000;
+
+const CALLBACK_RESPONSE_HTML = (message: string) => `
+varlock
+
+${message}
You can close this tab and return to your terminal.
`;
+
+/**
+ * Runs a loopback server, hands the authorization URL to `onAuthorizationUrl`
+ * (the caller opens it in a browser), waits for the provider to redirect back
+ * with a code, and exchanges it.
+ */
+export async function runPkceLogin(
+ config: OauthLoginConfig,
+ hooks: {
+ onAuthorizationUrl: (url: string) => void | Promise;
+ timeoutMs?: number;
+ },
+): Promise {
+ if (!config.authorizationUrl) {
+ throw new OauthLoginError('this provider has no authorization endpoint configured');
+ }
+ assertValidTokenUrl(config.authorizationUrl, 'authorizationUrl');
+
+ const codeVerifier = base64Url(randomBytes(32));
+ const codeChallenge = base64Url(createHash('sha256').update(codeVerifier).digest());
+ const state = base64Url(randomBytes(16));
+
+ let resolveCallback: (result: { code: string } | { error: string }) => void;
+ const callbackReceived = new Promise<{ code: string } | { error: string }>((resolve) => {
+ resolveCallback = resolve;
+ });
+
+ const server = http.createServer((req, res) => {
+ const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
+ if (reqUrl.pathname !== PKCE_CALLBACK_PATH) {
+ res.writeHead(404).end();
+ return;
+ }
+ const errorParam = reqUrl.searchParams.get('error');
+ const code = reqUrl.searchParams.get('code');
+ const returnedState = reqUrl.searchParams.get('state');
+ if (errorParam) {
+ res.writeHead(200, { 'content-type': 'text/html' }).end(CALLBACK_RESPONSE_HTML('Login failed'));
+ resolveCallback({ error: `provider returned error "${errorParam}"` });
+ } else if (!code || returnedState !== state) {
+ // a state mismatch means this redirect was not initiated by us - reject it
+ res.writeHead(400, { 'content-type': 'text/html' }).end(CALLBACK_RESPONSE_HTML('Login failed'));
+ resolveCallback({ error: 'callback state mismatch - possible interception or a stale login attempt' });
+ } else {
+ res.writeHead(200, { 'content-type': 'text/html' }).end(CALLBACK_RESPONSE_HTML('Login successful'));
+ resolveCallback({ code });
+ }
+ });
+
+ await new Promise((resolve) => {
+ server.listen(0, '127.0.0.1', resolve);
+ });
+ const port = (server.address() as import('node:net').AddressInfo).port;
+ const redirectUri = `http://127.0.0.1:${port}${PKCE_CALLBACK_PATH}`;
+
+ try {
+ const authUrl = new URL(config.authorizationUrl);
+ authUrl.searchParams.set('response_type', 'code');
+ authUrl.searchParams.set('client_id', config.clientId);
+ authUrl.searchParams.set('redirect_uri', redirectUri);
+ authUrl.searchParams.set('state', state);
+ authUrl.searchParams.set('code_challenge', codeChallenge);
+ authUrl.searchParams.set('code_challenge_method', 'S256');
+ if (config.scope) authUrl.searchParams.set('scope', config.scope);
+ for (const [key, value] of Object.entries(config.extraAuthParams ?? {})) {
+ authUrl.searchParams.set(key, value);
+ }
+ await hooks.onAuthorizationUrl(authUrl.toString());
+
+ const outcome = await Promise.race([
+ callbackReceived,
+ delay(hooks.timeoutMs ?? DEFAULT_PKCE_TIMEOUT_MS).then(() => ({ error: 'timed out waiting for the browser login to complete' })),
+ ]);
+ if ('error' in outcome) throw new OauthLoginError(outcome.error);
+
+ const result = await requestOauthToken({
+ tokenUrl: config.tokenUrl,
+ grantType: 'authorization_code',
+ code: outcome.code,
+ redirectUri,
+ codeVerifier,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret,
+ clientAuth: config.clientAuth,
+ });
+ return toLoginResult(result);
+ } finally {
+ server.close();
+ }
+}
diff --git a/packages/varlock/src/lib/oauth-providers.ts b/packages/varlock/src/lib/oauth-providers.ts
new file mode 100644
index 000000000..73d32b3b0
--- /dev/null
+++ b/packages/varlock/src/lib/oauth-providers.ts
@@ -0,0 +1,72 @@
+/**
+ * Data-driven definitions of well-known OAuth providers, used by the
+ * `@oauthClient` root decorator's `provider=` arg. A provider def fills in
+ * endpoints and quirks so users only supply their own client credentials.
+ *
+ * Keep these entries pure data - anything requiring provider-specific code
+ * belongs in a plugin instead.
+ */
+
+import type { OauthClientAuthMethod } from './oauth';
+
+export type OauthProviderDef = {
+ /** display label */
+ label: string;
+ tokenUrl: string;
+ /** authorization endpoint for the browser (PKCE) login flow */
+ authorizationUrl?: string;
+ /** device authorization endpoint (RFC 8628) - presence means device flow is supported */
+ deviceAuthorizationUrl?: string;
+ /** how client credentials are sent to the token endpoint */
+ clientAuth?: OauthClientAuthMethod;
+ /** extra params required on the authorization request (e.g. to get a refresh token at all) */
+ extraAuthParams?: Record;
+ /** scopes that must always be requested during login (e.g. offline_access) */
+ requiredLoginScopes?: Array;
+ /** delimiter for joining multiple scopes on the wire (default: space) */
+ scopesDelimiter?: string;
+ /** where to register an OAuth app for this provider */
+ appSetupUrl?: string;
+ /** shown in login guidance and error tips */
+ notes?: string;
+};
+
+export const OAUTH_PROVIDERS: Record = {
+ google: {
+ label: 'Google',
+ tokenUrl: 'https://oauth2.googleapis.com/token',
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+ deviceAuthorizationUrl: 'https://oauth2.googleapis.com/device/code',
+ // without these the authorization flow never returns a refresh token
+ extraAuthParams: { access_type: 'offline', prompt: 'consent' },
+ appSetupUrl: 'https://console.cloud.google.com/apis/credentials',
+ notes: 'Register a "Desktop app" OAuth client (loopback redirects are allowed implicitly). Device flow supports a limited set of scopes; clientSecret is required for token exchange even for desktop clients (it is not treated as confidential).',
+ },
+ github: {
+ label: 'GitHub',
+ tokenUrl: 'https://github.com/login/oauth/access_token',
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
+ deviceAuthorizationUrl: 'https://github.com/login/device/code',
+ appSetupUrl: 'https://github.com/settings/developers',
+ notes: 'Enable device flow on the OAuth app for device login. Refresh tokens are only issued when "user token expiration" is enabled on the app; otherwise tokens are long-lived and oauth() refresh does not apply.',
+ },
+ microsoft: {
+ label: 'Microsoft (Entra ID)',
+ tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
+ authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
+ deviceAuthorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode',
+ requiredLoginScopes: ['offline_access'],
+ appSetupUrl: 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade',
+ notes: 'Register a public client (mobile & desktop) app. The default endpoints use the "common" tenant; set tokenUrl/authorizationUrl explicitly to pin a tenant.',
+ },
+ slack: {
+ label: 'Slack',
+ tokenUrl: 'https://slack.com/api/oauth.v2.access',
+ authorizationUrl: 'https://slack.com/oauth/v2/authorize',
+ scopesDelimiter: ',',
+ appSetupUrl: 'https://api.slack.com/apps',
+ notes: 'Slack has no device flow and requires https redirect URLs, so the local browser login flow does not work; provision a refresh token elsewhere and pass it via refreshToken. Refresh tokens require token rotation to be enabled on the app.',
+ },
+};
+
+export const OAUTH_PROVIDER_NAMES = Object.keys(OAUTH_PROVIDERS);
diff --git a/packages/varlock/src/lib/oauth.ts b/packages/varlock/src/lib/oauth.ts
new file mode 100644
index 000000000..17a5e933d
--- /dev/null
+++ b/packages/varlock/src/lib/oauth.ts
@@ -0,0 +1,289 @@
+/**
+ * OAuth 2.0 token endpoint client (RFC 6749).
+ *
+ * Powers the `oauth()` resolver function: exchanges a long-lived credential
+ * (refresh token, or client id + secret) for a short-lived access token by
+ * POSTing to a provider's token endpoint.
+ *
+ * Error messages here must never echo token or secret values, since resolver
+ * errors are printed unredacted.
+ */
+
+import { createHash } from 'node:crypto';
+
+/** grant types usable from the oauth() resolver */
+export const OAUTH_GRANT_TYPES = ['refresh_token', 'client_credentials', 'jwt_bearer'] as const;
+export type OauthGrantType = typeof OAUTH_GRANT_TYPES[number];
+
+export const OAUTH_DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
+export const OAUTH_JWT_BEARER_GRANT_URN = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
+/** all grants the token client can send - provisioning grants included */
+export type OauthTokenRequestGrantType = OauthGrantType | 'authorization_code' | typeof OAUTH_DEVICE_CODE_GRANT;
+
+export const OAUTH_CLIENT_AUTH_METHODS = ['body', 'basic'] as const;
+/** How client credentials are sent: form body params (client_secret_post) or HTTP basic auth (client_secret_basic) */
+export type OauthClientAuthMethod = typeof OAUTH_CLIENT_AUTH_METHODS[number];
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+/** Max chars of provider error description we echo back in error messages */
+const MAX_ERROR_DESCRIPTION_LENGTH = 300;
+
+/** Params callers may not pass via extraParams because we set them ourselves */
+export const OAUTH_RESERVED_PARAMS = ['grant_type', 'refresh_token', 'client_id', 'client_secret', 'scope'];
+
+export class OauthTokenRequestError extends Error {
+ constructor(
+ message: string,
+ readonly details: {
+ /** HTTP status of the token endpoint response, if one was received */
+ status?: number;
+ /** standard OAuth error code from the response body (e.g. `invalid_grant`) */
+ oauthErrorCode?: string;
+ } = {},
+ ) {
+ super(message);
+ this.name = 'OauthTokenRequestError';
+ }
+}
+
+/**
+ * Validates an OAuth endpoint URL. Must be https, except localhost is allowed
+ * over plain http (tests, local identity providers).
+ */
+export function assertValidTokenUrl(tokenUrl: string, label = 'tokenUrl'): URL {
+ let parsed: URL;
+ try {
+ parsed = new URL(tokenUrl);
+ } catch {
+ throw new Error(`${label} must be a valid URL`);
+ }
+ if (parsed.protocol === 'https:') return parsed;
+ if (parsed.protocol === 'http:') {
+ const host = parsed.hostname;
+ if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1') return parsed;
+ throw new Error(`${label} must use https (plain http is only allowed for localhost)`);
+ }
+ throw new Error(`${label} must be an http(s) URL`);
+}
+
+export type OauthTokenRequestOpts = {
+ tokenUrl: string;
+ grantType: OauthTokenRequestGrantType;
+ clientId?: string;
+ clientSecret?: string;
+ /** how to send client credentials - form body (default) or HTTP basic auth */
+ clientAuth?: OauthClientAuthMethod;
+ /** required for the refresh_token grant */
+ refreshToken?: string;
+ /** required for the authorization_code grant */
+ code?: string;
+ redirectUri?: string;
+ codeVerifier?: string;
+ /** required for the device_code grant */
+ deviceCode?: string;
+ /** required for the jwt_bearer grant - a signed JWT (see oauth-jwt.ts) */
+ assertion?: string;
+ /** already delimiter-joined per the OAuth wire format */
+ scope?: string;
+ /** additional form body params (e.g. audience, resource) */
+ extraParams?: Record;
+ timeoutMs?: number;
+};
+
+export type OauthTokenResult = {
+ accessToken: string;
+ /** lifetime reported by the provider; undefined when the response omits expires_in */
+ expiresInSeconds?: number;
+ /** present when the provider rotates refresh tokens */
+ refreshToken?: string;
+ scope?: string;
+ tokenType?: string;
+};
+
+// โโ cache keys + entry shapes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// Shared between the oauth() resolver and the `varlock oauth login` CLI so
+// both compute identical keys.
+
+/** access-token cache entry, one per (item scope-set) */
+export type OauthItemCacheEntry = {
+ accessToken: string;
+ /** epoch ms when the access token stops being usable (provider-reported) */
+ expiresAt: number;
+ /** latest rotated refresh token - only used when the refresh token is item-configured */
+ refreshToken?: string;
+ scope?: string;
+ lastRefreshedAt: number;
+ refreshCount: number;
+};
+
+/** provider-level entry - the live home of a login-provisioned refresh token, shared across items */
+export type OauthClientCacheEntry = {
+ refreshToken: string;
+ /** scopes granted at login (may be broader than any one item's request) */
+ grantedScope?: string;
+ updatedAt: number;
+ source: 'login' | 'rotation';
+};
+
+/** key for an item's access-token entry, scoped to the exact credentials + scopes */
+export function buildOauthItemCacheKey(parts: {
+ tokenUrl: string;
+ grantType: string;
+ /** client id, or the assertion issuer for the jwt_bearer grant */
+ clientId: string;
+ scope?: string;
+ /** the CONFIGURED bootstrap refresh token (not a rotated one); empty for login-provisioned */
+ refreshToken?: string;
+ /** jwt_bearer impersonation subject */
+ subject?: string;
+}): string {
+ const keyMaterial = [parts.tokenUrl, parts.grantType, parts.clientId, parts.scope ?? '', parts.refreshToken ?? '', parts.subject ?? ''].join('\n');
+ const digest = createHash('sha256').update(keyMaterial).digest('hex').slice(0, 16);
+ return `oauth:${new URL(parts.tokenUrl).hostname}:${digest}`;
+}
+
+/** key for the shared provider-level refresh-token entry, written by `varlock oauth login` */
+export function buildOauthClientCacheKey(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);
+ return `oauth:${new URL(parts.tokenUrl).hostname}:provider-${digest}`;
+}
+
+/** display helper - scopes string or a placeholder when none requested */
+export function formatOauthScopesForDisplay(scope: string | undefined): string {
+ return scope || '(provider default)';
+}
+
+function truncate(str: string, maxLen: number) {
+ return str.length > maxLen ? `${str.slice(0, maxLen)}โฆ` : str;
+}
+
+/** Extracts a standard OAuth error shape from a response body, tolerating non-JSON */
+function parseErrorBody(bodyText: string): { code?: string; description?: string } {
+ try {
+ const parsed = JSON.parse(bodyText);
+ if (parsed && typeof parsed === 'object') {
+ return {
+ code: typeof parsed.error === 'string' ? parsed.error : undefined,
+ description: typeof parsed.error_description === 'string' ? parsed.error_description : undefined,
+ };
+ }
+ } catch { /* not json */ }
+ return {};
+}
+
+/**
+ * POST to an OAuth token endpoint and parse the response.
+ * Throws OauthTokenRequestError on any failure; messages never contain secret values.
+ */
+export async function requestOauthToken(opts: OauthTokenRequestOpts): Promise {
+ const url = assertValidTokenUrl(opts.tokenUrl);
+
+ const body = new URLSearchParams();
+ // jwt_bearer is the resolver-facing name; the wire format wants the URN
+ body.set('grant_type', opts.grantType === 'jwt_bearer' ? OAUTH_JWT_BEARER_GRANT_URN : opts.grantType);
+ if (opts.grantType === 'jwt_bearer') {
+ if (!opts.assertion) throw new OauthTokenRequestError('jwt_bearer grant requires an assertion');
+ body.set('assertion', opts.assertion);
+ } else if (opts.grantType === 'refresh_token') {
+ if (!opts.refreshToken) throw new OauthTokenRequestError('refresh_token grant requires a refresh token');
+ body.set('refresh_token', opts.refreshToken);
+ } else if (opts.grantType === 'authorization_code') {
+ if (!opts.code || !opts.redirectUri) {
+ throw new OauthTokenRequestError('authorization_code grant requires code and redirectUri');
+ }
+ body.set('code', opts.code);
+ body.set('redirect_uri', opts.redirectUri);
+ if (opts.codeVerifier) body.set('code_verifier', opts.codeVerifier);
+ } else if (opts.grantType === OAUTH_DEVICE_CODE_GRANT) {
+ if (!opts.deviceCode) throw new OauthTokenRequestError('device_code grant requires deviceCode');
+ body.set('device_code', opts.deviceCode);
+ }
+ if (opts.scope) body.set('scope', opts.scope);
+ for (const [key, value] of Object.entries(opts.extraParams ?? {})) {
+ if (OAUTH_RESERVED_PARAMS.includes(key)) {
+ throw new OauthTokenRequestError(`params may not override reserved param "${key}"`);
+ }
+ body.set(key, value);
+ }
+
+ const headers: Record = {
+ 'content-type': 'application/x-www-form-urlencoded',
+ accept: 'application/json',
+ };
+ if (opts.clientAuth === 'basic') {
+ if (!opts.clientId) throw new OauthTokenRequestError('clientAuth=basic requires clientId');
+ // RFC 6749 ยง2.3.1 - credentials are form-urlencoded before base64
+ const encoded = Buffer.from(
+ `${encodeURIComponent(opts.clientId)}:${encodeURIComponent(opts.clientSecret ?? '')}`,
+ ).toString('base64');
+ headers.authorization = `Basic ${encoded}`;
+ } else {
+ if (opts.clientId) body.set('client_id', opts.clientId);
+ if (opts.clientSecret) body.set('client_secret', opts.clientSecret);
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: body.toString(),
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
+ });
+ } catch (err) {
+ if (err instanceof Error && err.name === 'TimeoutError') {
+ throw new OauthTokenRequestError(`token endpoint request timed out (${url.host})`);
+ }
+ const cause = (err as any)?.cause?.code ?? (err instanceof Error ? err.message : String(err));
+ throw new OauthTokenRequestError(`token endpoint request failed (${url.host}): ${cause}`);
+ }
+
+ const bodyText = await res.text();
+
+ if (!res.ok) {
+ const { code, description } = parseErrorBody(bodyText);
+ let message = `token endpoint returned HTTP ${res.status}`;
+ if (code) message += ` (${code})`;
+ if (description) message += `: ${truncate(description, MAX_ERROR_DESCRIPTION_LENGTH)}`;
+ throw new OauthTokenRequestError(message, { status: res.status, oauthErrorCode: code });
+ }
+
+ let parsed: any;
+ try {
+ parsed = JSON.parse(bodyText);
+ } catch {
+ throw new OauthTokenRequestError('token endpoint returned a non-JSON response');
+ }
+ if (!parsed || typeof parsed !== 'object') {
+ throw new OauthTokenRequestError('token endpoint returned an unexpected response shape');
+ }
+
+ // some providers (e.g. Slack) return errors with HTTP 200
+ if (typeof parsed.access_token !== 'string' || !parsed.access_token) {
+ const code = typeof parsed.error === 'string' ? parsed.error : undefined;
+ let message = 'token endpoint response is missing access_token';
+ if (code) {
+ message = `token endpoint returned an error (${code})`;
+ if (typeof parsed.error_description === 'string') {
+ message += `: ${truncate(parsed.error_description, MAX_ERROR_DESCRIPTION_LENGTH)}`;
+ }
+ }
+ throw new OauthTokenRequestError(message, { status: res.status, oauthErrorCode: code });
+ }
+
+ // expires_in should be a number of seconds, but some providers send a string
+ let expiresInSeconds: number | undefined;
+ if (parsed.expires_in !== undefined) {
+ const num = Number(parsed.expires_in);
+ if (Number.isFinite(num) && num > 0) expiresInSeconds = num;
+ }
+
+ return {
+ accessToken: parsed.access_token,
+ expiresInSeconds,
+ refreshToken: typeof parsed.refresh_token === 'string' && parsed.refresh_token ? parsed.refresh_token : undefined,
+ scope: typeof parsed.scope === 'string' ? parsed.scope : undefined,
+ tokenType: typeof parsed.token_type === 'string' ? parsed.token_type : undefined,
+ };
+}
diff --git a/packages/varlock/src/lib/test/oauth-jwt.test.ts b/packages/varlock/src/lib/test/oauth-jwt.test.ts
new file mode 100644
index 000000000..2ac2c4525
--- /dev/null
+++ b/packages/varlock/src/lib/test/oauth-jwt.test.ts
@@ -0,0 +1,94 @@
+/**
+ * Tests for jwt_bearer assertion building/signing.
+ */
+
+import { generateKeyPairSync, verify as cryptoVerify } from 'node:crypto';
+import { describe, it, expect } from 'vitest';
+import { parseServiceAccountKey, buildJwtBearerAssertion } from '../oauth-jwt';
+
+const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
+const PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
+
+function decodeSegment(segment: string) {
+ return JSON.parse(Buffer.from(segment, 'base64url').toString());
+}
+
+describe('parseServiceAccountKey', () => {
+ it('extracts issuer, key, and token url from a google-style key file', () => {
+ const material = parseServiceAccountKey(JSON.stringify({
+ type: 'service_account',
+ client_email: 'sa@project.iam.gserviceaccount.com',
+ private_key: PRIVATE_KEY_PEM,
+ token_uri: 'https://oauth2.googleapis.com/token',
+ }));
+ expect(material.issuer).toBe('sa@project.iam.gserviceaccount.com');
+ expect(material.privateKeyPem).toBe(PRIVATE_KEY_PEM);
+ expect(material.tokenUrl).toBe('https://oauth2.googleapis.com/token');
+ });
+
+ it('rejects non-JSON and non-key-file shapes without echoing contents', () => {
+ expect(() => parseServiceAccountKey('not json')).toThrow(/not valid JSON/);
+ const err = (() => {
+ try {
+ parseServiceAccountKey(JSON.stringify({ some: 'secret-thing' }));
+ return undefined;
+ } catch (e) { return e as Error; }
+ })();
+ expect(err?.message).toMatch(/missing client_email/);
+ expect(err?.message).not.toContain('secret-thing');
+ });
+});
+
+describe('buildJwtBearerAssertion', () => {
+ const keyMaterial = { issuer: 'sa@project.iam', privateKeyPem: PRIVATE_KEY_PEM };
+
+ it('produces a valid RS256 JWT with the expected claims', () => {
+ const assertion = buildJwtBearerAssertion({
+ keyMaterial,
+ audience: 'https://example.com/token',
+ scope: 'a b',
+ });
+ const [headerSeg, claimsSeg, sigSeg] = assertion.split('.');
+ expect(decodeSegment(headerSeg)).toEqual({ alg: 'RS256', typ: 'JWT' });
+
+ const claims = decodeSegment(claimsSeg);
+ expect(claims.iss).toBe('sa@project.iam');
+ expect(claims.aud).toBe('https://example.com/token');
+ expect(claims.scope).toBe('a b');
+ expect(claims.sub).toBeUndefined();
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ expect(claims.iat).toBeLessThanOrEqual(nowSeconds);
+ expect(claims.exp).toBeGreaterThan(nowSeconds);
+ expect(claims.exp - claims.iat).toBeLessThanOrEqual(600);
+
+ const verified = cryptoVerify(
+ 'sha256',
+ Buffer.from(`${headerSeg}.${claimsSeg}`),
+ publicKey,
+ Buffer.from(sigSeg, 'base64url'),
+ );
+ expect(verified).toBe(true);
+ });
+
+ it('includes the subject claim when impersonating', () => {
+ const assertion = buildJwtBearerAssertion({
+ keyMaterial: { ...keyMaterial, subject: 'user@example.com' },
+ audience: 'https://example.com/token',
+ });
+ expect(decodeSegment(assertion.split('.')[1]).sub).toBe('user@example.com');
+ });
+
+ it('rejects invalid and non-RSA keys', () => {
+ expect(() => buildJwtBearerAssertion({
+ keyMaterial: { issuer: 'x', privateKeyPem: 'not a pem' },
+ audience: 'https://example.com/token',
+ })).toThrow(/not a valid PEM/);
+
+ const ecKey = generateKeyPairSync('ec', { namedCurve: 'P-256' })
+ .privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
+ expect(() => buildJwtBearerAssertion({
+ keyMaterial: { issuer: 'x', privateKeyPem: ecKey },
+ audience: 'https://example.com/token',
+ })).toThrow(/only RSA/);
+ });
+});
diff --git a/packages/varlock/src/lib/test/oauth-login.test.ts b/packages/varlock/src/lib/test/oauth-login.test.ts
new file mode 100644
index 000000000..ecfb878f5
--- /dev/null
+++ b/packages/varlock/src/lib/test/oauth-login.test.ts
@@ -0,0 +1,223 @@
+/**
+ * Tests for the oauth login flow executors (device code + PKCE loopback).
+ */
+
+import http from 'node:http';
+import { createHash } from 'node:crypto';
+import {
+ describe, it, expect, beforeEach, afterEach,
+} from 'vitest';
+import {
+ runDeviceCodeLogin, runPkceLogin, requestDeviceAuthorization, OauthLoginError,
+} from '../oauth-login';
+
+function base64Url(buf: Buffer) {
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+/** mock provider serving both the device-authorization and token endpoints */
+class MockProvider {
+ tokenRequests: Array = [];
+ deviceAuthRequests: Array = [];
+
+ /** how many polls return authorization_pending before success */
+ pendingPolls = 0;
+ /** override the successful token response body */
+ tokenResponse: Record = {
+ access_token: 'at-1', refresh_token: 'rt-1', expires_in: 3600, scope: 'read',
+ };
+
+ private server?: http.Server;
+ origin = '';
+ get tokenUrl() { return `${this.origin}/token`; }
+ get deviceAuthUrl() { return `${this.origin}/device`; }
+ get authorizationUrl() { return `${this.origin}/authorize`; }
+
+ async start() {
+ this.server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (chunk) => {
+ raw += chunk;
+ });
+ req.on('end', () => {
+ const body = new URLSearchParams(raw);
+ const respond = (status: number, payload: any) => {
+ res.writeHead(status, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(payload));
+ };
+ if (req.url === '/device') {
+ this.deviceAuthRequests.push(body);
+ respond(200, {
+ device_code: 'dev-code-1',
+ user_code: 'ABCD-1234',
+ verification_uri: 'https://example.com/activate',
+ expires_in: 300,
+ interval: 0.01, // fast polling for tests
+ });
+ } else if (req.url === '/token') {
+ this.tokenRequests.push(body);
+ if (this.pendingPolls > 0) {
+ this.pendingPolls -= 1;
+ respond(400, { error: 'authorization_pending' });
+ } else {
+ respond(200, this.tokenResponse);
+ }
+ } else {
+ respond(404, {});
+ }
+ });
+ });
+ await new Promise((resolve) => {
+ this.server!.listen(0, '127.0.0.1', resolve);
+ });
+ const address = this.server!.address() as import('node:net').AddressInfo;
+ this.origin = `http://127.0.0.1:${address.port}`;
+ }
+
+ async stop() {
+ await new Promise((resolve) => {
+ if (this.server) this.server.close(() => resolve());
+ else resolve();
+ });
+ }
+}
+
+describe('oauth login flows', () => {
+ let provider: MockProvider;
+ beforeEach(async () => {
+ provider = new MockProvider();
+ await provider.start();
+ });
+ afterEach(async () => {
+ await provider.stop();
+ });
+
+ function baseConfig() {
+ return {
+ tokenUrl: provider.tokenUrl,
+ authorizationUrl: provider.authorizationUrl,
+ deviceAuthorizationUrl: provider.deviceAuthUrl,
+ clientId: 'client-1',
+ clientSecret: 'secret-1',
+ scope: 'read write',
+ };
+ }
+
+ describe('device code flow', () => {
+ it('requests a device code and polls until approved', async () => {
+ provider.pendingPolls = 2;
+ let shownCode: string | undefined;
+ const result = await runDeviceCodeLogin(baseConfig(), {
+ onUserCode: (info) => {
+ shownCode = info.userCode;
+ },
+ });
+ expect(shownCode).toBe('ABCD-1234');
+ expect(result.refreshToken).toBe('rt-1');
+ expect(result.grantedScope).toBe('read');
+ expect(provider.deviceAuthRequests[0].get('client_id')).toBe('client-1');
+ expect(provider.deviceAuthRequests[0].get('scope')).toBe('read write');
+ // 2 pending + 1 success
+ expect(provider.tokenRequests.length).toBe(3);
+ expect(provider.tokenRequests[0].get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:device_code');
+ expect(provider.tokenRequests[0].get('device_code')).toBe('dev-code-1');
+ });
+
+ it('fails cleanly when the user denies access', async () => {
+ // Slack-style: error returned with HTTP 200 and no access_token
+ provider.tokenResponse = { error: 'access_denied' };
+ await expect(runDeviceCodeLogin(baseConfig(), { onUserCode: () => undefined }))
+ .rejects.toThrow(/access_denied|denied/i);
+ });
+
+ it('errors when device endpoint is missing', async () => {
+ await expect(runDeviceCodeLogin(
+ { ...baseConfig(), deviceAuthorizationUrl: undefined },
+ { onUserCode: () => undefined },
+ )).rejects.toThrow(/no device authorization endpoint/);
+ });
+
+ it('requestDeviceAuthorization surfaces provider errors with guidance', async () => {
+ await provider.stop();
+ await expect(requestDeviceAuthorization(baseConfig())).rejects.toThrow(OauthLoginError);
+ });
+ });
+
+ describe('pkce loopback flow', () => {
+ /** simulate the browser hitting the loopback callback */
+ async function completeInBrowser(authUrl: string, opts?: { tamperState?: boolean; error?: string }) {
+ const parsed = new URL(authUrl);
+ const redirectUri = parsed.searchParams.get('redirect_uri')!;
+ const state = opts?.tamperState ? 'tampered' : parsed.searchParams.get('state')!;
+ const callbackUrl = new URL(redirectUri);
+ if (opts?.error) {
+ callbackUrl.searchParams.set('error', opts.error);
+ } else {
+ callbackUrl.searchParams.set('code', 'auth-code-1');
+ callbackUrl.searchParams.set('state', state);
+ }
+ return await fetch(callbackUrl);
+ }
+
+ it('completes the full loopback exchange with PKCE', async () => {
+ let capturedAuthUrl: string | undefined;
+ const result = await runPkceLogin(baseConfig(), {
+ onAuthorizationUrl: async (url) => {
+ capturedAuthUrl = url;
+ const res = await completeInBrowser(url);
+ expect(res.status).toBe(200);
+ },
+ });
+ expect(result.refreshToken).toBe('rt-1');
+
+ const authUrl = new URL(capturedAuthUrl!);
+ expect(authUrl.searchParams.get('response_type')).toBe('code');
+ expect(authUrl.searchParams.get('client_id')).toBe('client-1');
+ expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256');
+
+ const exchange = provider.tokenRequests[0];
+ expect(exchange.get('grant_type')).toBe('authorization_code');
+ expect(exchange.get('code')).toBe('auth-code-1');
+ // PKCE verifier must hash to the challenge sent in the authorization URL
+ const verifier = exchange.get('code_verifier')!;
+ const expectedChallenge = base64Url(createHash('sha256').update(verifier).digest());
+ expect(authUrl.searchParams.get('code_challenge')).toBe(expectedChallenge);
+ expect(exchange.get('redirect_uri')).toBe(authUrl.searchParams.get('redirect_uri'));
+ });
+
+ it('includes extraAuthParams in the authorization URL', async () => {
+ await runPkceLogin({ ...baseConfig(), extraAuthParams: { access_type: 'offline' } }, {
+ onAuthorizationUrl: async (url) => {
+ expect(new URL(url).searchParams.get('access_type')).toBe('offline');
+ await completeInBrowser(url);
+ },
+ });
+ });
+
+ it('rejects a callback with a mismatched state', async () => {
+ await expect(runPkceLogin(baseConfig(), {
+ onAuthorizationUrl: async (url) => {
+ const res = await completeInBrowser(url, { tamperState: true });
+ expect(res.status).toBe(400);
+ },
+ })).rejects.toThrow(/state mismatch/);
+ });
+
+ it('surfaces provider errors from the callback', async () => {
+ await expect(runPkceLogin(baseConfig(), {
+ onAuthorizationUrl: async (url) => {
+ await completeInBrowser(url, { error: 'access_denied' });
+ },
+ })).rejects.toThrow(/access_denied/);
+ });
+
+ it('fails when the response has no refresh token', async () => {
+ provider.tokenResponse = { access_token: 'at-1', expires_in: 3600 };
+ await expect(runPkceLogin(baseConfig(), {
+ onAuthorizationUrl: async (url) => {
+ await completeInBrowser(url);
+ },
+ })).rejects.toThrow(/did not return a refresh token/);
+ });
+ });
+});
diff --git a/packages/varlock/src/lib/test/oauth.test.ts b/packages/varlock/src/lib/test/oauth.test.ts
new file mode 100644
index 000000000..2f9ae8b8e
--- /dev/null
+++ b/packages/varlock/src/lib/test/oauth.test.ts
@@ -0,0 +1,197 @@
+/**
+ * Tests for the OAuth token endpoint client.
+ * Resolver-level behavior (caching, rotation) is covered by
+ * src/env-graph/test/oauth-resolver.test.ts.
+ */
+
+import http from 'node:http';
+import {
+ describe, it, expect, afterEach,
+} from 'vitest';
+import {
+ assertValidTokenUrl, requestOauthToken, OauthTokenRequestError,
+} from '../oauth';
+
+type CapturedRequest = {
+ headers: http.IncomingHttpHeaders;
+ body: URLSearchParams;
+};
+
+/** Local stand-in for a provider token endpoint */
+class MockTokenEndpoint {
+ requests: Array = [];
+ respondWith: { status: number; body: string; contentType?: string } = {
+ status: 200,
+ body: JSON.stringify({ access_token: 'test-access-token', expires_in: 3600, token_type: 'Bearer' }),
+ };
+
+ private server?: http.Server;
+ url = '';
+
+ async start() {
+ this.server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (chunk) => {
+ raw += chunk;
+ });
+ req.on('end', () => {
+ this.requests.push({ headers: req.headers, body: new URLSearchParams(raw) });
+ res.writeHead(this.respondWith.status, { 'content-type': this.respondWith.contentType ?? 'application/json' });
+ res.end(this.respondWith.body);
+ });
+ });
+ await new Promise((resolve) => {
+ this.server!.listen(0, '127.0.0.1', resolve);
+ });
+ const address = this.server!.address() as import('node:net').AddressInfo;
+ this.url = `http://127.0.0.1:${address.port}/token`;
+ }
+
+ async stop() {
+ await new Promise((resolve) => {
+ if (this.server) this.server.close(() => resolve());
+ else resolve();
+ });
+ }
+}
+
+describe('assertValidTokenUrl', () => {
+ it('accepts https URLs', () => {
+ expect(() => assertValidTokenUrl('https://oauth2.googleapis.com/token')).not.toThrow();
+ });
+ it('accepts plain http for localhost only', () => {
+ expect(() => assertValidTokenUrl('http://localhost:3000/token')).not.toThrow();
+ expect(() => assertValidTokenUrl('http://127.0.0.1:3000/token')).not.toThrow();
+ expect(() => assertValidTokenUrl('http://example.com/token')).toThrow(/https/);
+ });
+ it('rejects non-http(s) and invalid URLs', () => {
+ expect(() => assertValidTokenUrl('ftp://example.com/token')).toThrow();
+ expect(() => assertValidTokenUrl('not a url')).toThrow();
+ });
+});
+
+describe('requestOauthToken', () => {
+ let endpoint: MockTokenEndpoint;
+ afterEach(async () => {
+ await endpoint?.stop();
+ });
+
+ async function startEndpoint() {
+ endpoint = new MockTokenEndpoint();
+ await endpoint.start();
+ }
+
+ it('sends a refresh_token grant with client credentials in the body', async () => {
+ await startEndpoint();
+ const result = await requestOauthToken({
+ tokenUrl: endpoint.url,
+ grantType: 'refresh_token',
+ refreshToken: 'rt-1',
+ clientId: 'client-1',
+ clientSecret: 'secret-1',
+ scope: 'a b',
+ });
+ expect(result.accessToken).toBe('test-access-token');
+ expect(result.expiresInSeconds).toBe(3600);
+ expect(result.tokenType).toBe('Bearer');
+
+ const req = endpoint.requests[0];
+ expect(req.headers['content-type']).toBe('application/x-www-form-urlencoded');
+ expect(req.body.get('grant_type')).toBe('refresh_token');
+ expect(req.body.get('refresh_token')).toBe('rt-1');
+ expect(req.body.get('client_id')).toBe('client-1');
+ expect(req.body.get('client_secret')).toBe('secret-1');
+ expect(req.body.get('scope')).toBe('a b');
+ });
+
+ it('sends client credentials via basic auth when clientAuth=basic', async () => {
+ await startEndpoint();
+ await requestOauthToken({
+ tokenUrl: endpoint.url,
+ grantType: 'client_credentials',
+ clientId: 'client-1',
+ clientSecret: 'secret-1',
+ clientAuth: 'basic',
+ });
+ const req = endpoint.requests[0];
+ const expected = Buffer.from('client-1:secret-1').toString('base64');
+ expect(req.headers.authorization).toBe(`Basic ${expected}`);
+ expect(req.body.get('client_id')).toBeNull();
+ expect(req.body.get('client_secret')).toBeNull();
+ expect(req.body.get('grant_type')).toBe('client_credentials');
+ });
+
+ it('merges extraParams into the body and rejects reserved keys', async () => {
+ await startEndpoint();
+ await requestOauthToken({
+ tokenUrl: endpoint.url,
+ grantType: 'client_credentials',
+ clientId: 'client-1',
+ extraParams: { audience: 'https://api.example.com' },
+ });
+ expect(endpoint.requests[0].body.get('audience')).toBe('https://api.example.com');
+
+ await expect(requestOauthToken({
+ tokenUrl: endpoint.url,
+ grantType: 'client_credentials',
+ clientId: 'client-1',
+ extraParams: { grant_type: 'password' },
+ })).rejects.toThrow(/reserved param/);
+ });
+
+ it('coerces a string expires_in and captures a rotated refresh token', async () => {
+ await startEndpoint();
+ endpoint.respondWith.body = JSON.stringify({
+ access_token: 'at-2', expires_in: '1200', refresh_token: 'rt-2', scope: 'a',
+ });
+ const result = await requestOauthToken({
+ tokenUrl: endpoint.url, grantType: 'refresh_token', refreshToken: 'rt-1', clientId: 'c',
+ });
+ expect(result.expiresInSeconds).toBe(1200);
+ expect(result.refreshToken).toBe('rt-2');
+ expect(result.scope).toBe('a');
+ });
+
+ it('maps error responses to OauthTokenRequestError with the oauth error code', async () => {
+ await startEndpoint();
+ endpoint.respondWith = {
+ status: 400,
+ body: JSON.stringify({ error: 'invalid_grant', error_description: 'Token has been revoked' }),
+ };
+ const err = await requestOauthToken({
+ tokenUrl: endpoint.url, grantType: 'refresh_token', refreshToken: 'rt-x', clientId: 'c',
+ }).catch((e) => e);
+ expect(err).toBeInstanceOf(OauthTokenRequestError);
+ expect(err.details.status).toBe(400);
+ expect(err.details.oauthErrorCode).toBe('invalid_grant');
+ expect(err.message).toContain('invalid_grant');
+ expect(err.message).toContain('Token has been revoked');
+ });
+
+ it('handles providers that return errors with HTTP 200', async () => {
+ await startEndpoint();
+ endpoint.respondWith.body = JSON.stringify({ ok: false, error: 'invalid_refresh_token' });
+ const err = await requestOauthToken({
+ tokenUrl: endpoint.url, grantType: 'refresh_token', refreshToken: 'rt-x', clientId: 'c',
+ }).catch((e) => e);
+ expect(err).toBeInstanceOf(OauthTokenRequestError);
+ expect(err.details.oauthErrorCode).toBe('invalid_refresh_token');
+ });
+
+ it('rejects non-JSON responses', async () => {
+ await startEndpoint();
+ endpoint.respondWith = { status: 200, body: 'login page', contentType: 'text/html' };
+ await expect(requestOauthToken({
+ tokenUrl: endpoint.url, grantType: 'client_credentials', clientId: 'c',
+ })).rejects.toThrow(/non-JSON/);
+ });
+
+ it('reports connection failures without leaking secrets', async () => {
+ const err = await requestOauthToken({
+ // nothing is listening on this port
+ tokenUrl: 'http://127.0.0.1:1/token', grantType: 'refresh_token', refreshToken: 'rt-secret', clientId: 'c',
+ }).catch((e) => e);
+ expect(err).toBeInstanceOf(OauthTokenRequestError);
+ expect(err.message).not.toContain('rt-secret');
+ });
+});
diff --git a/packages/vscode-plugin/src/intellisense-catalog.ts b/packages/vscode-plugin/src/intellisense-catalog.ts
index 618ec64df..37e71be33 100644
--- a/packages/vscode-plugin/src/intellisense-catalog.ts
+++ b/packages/vscode-plugin/src/intellisense-catalog.ts
@@ -187,6 +187,14 @@ export const ROOT_DECORATORS: Array = [
insertText: '@auditIgnorePaths(${1:path})',
isFunction: true,
},
+ {
+ name: 'oauthClient',
+ scope: 'root',
+ summary: 'Defines an OAuth client (app registration) for oauth() items to reference.',
+ documentation: 'Example: `# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)`. Known providers: google, github, microsoft, slack. The id defaults to the provider name; provision a refresh token with `varlock oauth login `.',
+ insertText: '@oauthClient(provider=${1:google}, clientId=$${2:CLIENT_ID}, clientSecret=$${3:CLIENT_SECRET})',
+ isFunction: true,
+ },
];
export const ITEM_DECORATORS: Array = [