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 = [