Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/oauth-jwt-bearer.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .bumpy/oauth-provider-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
varlock: minor
env-spec-language: patch
---

New @oauthProvider root decorator (with presets for google, github, microsoft, slack) and varlock oauth login/status commands: define an OAuth provider 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
5 changes: 5 additions & 0 deletions .bumpy/oauth-resolver.md
Original file line number Diff line number Diff line change
@@ -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
124 changes: 124 additions & 0 deletions packages/varlock-website/src/content/docs/guides/oauth.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
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
---

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 agent, a stray error report), the damage window is the token's remaining lifetime, not forever.

```env-spec
# @oauthProvider(id=google, preset=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 provider

The [`@oauthProvider`](/reference/root-decorators/#oauthprovider) root decorator holds client config in one place so several items can mint tokens from it. Presets fill in the endpoints and quirks for common providers:

```env-spec
# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)
# @oauthProvider(id=gh, preset=github, clientId=$GH_CLIENT_ID, clientSecret=$GH_CLIENT_SECRET)
```

Available presets: `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. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

GitHub OAuth Apps issue long-lived OAuth tokens and the documented device response has no refresh token, so this setup always reaches toLoginResult() and fails. Refreshable expiring user tokens are a GitHub App feature; the setup instructions and preset notes need to target that app type, or the login implementation must support the non-refreshing OAuth App result.

Technical details
# GitHub setup cannot produce the required refresh token

## Affected sites
- `packages/varlock-website/src/content/docs/guides/oauth.mdx:67` - instructs users to create an OAuth App
- `packages/varlock/src/lib/oauth-presets.ts:51` - attributes user-token expiration to OAuth Apps
- `packages/varlock/src/lib/oauth-login.ts:59` - rejects every result without a refresh token

## Required outcome
- The documented GitHub app type and settings must produce the refresh token required by `oauth()`.

## Provider contract
- GitHub OAuth App device responses contain `access_token`, `token_type`, and `scope`, but no refresh token: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
- Expiring user access tokens and refresh tokens are documented for GitHub Apps: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app

| Microsoft | Register a public client (mobile & desktop) app. The preset 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`.

## 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 preset-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 (the preset sends them).
- **`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)
- [`@oauthProvider` decorator reference](/reference/root-decorators/#oauthprovider)
- [`varlock oauth` CLI reference](/reference/cli/project/#oauth)
- [Caching guide](/guides/caching/) for where token state lives and how to inspect or clear it
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,47 @@ The output directory is a generated artifact: add it to `.gitignore`, and rerun

<div>

## `varlock oauth` ||oauth||

Manages OAuth providers defined with [`@oauthProvider`](/reference/root-decorators/#oauthprovider) and the refresh tokens used by [`oauth()`](/reference/functions/#oauth) items.

```bash
varlock oauth [status|login] [provider-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(<id>, ...)` 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 provider-level `scopes` and preset-required scopes (e.g. `offline_access` for Microsoft).

**Positional arguments:**
- `[provider-id]`: which `@oauthProvider` to log in to (optional when only one is defined)

**Flags:**
- `--flow <device|browser>`: `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 <string>`: 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.

</div>

<div>

## `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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div class="reference-docs">
Expand Down Expand Up @@ -415,6 +415,80 @@ A few other things to know:
</div>
</div>

## OAuth tokens

<div class="reference-docs">
<div>
### `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 [`@oauthProvider`](/reference/root-decorators/#oauthprovider) instance by id, which supplies `tokenUrl`, `clientId`, `clientSecret`, and `clientAuth` so several items can share one client config. Item-level args override provider-level ones.

Options:

- `tokenUrl=S`: the provider's token endpoint (required unless a provider 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 provider 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 provider 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"
# @oauthProvider(id=google, preset=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 provider 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.
:::
</div>
</div>

## Caching

<div class="reference-docs">
Expand Down
Loading
Loading