From f5a4841b4d5ff1e2ebbd90790ece5c36aa8bd5ac Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 00:04:13 -0700 Subject: [PATCH 1/7] Add oauth() resolver: refresh_token and client_credentials token exchange Exchanges a long-lived OAuth credential for a short-lived access token at a provider token endpoint. Tokens are cached with the provider-reported expiry inside a forever-TTL entry so rotated refresh tokens survive access-token expiry. Refreshes are serialized cross-process via a new CacheStore.withKeyLock helper (extracted from getOrSet). cache(oauth()) is rejected since oauth() manages its own expiry. --- .bumpy/oauth-resolver.md | 5 + .../src/content/docs/reference/functions.mdx | 55 +++- .../varlock/src/env-graph/lib/resolver.ts | 288 ++++++++++++++++++ .../src/env-graph/test/oauth-resolver.test.ts | 248 +++++++++++++++ packages/varlock/src/lib/cache/cache-store.ts | 19 +- packages/varlock/src/lib/oauth.ts | 205 +++++++++++++ packages/varlock/src/lib/test/oauth.test.ts | 197 ++++++++++++ 7 files changed, 1012 insertions(+), 5 deletions(-) create mode 100644 .bumpy/oauth-resolver.md create mode 100644 packages/varlock/src/env-graph/test/oauth-resolver.test.ts create mode 100644 packages/varlock/src/lib/oauth.ts create mode 100644 packages/varlock/src/lib/test/oauth.test.ts 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/reference/functions.mdx b/packages/varlock-website/src/content/docs/reference/functions.mdx index 2f4b9c2e6..f3c91c57e 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,59 @@ 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. + +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. + +Options: + +- `tokenUrl=S` (required): the provider's token endpoint. Must be https (plain http is allowed for localhost). +- `grant=S` option: `refresh_token` (default) or `client_credentials` +- `refreshToken=R`: the refresh token (required for the `refresh_token` grant). Usually a reference to another item. +- `clientId=R` (required): the OAuth client id +- `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`) + +```env-spec "oauth" +# long-lived credentials, resolved by varlock but never injected +# @internal @sensitive +GOOGLE_REFRESH_TOKEN=op("op://dev/google-oauth/refresh token") +# @internal @sensitive +GOOGLE_CLIENT_SECRET=op("op://dev/google-oauth/client secret") +# @internal +GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com + +# resolves to a fresh short-lived access token +# @sensitive +GOOGLE_ACCESS_TOKEN=oauth(tokenUrl="https://oauth2.googleapis.com/token", refreshToken=$GOOGLE_REFRESH_TOKEN, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) + +# machine-to-machine (client_credentials) grant, e.g. Auth0 +# @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" }) +``` + +A few things to know: + +- **The initial refresh token has to come from somewhere.** Run your provider's authorization flow once (many CLIs and provider consoles can do this) and store the resulting refresh token in your vault. `oauth()` keeps it fresh from there. +- **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 the provider's authorization flow and update the 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/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index 8de38080e..ef0bf2956 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -20,6 +20,11 @@ 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, + OAUTH_GRANT_TYPES, OAUTH_CLIENT_AUTH_METHODS, OAUTH_RESERVED_PARAMS, + type OauthGrantType, type OauthClientAuthMethod, type OauthTokenResult, +} from '../../lib/oauth'; import type { EnvGraphDataSource } from './data-source'; import { DecoratorInstance } from './decorators'; import { getErrorLocation } from './error-location'; @@ -1047,6 +1052,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 +1138,280 @@ 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; + +type OauthCacheEntry = { + accessToken: string; + /** epoch ms when the access token stops being usable (provider-reported) */ + expiresAt: number; + /** latest rotated refresh token, for providers that rotate on every refresh */ + refreshToken?: string; + scope?: string; + lastRefreshedAt: number; + refreshCount: number; +}; + +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: 'object', + objKeyMinLength: 1, + }, + process() { + const tokenUrlResolver = this.objArgs?.tokenUrl; + if (!tokenUrlResolver) throw new SchemaError('tokenUrl is required'); + if (!tokenUrlResolver.isStatic || typeof tokenUrlResolver.staticValue !== 'string') { + throw new SchemaError('tokenUrl must be a static string'); + } + const tokenUrl = tokenUrlResolver.staticValue as string; + try { + assertValidTokenUrl(tokenUrl); + } catch (err) { + throw new SchemaError(err instanceof Error ? err.message : String(err)); + } + + 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; + } + + let clientAuth: OauthClientAuthMethod = '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) { + throw new SchemaError('refreshToken is required for the refresh_token grant'); + } + if (grantType === 'client_credentials' && refreshTokenResolver) { + throw new SchemaError('refreshToken does not apply to the client_credentials grant'); + } + + const clientIdResolver = this.objArgs?.clientId; + if (!clientIdResolver) throw new SchemaError('clientId is required'); + const clientSecretResolver = this.objArgs?.clientSecret; + + const scopesResolver = this.objArgs?.scopes; + + 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']; + for (const argKey of Object.keys(this.objArgs ?? {})) { + if (!knownArgs.includes(argKey)) { + throw new SchemaError(`unknown arg "${argKey}" (expected one of: ${knownArgs.join(', ')})`); + } + } + + return { + tokenUrl, + grantType, + clientAuth, + skewMs, + refreshTokenResolver, + clientIdResolver, + clientSecretResolver, + scopesResolver, + paramsResolver, + }; + }, + async resolve(state) { + const { getResolutionContext } = await import('./resolution-context'); + const ctx = getResolutionContext(); + const cacheStore = ctx?.cacheStore; + + const clientId = await state.clientIdResolver.resolve(); + if (typeof clientId !== 'string' || !clientId) { + throw new ResolutionError('clientId resolved to an empty value'); + } + let clientSecret: string | undefined; + 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: string | undefined; + 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 space-delimited string + scope = resolved.join(' '); + } 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 keyMaterial = [state.tokenUrl, state.grantType, clientId, scope ?? '', configuredRefreshToken ?? ''].join('\n'); + const digest = createHash('sha256').update(keyMaterial).digest('hex').slice(0, 16); + const cacheKey = `oauth:${new URL(state.tokenUrl).hostname}:${digest}`; + + const entryIsFresh = (entry: OauthCacheEntry | undefined): entry is OauthCacheEntry => ( + !!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(cacheKey); + const entry = cached?.value as OauthCacheEntry | undefined; + if (entryIsFresh(entry)) { + ctx?.cacheHits.push({ cacheKey, 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(cacheKey) : undefined; + const entry = cached?.value as OauthCacheEntry | undefined; + if (!ctx?.skipCache && entryIsFresh(entry)) { + ctx?.cacheHits.push({ cacheKey, cachedAt: entry.lastRefreshedAt, expiresAt: entry.expiresAt }); + return entry.accessToken; + } + + let result: OauthTokenResult; + try { + result = await requestOauthToken({ + tokenUrl: state.tokenUrl, + grantType: state.grantType, + clientId, + clientSecret, + clientAuth: state.clientAuth, + // prefer the latest rotated refresh token over the configured bootstrap + refreshToken: entry?.refreshToken || configuredRefreshToken, + scope, + extraParams, + }); + } catch (err) { + if (err instanceof OauthTokenRequestError) { + const tip: Array = []; + if (err.details.oauthErrorCode === 'invalid_grant') { + tip.push('The refresh token is likely expired or revoked - 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: OauthCacheEntry = { + accessToken: result.accessToken, + expiresAt: refreshedAt + ( + result.expiresInSeconds !== undefined ? result.expiresInSeconds * 1000 : OAUTH_FALLBACK_EXPIRES_IN_MS + ), + // keep the previous rotated token when the provider doesn't rotate + refreshToken: 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 + // carries the rotated refresh token; freshness is checked via expiresAt + if (cacheStore) { + await cacheStore.set(cacheKey, newEntry, 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) + if (cacheStore?.withKeyLock) return await cacheStore.withKeyLock(cacheKey, 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 +1451,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..af2637164 --- /dev/null +++ b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts @@ -0,0 +1,248 @@ +/** + * 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 { + 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'; + +/** 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 loadAndResolve(envContent: string, cacheStore?: CacheStoreLike) { + const g = new EnvGraph(); + const source = new DotEnvFileDataSource('.env.schema', { + overrideContents: outdent` + # @defaultRequired=false + # --- + ${envContent} + `, + }); + await g.setRootDataSource(source); + await g.finishLoad(); + if (cacheStore) g._cacheStore = cacheStore; + await g.resolveEnvValues(); + return g; +} + +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(Array.isArray(tip) ? tip.join(' ') : tip).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('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.ts b/packages/varlock/src/lib/oauth.ts new file mode 100644 index 000000000..a9a03cc96 --- /dev/null +++ b/packages/varlock/src/lib/oauth.ts @@ -0,0 +1,205 @@ +/** + * 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. + */ + +export const OAUTH_GRANT_TYPES = ['refresh_token', 'client_credentials'] as const; +export type OauthGrantType = typeof OAUTH_GRANT_TYPES[number]; + +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 a token endpoint URL. Must be https, except localhost is allowed + * over plain http (tests, local identity providers). + */ +export function assertValidTokenUrl(tokenUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(tokenUrl); + } catch { + throw new Error('tokenUrl 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('tokenUrl must use https (plain http is only allowed for localhost)'); + } + throw new Error('tokenUrl must be an http(s) URL'); +} + +export type OauthTokenRequestOpts = { + tokenUrl: string; + grantType: OauthGrantType; + 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; + /** already space-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; +}; + +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(); + body.set('grant_type', opts.grantType); + if (opts.grantType === 'refresh_token') { + if (!opts.refreshToken) throw new OauthTokenRequestError('refresh_token grant requires a refresh token'); + body.set('refresh_token', opts.refreshToken); + } + 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.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'); + }); +}); From 76392a3454d234fee1fed469b915e73c91392ff0 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 11:48:51 -0700 Subject: [PATCH 2/7] Add @oauthProvider decorator, presets, and varlock oauth login/status @oauthProvider(id=..., preset=google|github|microsoft|slack, ...) defines a named provider that oauth() items reference positionally, sharing client config across items. When an item omits refreshToken, the resolver reads a provider-level cache entry provisioned by `varlock oauth login` (device code or PKCE loopback flow); rotated refresh tokens are written back to that shared entry under the provider key lock. `varlock oauth status` (also the bare command) shows providers and provisioning state. Login flows live in lib/oauth-login.ts as executor functions separate from the CLI driver, so a remote proxy can run them later. --- .bumpy/oauth-provider-login.md | 6 + .../content/docs/reference/cli/project.mdx | 41 +++ .../src/content/docs/reference/functions.mdx | 34 +- .../docs/reference/root-decorators.mdx | 28 ++ packages/varlock/src/cli/cli-executable.ts | 2 + .../varlock/src/cli/commands/oauth.command.ts | 314 ++++++++++++++++++ packages/varlock/src/env-graph/index.ts | 1 + .../varlock/src/env-graph/lib/decorators.ts | 164 +++++++++ .../varlock/src/env-graph/lib/env-graph.ts | 4 + .../varlock/src/env-graph/lib/resolver.ts | 197 ++++++++--- .../src/env-graph/test/oauth-resolver.test.ts | 183 +++++++++- packages/varlock/src/lib/oauth-login.ts | 272 +++++++++++++++ packages/varlock/src/lib/oauth-presets.ts | 72 ++++ packages/varlock/src/lib/oauth.ts | 88 ++++- .../varlock/src/lib/test/oauth-login.test.ts | 223 +++++++++++++ .../vscode-plugin/src/intellisense-catalog.ts | 8 + 16 files changed, 1564 insertions(+), 73 deletions(-) create mode 100644 .bumpy/oauth-provider-login.md create mode 100644 packages/varlock/src/cli/commands/oauth.command.ts create mode 100644 packages/varlock/src/lib/oauth-login.ts create mode 100644 packages/varlock/src/lib/oauth-presets.ts create mode 100644 packages/varlock/src/lib/test/oauth-login.test.ts diff --git a/.bumpy/oauth-provider-login.md b/.bumpy/oauth-provider-login.md new file mode 100644 index 000000000..798f72644 --- /dev/null +++ b/.bumpy/oauth-provider-login.md @@ -0,0 +1,6 @@ +--- +varlock: minor +env-spec-language: patch +--- + +New @oauthProvider root decorator (with presets for google, github, microsoft, slack) and varlock oauth login/status commands: define an OAuth provider once, provision a refresh token via a browser or device-code login flow, and mint access tokens from it with oauth() without storing a refresh token anywhere 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..f1e6e74c3 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 providers defined with [`@oauthProvider`](/reference/root-decorators/#oauthprovider) and the refresh tokens used by [`oauth()`](/reference/functions/#oauth) items. + +```bash +varlock oauth [status|login] [provider-id] +``` + +### `varlock oauth login` + +Runs a browser login flow against a provider and stores the resulting refresh token in the encrypted cache. Items using `oauth(, ...)` without an explicit `refreshToken` resolve using this stored token from then on. Requires a persistent (disk) cache. + +The requested scopes default to the union of scopes used by items referencing the provider, plus any provider-level `scopes` and preset-required scopes (e.g. `offline_access` for Microsoft). + +**Positional arguments:** +- `[provider-id]`: which `@oauthProvider` to log in to (optional when only one is defined) + +**Flags:** +- `--flow `: `device` 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 f3c91c57e..e88090e85 100644 --- a/packages/varlock-website/src/content/docs/reference/functions.mdx +++ b/packages/varlock-website/src/content/docs/reference/functions.mdx @@ -425,12 +425,14 @@ Exchanges a long-lived OAuth credential for a short-lived access token by callin Tokens are cached (encrypted, according to your [cache mode](/reference/root-decorators/#cache)) and reused until the provider-reported expiry, so repeated invocations do not hit the token endpoint. When a provider rotates refresh tokens on each use (Google and Slack do), the rotated token is stored in the cache and used for the next refresh automatically; the configured refresh token is just the bootstrap. +An optional first positional arg references an [`@oauthProvider`](/reference/root-decorators/#oauthprovider) instance by id, which supplies `tokenUrl`, `clientId`, `clientSecret`, and `clientAuth` so several items can share one client config. Item-level args override provider-level ones. + Options: -- `tokenUrl=S` (required): the provider's token endpoint. Must be https (plain http is allowed for localhost). +- `tokenUrl=S`: the provider's token endpoint (required unless a provider instance supplies it). Must be https (plain http is allowed for localhost). - `grant=S` option: `refresh_token` (default) or `client_credentials` -- `refreshToken=R`: the refresh token (required for the `refresh_token` grant). Usually a reference to another item. -- `clientId=R` (required): the OAuth client id +- `refreshToken=R`: the refresh token, usually a reference to another item. Required for the `refresh_token` grant unless a provider instance is referenced, in which case omitting it means "use the login-provisioned token" (see below). +- `clientId=R`: the OAuth client id (required unless a provider instance supplies it) - `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 @@ -438,29 +440,35 @@ Options: - `skew=N` option: refresh this long before the reported expiry, in seconds or a duration string (default: `60s`) ```env-spec "oauth" -# long-lived credentials, resolved by varlock but never injected -# @internal @sensitive -GOOGLE_REFRESH_TOKEN=op("op://dev/google-oauth/refresh token") -# @internal @sensitive -GOOGLE_CLIENT_SECRET=op("op://dev/google-oauth/client secret") +# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +# --- # @internal GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com +# @internal @sensitive +GOOGLE_CLIENT_SECRET=op("op://dev/google-oauth/client secret") + +# no refreshToken: provision once with `varlock oauth login google` +# @sensitive +DRIVE_TOKEN=oauth(google, scopes="https://www.googleapis.com/auth/drive.readonly") -# resolves to a fresh short-lived access token +# 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 -GOOGLE_ACCESS_TOKEN=oauth(tokenUrl="https://oauth2.googleapis.com/token", refreshToken=$GOOGLE_REFRESH_TOKEN, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +SHEETS_TOKEN=oauth(google, refreshToken=$GOOGLE_REFRESH_TOKEN, scopes="https://www.googleapis.com/auth/spreadsheets.readonly") -# machine-to-machine (client_credentials) grant, e.g. Auth0 +# fully inline, no provider instance # @sensitive API_TOKEN=oauth(tokenUrl="https://myorg.auth0.com/oauth/token", grant="client_credentials", clientId=$AUTH0_CLIENT_ID, clientSecret=$AUTH0_CLIENT_SECRET, params={ audience="https://api.myorg.com" }) ``` A few things to know: -- **The initial refresh token has to come from somewhere.** Run your provider's authorization flow once (many CLIs and provider consoles can do this) and store the resulting refresh token in your vault. `oauth()` keeps it fresh from there. +- **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 the provider's authorization flow and update the stored token. +- **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. 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..2b9161566 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() ```
+
+### `@oauthProvider()` +**Arg types:** `(id?: string, preset?: string, tokenUrl?: string, authorizationUrl?: string, deviceAuthorizationUrl?: string, clientAuth?: string, clientId, clientSecret?, scopes?)` + +Defines a named OAuth provider that [`oauth()`](/reference/functions/#oauth) items reference by id, so client config is written once and shared by every token minted from it. Can be declared multiple times with different ids. + +- `id`: name used by `oauth(, ...)` and `varlock oauth login ` (defaults to `_default`) +- `preset`: fills in endpoints and quirks for a known provider: `google`, `github`, `microsoft`, or `slack`. Explicit args override preset values. +- `tokenUrl`: token endpoint (required unless the preset provides 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 "@oauthProvider" +# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +# --- +# @internal +GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com +# @internal @sensitive +GOOGLE_CLIENT_SECRET=varlock(local:abc123...) +# @sensitive +DRIVE_TOKEN=oauth(google, scopes="https://www.googleapis.com/auth/drive.readonly") +``` + +Items referencing a provider 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 provider. +
+
## Code generation ||code-generation|| 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..d828c5365 --- /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 { + buildOauthProviderCacheKey, formatOauthScopesForDisplay, + type OauthProviderCacheEntry, +} 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 { OauthProviderInstanceRecord } 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.oauthProviders).length) { + throw new CliExitError('No oauth providers are defined in your schema', { + suggestion: 'Define one with a root decorator, e.g. `# @oauthProvider(id=google, preset=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, +): OauthProviderInstanceRecord { + const providers = envGraph.oauthProviders; + const ids = Object.keys(providers); + if (requestedId) { + const record = providers[requestedId]; + if (!record) { + throw new CliExitError(`Unknown oauth provider "${requestedId}"`, { + suggestion: `Defined providers: ${ids.join(', ')}`, + }); + } + return record; + } + if (ids.length === 1) return providers[ids[0]]; + throw new CliExitError('Multiple oauth providers are defined - specify which one to log in to', { + suggestion: `e.g. \`varlock oauth login ${ids[0]}\` (defined providers: ${ids.join(', ')})`, + }); +} + +/** union of provider-level scopes, preset-required scopes, and every login-provisioned item's scopes */ +async function collectLoginScopes( + envGraph: Awaited>, + record: OauthProviderInstanceRecord, +): 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: { + provider: { + type: 'positional', + required: false, + description: 'The @oauthProvider id to log in to (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 provider defined) + varlock oauth login google # log in to a specific provider + 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.provider); + if (!record.resolved) { + throw new CliExitError(`oauth provider "${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 @oauthProvider', + }); + } + if (flow === 'browser' && !record.authorizationUrl) { + throw new CliExitError(`Provider "${record.id}" has no authorization endpoint configured`, { + suggestion: [ + 'Set authorizationUrl on the @oauthProvider (or use a preset 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 provider ${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 providerCacheKey = buildOauthProviderCacheKey({ + tokenUrl: record.tokenUrl, + clientId: record.resolved.clientId, + }); + const entry: OauthProviderCacheEntry = { + refreshToken: loginResult.refreshToken, + grantedScope: loginResult.grantedScope ?? scope, + updatedAt: Date.now(), + source: 'login', + }; + const stored = await store.set(providerCacheKey, 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 providers 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.oauthProviders)) { + console.log(`${ansis.bold(record.id)}${record.presetName ? ansis.gray(` (preset: ${record.presetName})`) : ''}`); + 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 providerCacheKey = buildOauthProviderCacheKey({ + tokenUrl: record.tokenUrl, + clientId: record.resolved.clientId, + }); + const cached = await store.get(providerCacheKey); + const entry = cached?.value as OauthProviderCacheEntry | 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 providers and login-provisioned refresh tokens', + subCommands: { + login: loginCommand, + status: statusCommand, + }, + examples: ` +Provision and inspect refresh tokens for @oauthProvider instances used by oauth(). + +Examples: + varlock oauth status # show providers and provisioning state + varlock oauth login # run the login flow (single provider defined) + varlock oauth login google # log in to a specific provider +`.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..ece0d3598 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 OauthProviderInstanceRecord } 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..4366f711a 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_PROVIDER_PRESETS, OAUTH_PRESET_NAMES } from '../../lib/oauth-presets'; export abstract class DecoratorInstance { @@ -310,6 +314,41 @@ function parseEnvBulkValues( } // ~ Root decorators ---------------------------------------- +/** + * A registered `@oauthProvider(...)` instance, stored on `EnvGraph.oauthProviders` + * keyed by id. 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 OauthProviderInstanceRecord = { + id: string; + presetName?: 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 +713,131 @@ export const builtInRootDecorators: Array> = [ useFnArgsResolver: true, process: (argsVal) => validateProxyFunctionArgs(argsVal), }, + { + name: 'oauthProvider', + isFunction: true, + process(argsVal) { + const graph = argsVal.dataSource!.graph!; + if (argsVal.arrArgs?.length) { + throw new SchemaError('@oauthProvider expects only key-value args, e.g. `@oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID)`'); + } + const objArgs = argsVal.objArgs ?? {}; + + const knownArgs = [ + 'id', + 'preset', + 'tokenUrl', + 'authorizationUrl', + 'deviceAuthorizationUrl', + 'clientAuth', + 'clientId', + 'clientSecret', + 'scopes', + ]; + for (const argKey of Object.keys(objArgs)) { + if (!knownArgs.includes(argKey)) { + throw new SchemaError(`@oauthProvider: 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(`@oauthProvider: ${argKey} must be a static string`); + } + return r.staticValue; + }; + + const id = getStaticString('id') ?? '_default'; + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(id)) { + throw new SchemaError('@oauthProvider: id must start with a letter and contain only letters, numbers, dashes, underscores'); + } + if (graph.oauthProviders[id]) { + throw new SchemaError(`@oauthProvider: provider id "${id}" is already defined`); + } + + const presetName = getStaticString('preset'); + let preset; + if (presetName) { + preset = OAUTH_PROVIDER_PRESETS[presetName]; + if (!preset) { + throw new SchemaError(`@oauthProvider: unknown preset "${presetName}" (known presets: ${OAUTH_PRESET_NAMES.join(', ')})`); + } + } + + const tokenUrl = getStaticString('tokenUrl') ?? preset?.tokenUrl; + if (!tokenUrl) { + throw new SchemaError('@oauthProvider: tokenUrl is required (or use a preset that provides one)'); + } + const authorizationUrl = getStaticString('authorizationUrl') ?? preset?.authorizationUrl; + const deviceAuthorizationUrl = getStaticString('deviceAuthorizationUrl') ?? preset?.deviceAuthorizationUrl; + try { + assertValidTokenUrl(tokenUrl, 'tokenUrl'); + if (authorizationUrl) assertValidTokenUrl(authorizationUrl, 'authorizationUrl'); + if (deviceAuthorizationUrl) assertValidTokenUrl(deviceAuthorizationUrl, 'deviceAuthorizationUrl'); + } catch (err) { + throw new SchemaError(`@oauthProvider: ${err instanceof Error ? err.message : err}`); + } + + const clientAuthArg = getStaticString('clientAuth'); + if (clientAuthArg && !(OAUTH_CLIENT_AUTH_METHODS as ReadonlyArray).includes(clientAuthArg)) { + throw new SchemaError(`@oauthProvider: clientAuth must be one of: ${OAUTH_CLIENT_AUTH_METHODS.join(', ')}`); + } + const clientAuth = (clientAuthArg ?? preset?.clientAuth ?? 'body') as OauthClientAuthMethod; + + if (!objArgs.clientId) { + throw new SchemaError('@oauthProvider: clientId is required'); + } + + const record: OauthProviderInstanceRecord = { + id, + presetName, + tokenUrl, + authorizationUrl, + deviceAuthorizationUrl, + clientAuth, + extraAuthParams: preset?.extraAuthParams ?? {}, + requiredLoginScopes: preset?.requiredLoginScopes ?? [], + scopesDelimiter: preset?.scopesDelimiter ?? ' ', + appSetupUrl: preset?.appSetupUrl, + notes: preset?.notes, + clientIdResolver: objArgs.clientId, + clientSecretResolver: objArgs.clientSecret, + scopesResolver: objArgs.scopes, + argsResolver: argsVal, + usedBy: [], + }; + graph.oauthProviders[id] = record; + return record; + }, + async execute(record: OauthProviderInstanceRecord) { + const clientId = await record.clientIdResolver.resolve(); + if (typeof clientId !== 'string' || !clientId) { + throw new ResolutionError('@oauthProvider: 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('@oauthProvider: 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('@oauthProvider: 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..fcc82fd7e 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 OauthProviderInstanceRecord, type RootDecoratorDef, } from './decorators'; import { getErrorLocation } from './error-location'; @@ -113,6 +114,9 @@ export class EnvGraph { basePath?: string; + /** registered `@oauthProvider(...)` instances, keyed by id */ + oauthProviders: 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 ef0bf2956..1bc5d8a79 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -22,11 +22,13 @@ import { import { assertValidCacheKey, hasInvalidCacheKeyChars, MAX_CACHE_KEY_LENGTH } from '../../lib/cache/cache-store'; import { assertValidTokenUrl, requestOauthToken, OauthTokenRequestError, + buildOauthItemCacheKey, buildOauthProviderCacheKey, OAUTH_GRANT_TYPES, OAUTH_CLIENT_AUTH_METHODS, OAUTH_RESERVED_PARAMS, type OauthGrantType, type OauthClientAuthMethod, type OauthTokenResult, + type OauthItemCacheEntry, type OauthProviderCacheEntry, } from '../../lib/oauth'; import type { EnvGraphDataSource } from './data-source'; -import { DecoratorInstance } from './decorators'; +import { DecoratorInstance, type OauthProviderInstanceRecord } from './decorators'; import { getErrorLocation } from './error-location'; import { isBuiltinVar } from './builtin-vars'; @@ -228,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 @@ -1145,17 +1152,6 @@ 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; -type OauthCacheEntry = { - accessToken: string; - /** epoch ms when the access token stops being usable (provider-reported) */ - expiresAt: number; - /** latest rotated refresh token, for providers that rotate on every refresh */ - refreshToken?: string; - scope?: string; - lastRefreshedAt: number; - refreshCount: number; -}; - let warnedOauthRotationNotPersisted = false; export const OauthResolver: typeof Resolver = createResolver({ @@ -1165,16 +1161,41 @@ export const OauthResolver: typeof Resolver = createResolver({ inferredType: 'string', impliesSensitive: true, argsSchema: { - type: 'object', - objKeyMinLength: 1, + type: 'mixed', + arrayMaxLength: 1, }, process() { + // optional positional arg references an @oauthProvider instance by id + let provider: OauthProviderInstanceRecord | undefined; + const providerRefResolver = this.arrArgs?.[0]; + if (providerRefResolver) { + if (!providerRefResolver.isStatic || typeof providerRefResolver.staticValue !== 'string') { + throw new SchemaError('provider reference must be a static id, e.g. `oauth(google, ...)`'); + } + const providerId = providerRefResolver.staticValue as string; + const knownIds = Object.keys(this.envGraph?.oauthProviders ?? {}); + provider = this.envGraph?.oauthProviders[providerId]; + if (!provider) { + throw new SchemaError( + `unknown oauth provider "${providerId}"${knownIds.length ? ` (defined providers: ${knownIds.join(', ')})` : ''}`, + { tip: 'Define it with a root decorator, e.g. `# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID)`' }, + ); + } + // wire the provider'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 provider.argsResolver.deps) { + this.addDep(depKey); + } + } + const tokenUrlResolver = this.objArgs?.tokenUrl; - if (!tokenUrlResolver) throw new SchemaError('tokenUrl is required'); - if (!tokenUrlResolver.isStatic || typeof tokenUrlResolver.staticValue !== 'string') { + if (tokenUrlResolver && (!tokenUrlResolver.isStatic || typeof tokenUrlResolver.staticValue !== 'string')) { throw new SchemaError('tokenUrl must be a static string'); } - const tokenUrl = tokenUrlResolver.staticValue as string; + const tokenUrl = (tokenUrlResolver?.staticValue as string | undefined) ?? provider?.tokenUrl; + if (!tokenUrl) { + throw new SchemaError('tokenUrl is required (or reference an @oauthProvider instance that provides one)'); + } try { assertValidTokenUrl(tokenUrl); } catch (err) { @@ -1193,7 +1214,7 @@ export const OauthResolver: typeof Resolver = createResolver({ grantType = grantResolver.staticValue as OauthGrantType; } - let clientAuth: OauthClientAuthMethod = 'body'; + let clientAuth: OauthClientAuthMethod = provider?.clientAuth ?? 'body'; const clientAuthResolver = this.objArgs?.clientAuth; if (clientAuthResolver) { if (!clientAuthResolver.isStatic || typeof clientAuthResolver.staticValue !== 'string') { @@ -1228,19 +1249,33 @@ export const OauthResolver: typeof Resolver = createResolver({ } const refreshTokenResolver = this.objArgs?.refreshToken; - if (grantType === 'refresh_token' && !refreshTokenResolver) { - throw new SchemaError('refreshToken is required for the refresh_token grant'); + if (grantType === 'refresh_token' && !refreshTokenResolver && !provider) { + throw new SchemaError('refreshToken is required for the refresh_token grant', { + tip: 'Or reference an @oauthProvider instance and provision a refresh token with `varlock oauth login`', + }); } if (grantType === 'client_credentials' && refreshTokenResolver) { throw new SchemaError('refreshToken does not apply to the client_credentials grant'); } const clientIdResolver = this.objArgs?.clientId; - if (!clientIdResolver) throw new SchemaError('clientId is required'); + if (!clientIdResolver && !provider) throw new SchemaError('clientId is required'); const clientSecretResolver = this.objArgs?.clientSecret; const scopesResolver = this.objArgs?.scopes; + // register usage on the provider record so `varlock oauth login` can + // compute the union of scopes needed at provisioning time + const itemKey = this.parentItemKey; + if (provider && itemKey) { + provider.usedBy.push({ + itemKey, + grantType, + scopesResolver, + hasOwnRefreshToken: !!refreshTokenResolver, + }); + } + const paramsResolver = this.objArgs?.params; if (paramsResolver) { if (!(paramsResolver instanceof ObjectLiteralResolver)) { @@ -1261,6 +1296,7 @@ export const OauthResolver: typeof Resolver = createResolver({ } return { + provider, tokenUrl, grantType, clientAuth, @@ -1276,12 +1312,25 @@ export const OauthResolver: typeof Resolver = createResolver({ const { getResolutionContext } = await import('./resolution-context'); const ctx = getResolutionContext(); const cacheStore = ctx?.cacheStore; + const { provider } = state; - const clientId = await state.clientIdResolver.resolve(); - if (typeof clientId !== 'string' || !clientId) { - throw new ResolutionError('clientId resolved to an empty value'); + // provider dynamic args (client credentials) are resolved once during + // the decorator's execute() at load time + if (provider && !provider.resolved) { + throw new ResolutionError(`@oauthProvider "${provider.id}" failed to initialize`); } - let clientSecret: string | undefined; + + let clientId: string; + if (state.clientIdResolver) { + const resolved = await state.clientIdResolver.resolve(); + if (typeof resolved !== 'string' || !resolved) { + throw new ResolutionError('clientId resolved to an empty value'); + } + clientId = resolved; + } else { + clientId = provider!.resolved!.clientId; + } + let clientSecret = provider?.resolved?.clientSecret; if (state.clientSecretResolver) { const resolved = await state.clientSecretResolver.resolve(); if (typeof resolved !== 'string' || !resolved) { @@ -1297,14 +1346,14 @@ export const OauthResolver: typeof Resolver = createResolver({ } configuredRefreshToken = resolved; } - let scope: string | undefined; + let scope = provider?.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 space-delimited string - scope = resolved.join(' '); + // OAuth wire format is a single delimiter-joined string (space for most providers) + scope = resolved.join(provider?.scopesDelimiter ?? ' '); } else { throw new ResolutionError('scopes must resolve to a string or an array of strings'); } @@ -1324,20 +1373,38 @@ export const OauthResolver: typeof Resolver = createResolver({ // 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 keyMaterial = [state.tokenUrl, state.grantType, clientId, scope ?? '', configuredRefreshToken ?? ''].join('\n'); - const digest = createHash('sha256').update(keyMaterial).digest('hex').slice(0, 16); - const cacheKey = `oauth:${new URL(state.tokenUrl).hostname}:${digest}`; + const itemCacheKey = buildOauthItemCacheKey({ + tokenUrl: state.tokenUrl, + grantType: state.grantType, + clientId, + scope, + refreshToken: configuredRefreshToken, + }); - const entryIsFresh = (entry: OauthCacheEntry | undefined): entry is OauthCacheEntry => ( + // no item-level refresh token + refresh_token grant means the refresh token + // was provisioned via `varlock oauth login` and lives in a provider-level + // cache entry shared by every item using this provider + const usesProviderToken = state.grantType === 'refresh_token' && !configuredRefreshToken; + const providerCacheKey = usesProviderToken + ? buildOauthProviderCacheKey({ tokenUrl: state.tokenUrl, clientId }) + : undefined; + const loginTip = `Run \`varlock oauth login${provider && provider.id !== '_default' ? ` ${provider.id}` : ''}\` to provision a refresh token`; + if (usesProviderToken && !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(cacheKey); - const entry = cached?.value as OauthCacheEntry | undefined; + const cached = await cacheStore.get(itemCacheKey); + const entry = cached?.value as OauthItemCacheEntry | undefined; if (entryIsFresh(entry)) { - ctx?.cacheHits.push({ cacheKey, cachedAt: entry.lastRefreshedAt, expiresAt: entry.expiresAt }); + ctx?.cacheHits.push({ cacheKey: itemCacheKey, cachedAt: entry.lastRefreshedAt, expiresAt: entry.expiresAt }); return entry.accessToken; } } @@ -1345,13 +1412,25 @@ export const OauthResolver: typeof Resolver = createResolver({ 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(cacheKey) : undefined; - const entry = cached?.value as OauthCacheEntry | undefined; + const cached = cacheStore ? await cacheStore.get(itemCacheKey) : undefined; + const entry = cached?.value as OauthItemCacheEntry | undefined; if (!ctx?.skipCache && entryIsFresh(entry)) { - ctx?.cacheHits.push({ cacheKey, cachedAt: entry.lastRefreshedAt, expiresAt: entry.expiresAt }); + 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 + // provider entry, item-configured ones rotate within the item entry + let providerEntry: OauthProviderCacheEntry | undefined; + let refreshToken = entry?.refreshToken || configuredRefreshToken; + if (usesProviderToken) { + providerEntry = (await cacheStore!.get(providerCacheKey!))?.value as OauthProviderCacheEntry | undefined; + if (!providerEntry?.refreshToken) { + throw new ResolutionError('no refresh token has been provisioned for this oauth provider', { tip: loginTip }); + } + refreshToken = providerEntry.refreshToken; + } + let result: OauthTokenResult; try { result = await requestOauthToken({ @@ -1360,8 +1439,7 @@ export const OauthResolver: typeof Resolver = createResolver({ clientId, clientSecret, clientAuth: state.clientAuth, - // prefer the latest rotated refresh token over the configured bootstrap - refreshToken: entry?.refreshToken || configuredRefreshToken, + refreshToken, scope, extraParams, }); @@ -1369,9 +1447,14 @@ export const OauthResolver: typeof Resolver = createResolver({ if (err instanceof OauthTokenRequestError) { const tip: Array = []; if (err.details.oauthErrorCode === 'invalid_grant') { - tip.push('The refresh token is likely expired or revoked - 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'); + tip.push('The refresh token is likely expired or revoked'); + if (usesProviderToken) { + 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); @@ -1380,21 +1463,32 @@ export const OauthResolver: typeof Resolver = createResolver({ } const refreshedAt = Date.now(); - const newEntry: OauthCacheEntry = { + const newEntry: OauthItemCacheEntry = { accessToken: result.accessToken, expiresAt: refreshedAt + ( result.expiresInSeconds !== undefined ? result.expiresInSeconds * 1000 : OAUTH_FALLBACK_EXPIRES_IN_MS ), - // keep the previous rotated token when the provider doesn't rotate - refreshToken: result.refreshToken ?? entry?.refreshToken, + // rotated tokens are stored in the item entry only when the refresh + // token is item-configured; login-provisioned rotation goes to the + // shared provider entry below + refreshToken: usesProviderToken ? undefined : (result.refreshToken ?? entry?.refreshToken), scope: result.scope ?? scope, lastRefreshedAt: refreshedAt, refreshCount: (entry?.refreshCount ?? 0) + 1, }; // entry TTL is forever because it must outlive the access token - it - // carries the rotated refresh token; freshness is checked via expiresAt + // can carry a rotated refresh token; freshness is checked via expiresAt if (cacheStore) { - await cacheStore.set(cacheKey, newEntry, TTL_FOREVER); + await cacheStore.set(itemCacheKey, newEntry, TTL_FOREVER); + if (usesProviderToken && result.refreshToken) { + const updatedProviderEntry: OauthProviderCacheEntry = { + refreshToken: result.refreshToken, + grantedScope: providerEntry?.grantedScope, + updatedAt: refreshedAt, + source: 'rotation', + }; + await cacheStore.set(providerCacheKey!, updatedProviderEntry, TTL_FOREVER); + } } else if ( result.refreshToken && result.refreshToken !== configuredRefreshToken && !warnedOauthRotationNotPersisted ) { @@ -1406,8 +1500,11 @@ export const OauthResolver: typeof Resolver = createResolver({ }; // serialize refreshes across processes when the store supports locking, so - // parallel invocations share one token exchange (rotation makes this matter) - if (cacheStore?.withKeyLock) return await cacheStore.withKeyLock(cacheKey, doRefresh); + // parallel invocations share one token exchange (rotation makes this matter). + // login-provisioned refreshes lock on the shared provider key since they + // read and rotate the provider-level refresh token. + const lockKey = providerCacheKey ?? itemCacheKey; + if (cacheStore?.withKeyLock) return await cacheStore.withKeyLock(lockKey, doRefresh); return await doRefresh(); }, }); diff --git a/packages/varlock/src/env-graph/test/oauth-resolver.test.ts b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts index af2637164..d1ef33c48 100644 --- a/packages/varlock/src/env-graph/test/oauth-resolver.test.ts +++ b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts @@ -11,6 +11,8 @@ import { outdent } from 'outdent'; import { DotEnvFileDataSource, EnvGraph } from '../index'; import { InMemoryCacheStore } from '../../lib/cache'; import type { CacheStoreLike } from '../../lib/cache/cache-store'; +import { buildOauthProviderCacheKey, type OauthProviderCacheEntry } from '../../lib/oauth'; +import { TTL_FOREVER } from '../../lib/cache/ttl-parser'; /** Minimal token endpoint that issues sequential tokens and records requests */ class MockTokenEndpoint { @@ -52,22 +54,27 @@ class MockTokenEndpoint { } } -async function loadAndResolve(envContent: string, cacheStore?: CacheStoreLike) { +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); - await g.finishLoad(); 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 () => { @@ -130,7 +137,7 @@ describe('oauth()', () => { const g = await loadAndResolve(refreshGrantSchema()); expect(g.configSchema.TOKEN.resolutionError?.message).toContain('invalid_grant'); const tip = g.configSchema.TOKEN.resolutionError?.more?.tip; - expect(Array.isArray(tip) ? tip.join(' ') : tip).toContain('re-provision'); + expect(String(tip).toLowerCase()).toContain('re-provision'); }); }); @@ -199,6 +206,176 @@ describe('oauth()', () => { }); }); + describe('@oauthProvider instances', () => { + function providerHeader(extraArgs = '') { + return `# @oauthProvider(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 = buildOauthProviderCacheKey({ tokenUrl: endpoint.url, clientId: 'client-1' }); + const entry: OauthProviderCacheEntry = { + 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 OauthProviderCacheEntry; + 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.oauthProviders.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 preset endpoints and clientAuth, with explicit args overriding', async () => { + const g = await loadAndResolveWithHeader( + // tokenUrl overrides the preset so resolution hits the mock endpoint + `# @oauthProvider(id=goog, preset=google, tokenUrl="${endpoint.url}", clientId=$CLIENT_ID, clientSecret=$CLIENT_SECRET)`, + outdent` + ${clientItems} + # @internal @sensitive + RT=rt-1 + TOKEN=oauth(goog, refreshToken=$RT) + `, + ); + expect(g.configSchema.TOKEN.errors).toEqual([]); + const record = g.oauthProviders.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('rejects unknown provider 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 provider "nope".*test/); + }); + + it('rejects duplicate provider ids and unknown presets/args', async () => { + const dupG = await loadAndResolveWithHeader(outdent` + # @oauthProvider(id=test, tokenUrl="${endpoint.url}", clientId="c") + # @oauthProvider(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( + '# @oauthProvider(id=x, preset=bogus, clientId="c")', + 'A=1', + ); + expect(presetG.rootDataSource!.schemaErrors.some((e) => e.message.includes('unknown preset'))).toBe(true); + + const argG = await loadAndResolveWithHeader( + `# @oauthProvider(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('schema validation', () => { async function expectSchemaError(envContent: string, messageMatch: RegExp) { const g = await loadAndResolve(envContent); 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-presets.ts b/packages/varlock/src/lib/oauth-presets.ts new file mode 100644 index 000000000..aec92d65b --- /dev/null +++ b/packages/varlock/src/lib/oauth-presets.ts @@ -0,0 +1,72 @@ +/** + * Data-driven presets for well-known OAuth providers, used by the + * `@oauthProvider` root decorator. A preset 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 OauthProviderPreset = { + /** 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_PROVIDER_PRESETS: 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_PRESET_NAMES = Object.keys(OAUTH_PROVIDER_PRESETS); diff --git a/packages/varlock/src/lib/oauth.ts b/packages/varlock/src/lib/oauth.ts index a9a03cc96..86bdcc348 100644 --- a/packages/varlock/src/lib/oauth.ts +++ b/packages/varlock/src/lib/oauth.ts @@ -9,9 +9,16 @@ * 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'] as const; export type OauthGrantType = typeof OAUTH_GRANT_TYPES[number]; +export const OAUTH_DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; +/** 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]; @@ -39,35 +46,41 @@ export class OauthTokenRequestError extends Error { } /** - * Validates a token endpoint URL. Must be https, except localhost is allowed + * Validates an OAuth endpoint URL. Must be https, except localhost is allowed * over plain http (tests, local identity providers). */ -export function assertValidTokenUrl(tokenUrl: string): URL { +export function assertValidTokenUrl(tokenUrl: string, label = 'tokenUrl'): URL { let parsed: URL; try { parsed = new URL(tokenUrl); } catch { - throw new Error('tokenUrl must be a valid URL'); + 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('tokenUrl must use https (plain http is only allowed for localhost)'); + throw new Error(`${label} must use https (plain http is only allowed for localhost)`); } - throw new Error('tokenUrl must be an http(s) URL'); + throw new Error(`${label} must be an http(s) URL`); } export type OauthTokenRequestOpts = { tokenUrl: string; - grantType: OauthGrantType; + 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; - /** already space-joined per the OAuth wire format */ + /** required for the authorization_code grant */ + code?: string; + redirectUri?: string; + codeVerifier?: string; + /** required for the device_code grant */ + deviceCode?: string; + /** already delimiter-joined per the OAuth wire format */ scope?: string; /** additional form body params (e.g. audience, resource) */ extraParams?: Record; @@ -84,6 +97,57 @@ export type OauthTokenResult = { 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 OauthProviderCacheEntry = { + 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; + clientId: string; + scope?: string; + /** the CONFIGURED bootstrap refresh token (not a rotated one); empty for login-provisioned */ + refreshToken?: string; +}): string { + const keyMaterial = [parts.tokenUrl, parts.grantType, parts.clientId, parts.scope ?? '', parts.refreshToken ?? ''].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 buildOauthProviderCacheKey(parts: { tokenUrl: string; clientId: string }): string { + const keyMaterial = [parts.tokenUrl, parts.clientId].join('\n'); + const digest = createHash('sha256').update(keyMaterial).digest('hex').slice(0, 16); + 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; } @@ -114,6 +178,16 @@ export async function requestOauthToken(opts: OauthTokenRequestOpts): Promise = []; + 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/vscode-plugin/src/intellisense-catalog.ts b/packages/vscode-plugin/src/intellisense-catalog.ts index 618ec64df..83ed94178 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: 'oauthProvider', + scope: 'root', + summary: 'Defines a named OAuth provider for oauth() items to reference.', + documentation: 'Example: `# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)`. Presets: google, github, microsoft, slack. Provision a refresh token with `varlock oauth login `.', + insertText: '@oauthProvider(id=${1:google}, preset=${2:google}, clientId=$${3:CLIENT_ID}, clientSecret=$${4:CLIENT_SECRET})', + isFunction: true, + }, ]; export const ITEM_DECORATORS: Array = [ From f81030dd096a5a69d70964351c1fd2df68aa0c5f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 14:43:04 -0700 Subject: [PATCH 3/7] Add jwt_bearer grant to oauth(): RS256 assertions for service accounts Signs a short-lived RS256 assertion (RFC 7523) from a Google-style service account key JSON (serviceAccountKey) or a raw PEM key + issuer, and exchanges it at the token endpoint. tokenUrl falls back to the key file's token_uri; subject supports impersonation; audience overrides the aud claim. No refresh token exists in this flow, so no rotation or provider-entry machinery applies; the cache just avoids re-minting. RSA keys only for now (ES256 needs DER-to-JOSE conversion). --- .bumpy/oauth-jwt-bearer.md | 5 + .../src/content/docs/reference/functions.mdx | 19 +- .../varlock/src/env-graph/lib/resolver.ts | 196 ++++++++++++++---- .../src/env-graph/test/oauth-resolver.test.ts | 90 ++++++++ packages/varlock/src/lib/oauth-jwt.ts | 100 +++++++++ packages/varlock/src/lib/oauth.ts | 18 +- .../varlock/src/lib/test/oauth-jwt.test.ts | 94 +++++++++ 7 files changed, 479 insertions(+), 43 deletions(-) create mode 100644 .bumpy/oauth-jwt-bearer.md create mode 100644 packages/varlock/src/lib/oauth-jwt.ts create mode 100644 packages/varlock/src/lib/test/oauth-jwt.test.ts 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/packages/varlock-website/src/content/docs/reference/functions.mdx b/packages/varlock-website/src/content/docs/reference/functions.mdx index e88090e85..20672bcc9 100644 --- a/packages/varlock-website/src/content/docs/reference/functions.mdx +++ b/packages/varlock-website/src/content/docs/reference/functions.mdx @@ -429,16 +429,23 @@ An optional first positional arg references an [`@oauthProvider`](/reference/roo Options: -- `tokenUrl=S`: the provider's token endpoint (required unless a provider instance supplies it). Must be https (plain http is allowed for localhost). -- `grant=S` option: `refresh_token` (default) or `client_credentials` +- `tokenUrl=S`: the provider's token endpoint (required unless a provider instance or service account key supplies it). Must be https (plain http is allowed for localhost). +- `grant=S` option: `refresh_token` (default), `client_credentials`, or `jwt_bearer` - `refreshToken=R`: the refresh token, usually a reference to another item. Required for the `refresh_token` grant unless a provider instance is referenced, in which case omitting it means "use the login-provisioned token" (see below). -- `clientId=R`: the OAuth client id (required unless a provider instance supplies it) +- `clientId=R`: the OAuth client id (required unless a provider instance supplies it; optional for `jwt_bearer`) - `clientSecret=R` option: the OAuth client secret. Not needed for public (PKCE) clients. - `clientAuth=S` option: how client credentials are sent, `body` (default) or `basic` for HTTP basic auth. Some providers (e.g. Notion) require `basic`. - `scopes=R` option: a space-delimited string or an array of scope strings - `params={...}` option: extra form params for the token request, e.g. `params={ audience="..." }` for Auth0 - `skew=N` option: refresh this long before the reported expiry, in seconds or a duration string (default: `60s`) +`jwt_bearer`-only options (RFC 7523, e.g. Google service accounts): instead of a stored credential, varlock signs a short-lived RS256 assertion with a private key and exchanges it for an access token. + +- `serviceAccountKey=R`: a Google-style service account key JSON (supplies the signing key, issuer, and token endpoint) +- `privateKey=R` + `issuer=R`: raw PEM key and `iss` claim, for non-Google providers +- `subject=R` option: `sub` claim, for providers that support impersonation (e.g. Google domain-wide delegation) +- `audience=S` option: `aud` claim override (defaults to the token endpoint) + ```env-spec "oauth" # @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) # --- @@ -460,6 +467,12 @@ SHEETS_TOKEN=oauth(google, refreshToken=$GOOGLE_REFRESH_TOKEN, scopes="https://w # fully inline, no provider instance # @sensitive API_TOKEN=oauth(tokenUrl="https://myorg.auth0.com/oauth/token", grant="client_credentials", clientId=$AUTH0_CLIENT_ID, clientSecret=$AUTH0_CLIENT_SECRET, params={ audience="https://api.myorg.com" }) + +# Google service account (jwt_bearer): the key file supplies everything +# @internal @sensitive +GCP_SA_KEY=op("op://infra/gcp-sa/key json") +# @sensitive +GCP_TOKEN=oauth(grant="jwt_bearer", serviceAccountKey=$GCP_SA_KEY, scopes="https://www.googleapis.com/auth/cloud-platform") ``` A few things to know: diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index 1bc5d8a79..9a6583a17 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -1188,20 +1188,6 @@ export const OauthResolver: typeof Resolver = createResolver({ } } - 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) ?? provider?.tokenUrl; - if (!tokenUrl) { - throw new SchemaError('tokenUrl is required (or reference an @oauthProvider instance that provides one)'); - } - try { - assertValidTokenUrl(tokenUrl); - } catch (err) { - throw new SchemaError(err instanceof Error ? err.message : String(err)); - } - let grantType: OauthGrantType = 'refresh_token'; const grantResolver = this.objArgs?.grant; if (grantResolver) { @@ -1214,6 +1200,61 @@ export const OauthResolver: typeof Resolver = createResolver({ 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) ?? provider?.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 @oauthProvider instance that provides one)'); + } + if (tokenUrl) { + try { + assertValidTokenUrl(tokenUrl); + } catch (err) { + throw new SchemaError(err instanceof Error ? err.message : String(err)); + } + } + let clientAuth: OauthClientAuthMethod = provider?.clientAuth ?? 'body'; const clientAuthResolver = this.objArgs?.clientAuth; if (clientAuthResolver) { @@ -1254,12 +1295,15 @@ export const OauthResolver: typeof Resolver = createResolver({ tip: 'Or reference an @oauthProvider instance and provision a refresh token with `varlock oauth login`', }); } - if (grantType === 'client_credentials' && refreshTokenResolver) { - throw new SchemaError('refreshToken does not apply to the client_credentials grant'); + if (grantType !== 'refresh_token' && refreshTokenResolver) { + throw new SchemaError(`refreshToken does not apply to the ${grantType} grant`); } const clientIdResolver = this.objArgs?.clientId; - if (!clientIdResolver && !provider) throw new SchemaError('clientId is required'); + // jwt_bearer identifies via the signed assertion; client_id is optional there + if (!clientIdResolver && !provider && grantType !== 'jwt_bearer') { + throw new SchemaError('clientId is required'); + } const clientSecretResolver = this.objArgs?.clientSecret; const scopesResolver = this.objArgs?.scopes; @@ -1288,7 +1332,22 @@ export const OauthResolver: typeof Resolver = createResolver({ } } - const knownArgs = ['tokenUrl', 'grant', 'clientAuth', 'skew', 'refreshToken', 'clientId', 'clientSecret', 'scopes', 'params']; + 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(', ')})`); @@ -1301,11 +1360,16 @@ export const OauthResolver: typeof Resolver = createResolver({ grantType, clientAuth, skewMs, + audience, refreshTokenResolver, clientIdResolver, clientSecretResolver, scopesResolver, paramsResolver, + serviceAccountKeyResolver, + privateKeyResolver, + issuerResolver, + subjectResolver, }; }, async resolve(state) { @@ -1320,15 +1384,54 @@ export const OauthResolver: typeof Resolver = createResolver({ throw new ResolutionError(`@oauthProvider "${provider.id}" failed to initialize`); } - let clientId: string; - if (state.clientIdResolver) { - const resolved = await state.clientIdResolver.resolve(); + const resolveRequiredString = async (resolver: Resolver, argName: string): Promise => { + const resolved = await resolver.resolve(); if (typeof resolved !== 'string' || !resolved) { - throw new ResolutionError('clientId resolved to an empty value'); + 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'), + }; } - clientId = resolved; + 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 = provider!.resolved!.clientId; + clientId = provider?.resolved?.clientId; } let clientSecret = provider?.resolved?.clientSecret; if (state.clientSecretResolver) { @@ -1374,19 +1477,20 @@ export const OauthResolver: typeof Resolver = createResolver({ // 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: state.tokenUrl, + tokenUrl, grantType: state.grantType, - clientId, + 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 provider-level // cache entry shared by every item using this provider const usesProviderToken = state.grantType === 'refresh_token' && !configuredRefreshToken; - const providerCacheKey = usesProviderToken - ? buildOauthProviderCacheKey({ tokenUrl: state.tokenUrl, clientId }) + const providerCacheKey = usesProviderToken && clientId + ? buildOauthProviderCacheKey({ tokenUrl, clientId }) : undefined; const loginTip = `Run \`varlock oauth login${provider && provider.id !== '_default' ? ` ${provider.id}` : ''}\` to provision a refresh token`; if (usesProviderToken && !cacheStore) { @@ -1431,15 +1535,31 @@ export const OauthResolver: typeof Resolver = createResolver({ refreshToken = providerEntry.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: state.tokenUrl, + tokenUrl, grantType: state.grantType, clientId, clientSecret, clientAuth: state.clientAuth, refreshToken, + assertion, scope, extraParams, }); @@ -1447,13 +1567,17 @@ export const OauthResolver: typeof Resolver = createResolver({ if (err instanceof OauthTokenRequestError) { const tip: Array = []; if (err.details.oauthErrorCode === 'invalid_grant') { - tip.push('The refresh token is likely expired or revoked'); - if (usesProviderToken) { - 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'); + 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 (usesProviderToken) { + 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'); + } } } } diff --git a/packages/varlock/src/env-graph/test/oauth-resolver.test.ts b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts index d1ef33c48..46485bb99 100644 --- a/packages/varlock/src/env-graph/test/oauth-resolver.test.ts +++ b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts @@ -4,6 +4,7 @@ */ import http from 'node:http'; +import { generateKeyPairSync } from 'node:crypto'; import { describe, it, expect, beforeEach, afterEach, } from 'vitest'; @@ -376,6 +377,95 @@ describe('oauth()', () => { }); }); + 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); 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.ts b/packages/varlock/src/lib/oauth.ts index 86bdcc348..6d3b565be 100644 --- a/packages/varlock/src/lib/oauth.ts +++ b/packages/varlock/src/lib/oauth.ts @@ -12,10 +12,11 @@ import { createHash } from 'node:crypto'; /** grant types usable from the oauth() resolver */ -export const OAUTH_GRANT_TYPES = ['refresh_token', 'client_credentials'] as const; +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; @@ -80,6 +81,8 @@ export type OauthTokenRequestOpts = { 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) */ @@ -126,12 +129,15 @@ export type OauthProviderCacheEntry = { 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 ?? ''].join('\n'); + 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}`; } @@ -174,8 +180,12 @@ export async function requestOauthToken(opts: OauthTokenRequestOpts): Promise { + 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/); + }); +}); From 94d94c6a99784320b6c0d16c2c680036ad25f785 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 15:17:09 -0700 Subject: [PATCH 4/7] Add OAuth guide Covers the full workflow: defining providers with presets, provisioning via varlock oauth login (device + browser flows, per-provider app setup table) or explicit vault-stored refresh tokens for CI, the client_credentials and jwt_bearer grants, scope handling, and troubleshooting. Cross-linked from the oauth() and @oauthProvider reference sections. --- .../src/content/docs/guides/oauth.mdx | 124 ++++++++++++++++++ .../src/content/docs/reference/functions.mdx | 2 +- .../docs/reference/root-decorators.mdx | 2 +- packages/varlock-website/src/sidebar.ts | 1 + 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 packages/varlock-website/src/content/docs/guides/oauth.mdx 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..1c86522e3 --- /dev/null +++ b/packages/varlock-website/src/content/docs/guides/oauth.mdx @@ -0,0 +1,124 @@ +--- +title: OAuth tokens +description: Mint short-lived OAuth access tokens from refresh tokens, client credentials, or service account keys, without handing the long-lived credential to your app +--- + +Many APIs (Google, Slack, GitHub Apps, Microsoft, Auth0, and others) issue short-lived access tokens that expire after about an hour. To keep working, something has to hold a long-lived credential (a refresh token, client secret, or service account key) and exchange it for fresh tokens. Usually that something is your app's SDK, which means the long-lived credential sits in your process env. + +Varlock moves that exchange into config resolution. The long-lived credential stays in your vault as an [`@internal`](/reference/item-decorators/#internal) item that is never injected, and your app only ever receives a fresh short-lived access token. If the token leaks (a log line, a compromised agent, a stray error report), the damage window is the token's remaining lifetime, not forever. + +```env-spec +# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +# --- +# @internal +GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com +# @internal @sensitive +GOOGLE_CLIENT_SECRET=op("op://dev/google-oauth/client secret") + +# resolves to a fresh access token, refreshed automatically as it expires +# @sensitive +DRIVE_TOKEN=oauth(google, scopes="https://www.googleapis.com/auth/drive.readonly") +``` + +```bash +varlock oauth login google # one-time browser login, stores the refresh token +varlock run -- your-app # DRIVE_TOKEN is a valid access token +``` + +## How it works + +The [`oauth()`](/reference/functions/#oauth) function calls the provider's token endpoint during resolution. Tokens are cached in the [encrypted cache](/guides/caching/) with the expiry the provider reported, so repeated runs reuse the same token until shortly before it expires (60s early by default, tunable via `skew`). Parallel `varlock run` invocations coordinate through a lock so the provider sees one exchange, not a stampede. + +Providers that rotate refresh tokens on every use (Google and Slack do) are handled automatically: the rotated token is stored in the cache and used for the next refresh. + +## Defining a provider + +The [`@oauthProvider`](/reference/root-decorators/#oauthprovider) root decorator holds client config in one place so several items can mint tokens from it. Presets fill in the endpoints and quirks for common providers: + +```env-spec +# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +# @oauthProvider(id=gh, preset=github, clientId=$GH_CLIENT_ID, clientSecret=$GH_CLIENT_SECRET) +``` + +Available presets: `google`, `github`, `microsoft`, `slack`. For anything else, set `tokenUrl` (and `authorizationUrl` / `deviceAuthorizationUrl` if you want browser login) explicitly. Item-level args always override provider-level ones. + +For a one-off token you can skip the provider entirely and pass everything inline to `oauth()`; see the [function reference](/reference/functions/#oauth). + +## Getting the initial credential + +The token exchange needs a long-lived credential to start from. There are two ways to provide it. + +### Option 1: `varlock oauth login` (local development) + +```bash +varlock oauth login google +``` + +This runs a browser login flow and stores the resulting refresh token in the encrypted cache. Every item referencing that provider *without* an explicit `refreshToken` uses it from then on. Two flows are supported: + +- **Device code** (default when the provider supports it): the terminal shows a short code, you enter it on the provider's site. No redirect configuration needed at all. +- **Browser** (`--flow browser`): opens the provider's consent page and catches the redirect on a local loopback server. Requires the OAuth app to allow loopback redirects. + +You need an OAuth app registered with the provider first. This is a one-time setup per team: + +| Provider | App setup | +|---|---| +| Google | Create a "Desktop app" OAuth client at [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials). Desktop clients allow loopback redirects implicitly, and the device flow works for a limited set of scopes. | +| GitHub | Create an OAuth app at [github.com/settings/developers](https://github.com/settings/developers) and enable device flow. Refresh tokens require "user token expiration" enabled on the app. | +| Microsoft | Register a public client (mobile & desktop) app. The preset uses the "common" tenant; set `tokenUrl`/`authorizationUrl` to pin a tenant. | +| Slack | Neither flow works locally (no device flow, https-only redirects). Provision a refresh token elsewhere and use option 2. Token rotation must be enabled on the app. | + +Login-provisioned tokens live in this machine's encrypted cache. Clearing the cache means logging in again, and each machine logs in separately. `varlock oauth status` (or bare `varlock oauth`) shows what is provisioned. + +### Option 2: explicit `refreshToken` (CI and servers) + +Store a refresh token in your vault and reference it directly: + +```env-spec +# @internal @sensitive +GOOGLE_REFRESH_TOKEN=op("op://ci/google-oauth/refresh token") +# @sensitive +DRIVE_TOKEN=oauth(google, refreshToken=$GOOGLE_REFRESH_TOKEN, scopes="https://www.googleapis.com/auth/drive.readonly") +``` + +This is the right form for CI, where there is no browser and no persistent login. Note that in CI without a persistent cache, a provider that rotates refresh tokens will invalidate the stored one after the first exchange; varlock prints a warning when this happens. Either use a non-rotating provider credential, or set up a [persistent CI cache](/guides/caching/) via `_VARLOCK_CACHE_KEY`. + +## Machine-to-machine grants + +Not everything starts from a user consent flow. Two more grants cover service identities: + +**`client_credentials`**: for providers where the client id + secret *is* the identity (Auth0, Okta, and most "M2M applications"): + +```env-spec +# @sensitive +API_TOKEN=oauth(tokenUrl="https://myorg.auth0.com/oauth/token", grant="client_credentials", clientId=$AUTH0_CLIENT_ID, clientSecret=$AUTH0_CLIENT_SECRET, params={ audience="https://api.myorg.com" }) +``` + +**`jwt_bearer`**: for providers that give you a signing key instead of a secret, most commonly Google service accounts. Varlock signs a short-lived RS256 assertion with the key and exchanges it. The key file supplies the endpoint and identity, so config is minimal: + +```env-spec +# @internal @sensitive +GCP_SA_KEY=op("op://infra/gcp-sa/key json") +# @sensitive +GCP_TOKEN=oauth(grant="jwt_bearer", serviceAccountKey=$GCP_SA_KEY, scopes="https://www.googleapis.com/auth/cloud-platform") +``` + +This replaces the usual pattern of handing the entire service account JSON (a permanent credential) to your app so its SDK can sign. The key stays `@internal`; the app gets a one-hour token. Non-Google providers use the `privateKey` + `issuer` form instead, and `subject` supports impersonation (e.g. Google domain-wide delegation). See the [function reference](/reference/functions/#oauth) for all args. + +## Scopes + +Each item requests its own scopes, and items sharing a provider get separately-scoped access tokens from one shared refresh token. `varlock oauth login` requests the union of every scope used in your schema (plus any preset-required ones, like Microsoft's `offline_access`), so one login covers all items. If you add an item with new scopes later, run login again. + +## Troubleshooting + +- **`invalid_grant` on refresh**: the refresh token is expired or revoked. Run `varlock oauth login` again, or re-provision the vault-stored token. For `jwt_bearer` this usually means the key was revoked, the subject is not authorized, or your clock is off. +- **Login succeeds but no refresh token is returned**: the provider needs opt-in. GitHub apps need "user token expiration" enabled; Google needs the `access_type=offline` and `prompt=consent` params (the preset sends them). +- **`no refresh token has been provisioned`**: an item references a provider without `refreshToken`, and this machine has not run `varlock oauth login` (or the cache was cleared). +- **Wrapping in `cache()` is an error**: `oauth()` already caches tokens according to their real expiry; a generic TTL would serve expired tokens. + +## Related + +- [`oauth()` function reference](/reference/functions/#oauth) +- [`@oauthProvider` decorator reference](/reference/root-decorators/#oauthprovider) +- [`varlock oauth` CLI reference](/reference/cli/project/#oauth) +- [Caching guide](/guides/caching/) for where token state lives and how to inspect or clear it diff --git a/packages/varlock-website/src/content/docs/reference/functions.mdx b/packages/varlock-website/src/content/docs/reference/functions.mdx index 20672bcc9..65c246aec 100644 --- a/packages/varlock-website/src/content/docs/reference/functions.mdx +++ b/packages/varlock-website/src/content/docs/reference/functions.mdx @@ -421,7 +421,7 @@ A few other things to know:
### `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. +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. 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 2b9161566..93af5cf16 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -530,7 +530,7 @@ WEBHOOK_SECRET=yourPreferredPlugin() ### `@oauthProvider()` **Arg types:** `(id?: string, preset?: string, tokenUrl?: string, authorizationUrl?: string, deviceAuthorizationUrl?: string, clientAuth?: string, clientId, clientSecret?, scopes?)` -Defines a named OAuth provider that [`oauth()`](/reference/functions/#oauth) items reference by id, so client config is written once and shared by every token minted from it. Can be declared multiple times with different ids. +Defines a named OAuth provider that [`oauth()`](/reference/functions/#oauth) items reference by id, so client config is written once and shared by every token minted from it. Can be declared multiple times with different ids. See the [OAuth guide](/guides/oauth/) for the full workflow. - `id`: name used by `oauth(, ...)` and `varlock oauth login ` (defaults to `_default`) - `preset`: fills in endpoints and quirks for a known provider: `google`, `github`, `microsoft`, or `slack`. Explicit args override preset values. 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' }, ], }, From 8f3e3bfbb30d95c55c7d1f6f721c52d90519bcaf Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 16:30:13 -0700 Subject: [PATCH 5/7] Rename @oauthProvider to @oauthClient; provider now means the vendor In OAuth vocabulary a provider is Google/Okta/Auth0, and what you register with them is a client, so the decorator is now @oauthClient and the old preset= arg is provider= (built-in defs for google, github, microsoft, slack). Clients are addressed by provider name by default (oauth(google, ...)), and an explicit id nests under it: id=dev is addressed as google/dev. Provider-less clients use their id alone. Renamed throughout: EnvGraph.oauthClients, OauthClientRecord, buildOauthClientCacheKey, CLI copy, docs, intellisense. --- .bumpy/oauth-provider-login.md | 2 +- .../src/content/docs/guides/oauth.mdx | 20 +-- .../content/docs/reference/cli/project.mdx | 8 +- .../src/content/docs/reference/functions.mdx | 12 +- .../docs/reference/root-decorators.mdx | 18 +-- .../varlock/src/cli/commands/oauth.command.ts | 80 ++++++------ packages/varlock/src/env-graph/index.ts | 2 +- .../varlock/src/env-graph/lib/decorators.ts | 106 ++++++++------- .../varlock/src/env-graph/lib/env-graph.ts | 6 +- .../varlock/src/env-graph/lib/resolver.ts | 121 +++++++++--------- .../src/env-graph/test/oauth-resolver.test.ts | 65 +++++++--- .../{oauth-presets.ts => oauth-providers.ts} | 12 +- packages/varlock/src/lib/oauth.ts | 4 +- .../vscode-plugin/src/intellisense-catalog.ts | 8 +- 14 files changed, 252 insertions(+), 212 deletions(-) rename packages/varlock/src/lib/{oauth-presets.ts => oauth-providers.ts} (89%) diff --git a/.bumpy/oauth-provider-login.md b/.bumpy/oauth-provider-login.md index 798f72644..fc0d145b1 100644 --- a/.bumpy/oauth-provider-login.md +++ b/.bumpy/oauth-provider-login.md @@ -3,4 +3,4 @@ varlock: minor env-spec-language: patch --- -New @oauthProvider root decorator (with presets for google, github, microsoft, slack) and varlock oauth login/status commands: define an OAuth provider once, provision a refresh token via a browser or device-code login flow, and mint access tokens from it with oauth() without storing a refresh token anywhere +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/packages/varlock-website/src/content/docs/guides/oauth.mdx b/packages/varlock-website/src/content/docs/guides/oauth.mdx index 1c86522e3..1045549c6 100644 --- a/packages/varlock-website/src/content/docs/guides/oauth.mdx +++ b/packages/varlock-website/src/content/docs/guides/oauth.mdx @@ -8,7 +8,7 @@ Many APIs (Google, Slack, GitHub Apps, Microsoft, Auth0, and others) issue short Varlock moves that exchange into config resolution. The long-lived credential stays in your vault as an [`@internal`](/reference/item-decorators/#internal) item that is never injected, and your app only ever receives a fresh short-lived access token. If the token leaks (a log line, a compromised agent, a stray error report), the damage window is the token's remaining lifetime, not forever. ```env-spec -# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) # --- # @internal GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com @@ -31,16 +31,16 @@ The [`oauth()`](/reference/functions/#oauth) function calls the provider's token Providers that rotate refresh tokens on every use (Google and Slack do) are handled automatically: the rotated token is stored in the cache and used for the next refresh. -## Defining a provider +## Defining a client -The [`@oauthProvider`](/reference/root-decorators/#oauthprovider) root decorator holds client config in one place so several items can mint tokens from it. Presets fill in the endpoints and quirks for common providers: +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 -# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) -# @oauthProvider(id=gh, preset=github, clientId=$GH_CLIENT_ID, clientSecret=$GH_CLIENT_SECRET) +# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +# @oauthClient(provider=github, clientId=$GH_CLIENT_ID, clientSecret=$GH_CLIENT_SECRET) ``` -Available presets: `google`, `github`, `microsoft`, `slack`. For anything else, set `tokenUrl` (and `authorizationUrl` / `deviceAuthorizationUrl` if you want browser login) explicitly. Item-level args always override provider-level ones. +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). @@ -65,7 +65,7 @@ You need an OAuth app registered with the provider first. This is a one-time set |---|---| | 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 preset uses the "common" tenant; set `tokenUrl`/`authorizationUrl` to pin a tenant. | +| 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. @@ -107,18 +107,18 @@ This replaces the usual pattern of handing the entire service account JSON (a pe ## Scopes -Each item requests its own scopes, and items sharing a provider get separately-scoped access tokens from one shared refresh token. `varlock oauth login` requests the union of every scope used in your schema (plus any preset-required ones, like Microsoft's `offline_access`), so one login covers all items. If you add an item with new scopes later, run login again. +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 (the preset sends them). +- **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) -- [`@oauthProvider` decorator reference](/reference/root-decorators/#oauthprovider) +- [`@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 f1e6e74c3..8d5d1bbae 100644 --- a/packages/varlock-website/src/content/docs/reference/cli/project.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli/project.mdx @@ -172,20 +172,20 @@ The output directory is a generated artifact: add it to `.gitignore`, and rerun ## `varlock oauth` ||oauth|| -Manages OAuth providers defined with [`@oauthProvider`](/reference/root-decorators/#oauthprovider) and the refresh tokens used by [`oauth()`](/reference/functions/#oauth) items. +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] [provider-id] +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 provider-level `scopes` and preset-required scopes (e.g. `offline_access` for Microsoft). +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:** -- `[provider-id]`: which `@oauthProvider` to log in to (optional when only one is defined) +- `[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). diff --git a/packages/varlock-website/src/content/docs/reference/functions.mdx b/packages/varlock-website/src/content/docs/reference/functions.mdx index 65c246aec..7be24e7cd 100644 --- a/packages/varlock-website/src/content/docs/reference/functions.mdx +++ b/packages/varlock-website/src/content/docs/reference/functions.mdx @@ -425,14 +425,14 @@ Exchanges a long-lived OAuth credential for a short-lived access token by callin Tokens are cached (encrypted, according to your [cache mode](/reference/root-decorators/#cache)) and reused until the provider-reported expiry, so repeated invocations do not hit the token endpoint. When a provider rotates refresh tokens on each use (Google and Slack do), the rotated token is stored in the cache and used for the next refresh automatically; the configured refresh token is just the bootstrap. -An optional first positional arg references an [`@oauthProvider`](/reference/root-decorators/#oauthprovider) instance by id, which supplies `tokenUrl`, `clientId`, `clientSecret`, and `clientAuth` so several items can share one client config. Item-level args override provider-level ones. +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 provider instance or service account key supplies it). Must be https (plain http is allowed for localhost). +- `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 provider instance is referenced, in which case omitting it means "use the login-provisioned token" (see below). -- `clientId=R`: the OAuth client id (required unless a provider instance supplies it; optional for `jwt_bearer`) +- `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 @@ -447,7 +447,7 @@ Options: - `audience=S` option: `aud` claim override (defaults to the token endpoint) ```env-spec "oauth" -# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) # --- # @internal GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com @@ -464,7 +464,7 @@ GOOGLE_REFRESH_TOKEN=op("op://dev/google-oauth/refresh token") # @sensitive SHEETS_TOKEN=oauth(google, refreshToken=$GOOGLE_REFRESH_TOKEN, scopes="https://www.googleapis.com/auth/spreadsheets.readonly") -# fully inline, no provider instance +# 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" }) 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 93af5cf16..09ada793f 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -527,21 +527,21 @@ WEBHOOK_SECRET=yourPreferredPlugin()
-### `@oauthProvider()` -**Arg types:** `(id?: string, preset?: string, tokenUrl?: string, authorizationUrl?: string, deviceAuthorizationUrl?: string, clientAuth?: string, clientId, clientSecret?, scopes?)` +### `@oauthClient()` +**Arg types:** `(provider?: string, id?: string, tokenUrl?: string, authorizationUrl?: string, deviceAuthorizationUrl?: string, clientAuth?: string, clientId, clientSecret?, scopes?)` -Defines a named OAuth provider that [`oauth()`](/reference/functions/#oauth) items reference by id, so client config is written once and shared by every token minted from it. Can be declared multiple times with different ids. See the [OAuth guide](/guides/oauth/) for the full workflow. +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. -- `id`: name used by `oauth(, ...)` and `varlock oauth login ` (defaults to `_default`) -- `preset`: fills in endpoints and quirks for a known provider: `google`, `github`, `microsoft`, or `slack`. Explicit args override preset values. -- `tokenUrl`: token endpoint (required unless the preset provides it) +- `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 "@oauthProvider" -# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) +```env-spec "@oauthClient" +# @oauthClient(provider=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET) # --- # @internal GOOGLE_CLIENT_ID=1234-abcd.apps.googleusercontent.com @@ -551,7 +551,7 @@ GOOGLE_CLIENT_SECRET=varlock(local:abc123...) DRIVE_TOKEN=oauth(google, scopes="https://www.googleapis.com/auth/drive.readonly") ``` -Items referencing a provider 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 provider. +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.
diff --git a/packages/varlock/src/cli/commands/oauth.command.ts b/packages/varlock/src/cli/commands/oauth.command.ts index d828c5365..919c67c61 100644 --- a/packages/varlock/src/cli/commands/oauth.command.ts +++ b/packages/varlock/src/cli/commands/oauth.command.ts @@ -10,14 +10,14 @@ import { trackCommand } from '../helpers/telemetry'; import { logLines } from '../helpers/pretty-format'; import { runDeviceCodeLogin, runPkceLogin, OauthLoginError } from '../../lib/oauth-login'; import { - buildOauthProviderCacheKey, formatOauthScopesForDisplay, - type OauthProviderCacheEntry, + 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 { OauthProviderInstanceRecord } from '../../env-graph'; +import type { OauthClientRecord } from '../../env-graph'; const PATH_ARG = { type: 'string', @@ -29,9 +29,9 @@ const PATH_ARG = { async function loadGraphWithProviders(paths?: Array) { const envGraph = await loadVarlockEnvGraph({ entryFilePaths: paths }); checkForSchemaErrors(envGraph); - if (!Object.keys(envGraph.oauthProviders).length) { - throw new CliExitError('No oauth providers are defined in your schema', { - suggestion: 'Define one with a root decorator, e.g. `# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)`', + 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; @@ -50,28 +50,28 @@ function requirePersistentStore(envGraph: Awaited>, requestedId: string | undefined, -): OauthProviderInstanceRecord { - const providers = envGraph.oauthProviders; - const ids = Object.keys(providers); +): OauthClientRecord { + const clients = envGraph.oauthClients; + const ids = Object.keys(clients); if (requestedId) { - const record = providers[requestedId]; + const record = clients[requestedId]; if (!record) { - throw new CliExitError(`Unknown oauth provider "${requestedId}"`, { - suggestion: `Defined providers: ${ids.join(', ')}`, + throw new CliExitError(`Unknown oauth client "${requestedId}"`, { + suggestion: `Defined clients: ${ids.join(', ')}`, }); } return record; } - if (ids.length === 1) return providers[ids[0]]; - throw new CliExitError('Multiple oauth providers are defined - specify which one to log in to', { - suggestion: `e.g. \`varlock oauth login ${ids[0]}\` (defined providers: ${ids.join(', ')})`, + 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 provider-level scopes, preset-required scopes, and every login-provisioned item's scopes */ +/** union of client-level scopes, provider-required scopes, and every login-provisioned item's scopes */ async function collectLoginScopes( envGraph: Awaited>, - record: OauthProviderInstanceRecord, + record: OauthClientRecord, ): Promise { const delim = record.scopesDelimiter; const scopeSet = new Set(); @@ -104,10 +104,10 @@ const loginCommand = define({ name: 'login', description: 'Run a browser login flow and store the resulting refresh token in the encrypted cache', args: { - provider: { + client: { type: 'positional', required: false, - description: 'The @oauthProvider id to log in to (optional when only one is defined)', + description: 'The @oauthClient id to log in to, e.g. google or google/dev (optional when only one is defined)', }, flow: { type: 'string', @@ -120,8 +120,8 @@ const loginCommand = define({ path: PATH_ARG, }, examples: ` - varlock oauth login # log in (single provider defined) - varlock oauth login google # log in to a specific provider + 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(), @@ -130,9 +130,9 @@ const loginCommand = define({ const envGraph = await loadGraphWithProviders(ctx.values.path); const store = requirePersistentStore(envGraph); - const record = pickProvider(envGraph, ctx.values.provider); + const record = pickProvider(envGraph, ctx.values.client); if (!record.resolved) { - throw new CliExitError(`oauth provider "${record.id}" failed to initialize - fix schema errors first`); + throw new CliExitError(`oauth client "${record.id}" failed to initialize - fix schema errors first`); } const scope = ctx.values.scopes ?? await collectLoginScopes(envGraph, record); @@ -144,13 +144,13 @@ const loginCommand = define({ 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 @oauthProvider', + 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 @oauthProvider (or use a preset that provides one)', + 'Set authorizationUrl on the @oauthClient (or use a provider that provides one)', ...record.notes ? [`Note for this provider: ${record.notes}`] : [], ].join('\n'), }); @@ -158,7 +158,7 @@ const loginCommand = define({ // state intent up front so the terminal can be compared against the provider's consent screen logLines([ - `🔑 Logging in to oauth provider ${ansis.bold(record.id)}`, + `🔑 Logging in to oauth client ${ansis.bold(record.id)}`, '', ` token endpoint: ${record.tokenUrl}`, ` client id: ${record.resolved.clientId}`, @@ -215,17 +215,17 @@ const loginCommand = define({ throw err; } - const providerCacheKey = buildOauthProviderCacheKey({ + const clientEntryCacheKey = buildOauthClientCacheKey({ tokenUrl: record.tokenUrl, clientId: record.resolved.clientId, }); - const entry: OauthProviderCacheEntry = { + const entry: OauthClientCacheEntry = { refreshToken: loginResult.refreshToken, grantedScope: loginResult.grantedScope ?? scope, updatedAt: Date.now(), source: 'login', }; - const stored = await store.set(providerCacheKey, entry, TTL_FOREVER); + 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', @@ -246,7 +246,7 @@ const loginCommand = define({ const statusCommand = define({ name: 'status', - description: 'Show defined oauth providers and whether a refresh token has been provisioned', + description: 'Show defined oauth clients and whether a refresh token has been provisioned', args: { path: PATH_ARG, }, @@ -255,8 +255,8 @@ const statusCommand = define({ const envGraph = await loadGraphWithProviders(ctx.values.path); const store = envGraph._cacheStore; - for (const record of Object.values(envGraph.oauthProviders)) { - console.log(`${ansis.bold(record.id)}${record.presetName ? ansis.gray(` (preset: ${record.presetName})`) : ''}`); + 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) { @@ -266,12 +266,12 @@ const statusCommand = define({ if (!record.resolved) { console.log(ansis.red(' ⚠️ failed to initialize')); } else if (store && !(store instanceof InMemoryCacheStore)) { - const providerCacheKey = buildOauthProviderCacheKey({ + const clientEntryCacheKey = buildOauthClientCacheKey({ tokenUrl: record.tokenUrl, clientId: record.resolved.clientId, }); - const cached = await store.get(providerCacheKey); - const entry = cached?.value as OauthProviderCacheEntry | undefined; + 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)})`)}`); @@ -293,18 +293,18 @@ const statusCommand = define({ export const commandSpec = define({ name: 'oauth', - description: 'Manage OAuth providers and login-provisioned refresh tokens', + description: 'Manage OAuth clients and login-provisioned refresh tokens', subCommands: { login: loginCommand, status: statusCommand, }, examples: ` -Provision and inspect refresh tokens for @oauthProvider instances used by oauth(). +Provision and inspect refresh tokens for @oauthClient instances used by oauth(). Examples: - varlock oauth status # show providers and provisioning state - varlock oauth login # run the login flow (single provider defined) - varlock oauth login google # log in to a specific provider + 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(), }); diff --git a/packages/varlock/src/env-graph/index.ts b/packages/varlock/src/env-graph/index.ts index ece0d3598..ee8fa135d 100644 --- a/packages/varlock/src/env-graph/index.ts +++ b/packages/varlock/src/env-graph/index.ts @@ -5,7 +5,7 @@ export { FileBasedDataSource, DotEnvFileDataSource, DirectoryDataSource, MultiplePathsContainerDataSource, } from './lib/data-source'; export { Resolver, StaticValueResolver } from './lib/resolver'; -export { type OauthProviderInstanceRecord } from './lib/decorators'; +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 4366f711a..b20164f5d 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -18,7 +18,7 @@ import { PROXY_APPROVAL_EACH_VALUES, parseProxySubstitutionTarget } from '../../ import { assertValidTokenUrl, OAUTH_CLIENT_AUTH_METHODS, type OauthClientAuthMethod, type OauthGrantType, } from '../../lib/oauth'; -import { OAUTH_PROVIDER_PRESETS, OAUTH_PRESET_NAMES } from '../../lib/oauth-presets'; +import { OAUTH_PROVIDERS, OAUTH_PROVIDER_NAMES } from '../../lib/oauth-providers'; export abstract class DecoratorInstance { @@ -315,15 +315,18 @@ function parseEnvBulkValues( // ~ Root decorators ---------------------------------------- /** - * A registered `@oauthProvider(...)` instance, stored on `EnvGraph.oauthProviders` - * keyed by id. 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. + * 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 OauthProviderInstanceRecord = { +export type OauthClientRecord = { + /** full address (`google`, `google/dev`, or a bare id) */ id: string; - presetName?: string; + providerName?: string; tokenUrl: string; authorizationUrl?: string; deviceAuthorizationUrl?: string; @@ -714,18 +717,18 @@ export const builtInRootDecorators: Array> = [ process: (argsVal) => validateProxyFunctionArgs(argsVal), }, { - name: 'oauthProvider', + name: 'oauthClient', isFunction: true, process(argsVal) { const graph = argsVal.dataSource!.graph!; if (argsVal.arrArgs?.length) { - throw new SchemaError('@oauthProvider expects only key-value args, e.g. `@oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID)`'); + 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', - 'preset', + 'provider', 'tokenUrl', 'authorizationUrl', 'deviceAuthorizationUrl', @@ -736,7 +739,7 @@ export const builtInRootDecorators: Array> = [ ]; for (const argKey of Object.keys(objArgs)) { if (!knownArgs.includes(argKey)) { - throw new SchemaError(`@oauthProvider: unknown arg "${argKey}" (expected one of: ${knownArgs.join(', ')})`); + throw new SchemaError(`@oauthClient: unknown arg "${argKey}" (expected one of: ${knownArgs.join(', ')})`); } } @@ -744,83 +747,94 @@ export const builtInRootDecorators: Array> = [ const r = objArgs[argKey]; if (!r) return undefined; if (!r.isStatic || typeof r.staticValue !== 'string' || !r.staticValue) { - throw new SchemaError(`@oauthProvider: ${argKey} must be a static string`); + throw new SchemaError(`@oauthClient: ${argKey} must be a static string`); } return r.staticValue; }; - const id = getStaticString('id') ?? '_default'; - if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(id)) { - throw new SchemaError('@oauthProvider: id must start with a letter and contain only letters, numbers, dashes, underscores'); - } - if (graph.oauthProviders[id]) { - throw new SchemaError(`@oauthProvider: provider id "${id}" is already defined`); + 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(', ')})`); + } } - const presetName = getStaticString('preset'); - let preset; - if (presetName) { - preset = OAUTH_PROVIDER_PRESETS[presetName]; - if (!preset) { - throw new SchemaError(`@oauthProvider: unknown preset "${presetName}" (known presets: ${OAUTH_PRESET_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') ?? preset?.tokenUrl; + const tokenUrl = getStaticString('tokenUrl') ?? provider?.tokenUrl; if (!tokenUrl) { - throw new SchemaError('@oauthProvider: tokenUrl is required (or use a preset that provides one)'); + throw new SchemaError('@oauthClient: tokenUrl is required (or use a provider that provides one)'); } - const authorizationUrl = getStaticString('authorizationUrl') ?? preset?.authorizationUrl; - const deviceAuthorizationUrl = getStaticString('deviceAuthorizationUrl') ?? preset?.deviceAuthorizationUrl; + 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(`@oauthProvider: ${err instanceof Error ? err.message : 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(`@oauthProvider: clientAuth must be one of: ${OAUTH_CLIENT_AUTH_METHODS.join(', ')}`); + throw new SchemaError(`@oauthClient: clientAuth must be one of: ${OAUTH_CLIENT_AUTH_METHODS.join(', ')}`); } - const clientAuth = (clientAuthArg ?? preset?.clientAuth ?? 'body') as OauthClientAuthMethod; + const clientAuth = (clientAuthArg ?? provider?.clientAuth ?? 'body') as OauthClientAuthMethod; if (!objArgs.clientId) { - throw new SchemaError('@oauthProvider: clientId is required'); + throw new SchemaError('@oauthClient: clientId is required'); } - const record: OauthProviderInstanceRecord = { + const record: OauthClientRecord = { id, - presetName, + providerName, tokenUrl, authorizationUrl, deviceAuthorizationUrl, clientAuth, - extraAuthParams: preset?.extraAuthParams ?? {}, - requiredLoginScopes: preset?.requiredLoginScopes ?? [], - scopesDelimiter: preset?.scopesDelimiter ?? ' ', - appSetupUrl: preset?.appSetupUrl, - notes: preset?.notes, + 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.oauthProviders[id] = record; + graph.oauthClients[id] = record; return record; }, - async execute(record: OauthProviderInstanceRecord) { + async execute(record: OauthClientRecord) { const clientId = await record.clientIdResolver.resolve(); if (typeof clientId !== 'string' || !clientId) { - throw new ResolutionError('@oauthProvider: clientId resolved to an empty value'); + 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('@oauthProvider: clientSecret resolved to an empty value'); + throw new ResolutionError('@oauthClient: clientSecret resolved to an empty value'); } clientSecret = resolved; } @@ -832,7 +846,7 @@ export const builtInRootDecorators: Array> = [ } else if (Array.isArray(resolved) && resolved.every((s) => typeof s === 'string')) { scope = resolved.join(record.scopesDelimiter); } else { - throw new ResolutionError('@oauthProvider: scopes must resolve to a string or an array of strings'); + throw new ResolutionError('@oauthClient: scopes must resolve to a string or an array of strings'); } } record.resolved = { clientId, clientSecret, scope }; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index fcc82fd7e..038dc2414 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -22,7 +22,7 @@ import { builtInItemDecorators, builtInRootDecorators, RootDecoratorInstance, type ItemDecoratorDef, - type OauthProviderInstanceRecord, + type OauthClientRecord, type RootDecoratorDef, } from './decorators'; import { getErrorLocation } from './error-location'; @@ -114,8 +114,8 @@ export class EnvGraph { basePath?: string; - /** registered `@oauthProvider(...)` instances, keyed by id */ - oauthProviders: Record = {}; + /** registered `@oauthClient(...)` instances, keyed by id */ + oauthClients: Record = {}; // -- Cache -- /** @internal cache store instance, initialized during loading */ diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts index 9a6583a17..333a57478 100644 --- a/packages/varlock/src/env-graph/lib/resolver.ts +++ b/packages/varlock/src/env-graph/lib/resolver.ts @@ -22,13 +22,13 @@ import { import { assertValidCacheKey, hasInvalidCacheKeyChars, MAX_CACHE_KEY_LENGTH } from '../../lib/cache/cache-store'; import { assertValidTokenUrl, requestOauthToken, OauthTokenRequestError, - buildOauthItemCacheKey, buildOauthProviderCacheKey, + buildOauthItemCacheKey, buildOauthClientCacheKey, OAUTH_GRANT_TYPES, OAUTH_CLIENT_AUTH_METHODS, OAUTH_RESERVED_PARAMS, type OauthGrantType, type OauthClientAuthMethod, type OauthTokenResult, - type OauthItemCacheEntry, type OauthProviderCacheEntry, + type OauthItemCacheEntry, type OauthClientCacheEntry, } from '../../lib/oauth'; import type { EnvGraphDataSource } from './data-source'; -import { DecoratorInstance, type OauthProviderInstanceRecord } from './decorators'; +import { DecoratorInstance, type OauthClientRecord } from './decorators'; import { getErrorLocation } from './error-location'; import { isBuiltinVar } from './builtin-vars'; @@ -1165,25 +1165,26 @@ export const OauthResolver: typeof Resolver = createResolver({ arrayMaxLength: 1, }, process() { - // optional positional arg references an @oauthProvider instance by id - let provider: OauthProviderInstanceRecord | undefined; - const providerRefResolver = this.arrArgs?.[0]; - if (providerRefResolver) { - if (!providerRefResolver.isStatic || typeof providerRefResolver.staticValue !== 'string') { - throw new SchemaError('provider reference must be a static id, e.g. `oauth(google, ...)`'); - } - const providerId = providerRefResolver.staticValue as string; - const knownIds = Object.keys(this.envGraph?.oauthProviders ?? {}); - provider = this.envGraph?.oauthProviders[providerId]; - if (!provider) { + // 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 provider "${providerId}"${knownIds.length ? ` (defined providers: ${knownIds.join(', ')})` : ''}`, - { tip: 'Define it with a root decorator, e.g. `# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID)`' }, + `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 provider's arg dependencies ($REFS to other items) into this + // 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 provider.argsResolver.deps) { + for (const depKey of client.argsResolver.deps) { this.addDep(depKey); } } @@ -1242,10 +1243,10 @@ export const OauthResolver: typeof Resolver = createResolver({ if (tokenUrlResolver && (!tokenUrlResolver.isStatic || typeof tokenUrlResolver.staticValue !== 'string')) { throw new SchemaError('tokenUrl must be a static string'); } - const tokenUrl = (tokenUrlResolver?.staticValue as string | undefined) ?? provider?.tokenUrl; + 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 @oauthProvider instance that provides one)'); + throw new SchemaError('tokenUrl is required (or reference an @oauthClient instance that provides one)'); } if (tokenUrl) { try { @@ -1255,7 +1256,7 @@ export const OauthResolver: typeof Resolver = createResolver({ } } - let clientAuth: OauthClientAuthMethod = provider?.clientAuth ?? 'body'; + let clientAuth: OauthClientAuthMethod = client?.clientAuth ?? 'body'; const clientAuthResolver = this.objArgs?.clientAuth; if (clientAuthResolver) { if (!clientAuthResolver.isStatic || typeof clientAuthResolver.staticValue !== 'string') { @@ -1290,9 +1291,9 @@ export const OauthResolver: typeof Resolver = createResolver({ } const refreshTokenResolver = this.objArgs?.refreshToken; - if (grantType === 'refresh_token' && !refreshTokenResolver && !provider) { + if (grantType === 'refresh_token' && !refreshTokenResolver && !client) { throw new SchemaError('refreshToken is required for the refresh_token grant', { - tip: 'Or reference an @oauthProvider instance and provision a refresh token with `varlock oauth login`', + tip: 'Or reference an @oauthClient instance and provision a refresh token with `varlock oauth login`', }); } if (grantType !== 'refresh_token' && refreshTokenResolver) { @@ -1301,18 +1302,18 @@ export const OauthResolver: typeof Resolver = createResolver({ const clientIdResolver = this.objArgs?.clientId; // jwt_bearer identifies via the signed assertion; client_id is optional there - if (!clientIdResolver && !provider && grantType !== 'jwt_bearer') { + 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 provider record so `varlock oauth login` can + // 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 (provider && itemKey) { - provider.usedBy.push({ + if (client && itemKey) { + client.usedBy.push({ itemKey, grantType, scopesResolver, @@ -1355,7 +1356,7 @@ export const OauthResolver: typeof Resolver = createResolver({ } return { - provider, + client, tokenUrl, grantType, clientAuth, @@ -1376,12 +1377,12 @@ export const OauthResolver: typeof Resolver = createResolver({ const { getResolutionContext } = await import('./resolution-context'); const ctx = getResolutionContext(); const cacheStore = ctx?.cacheStore; - const { provider } = state; + const { client } = state; - // provider dynamic args (client credentials) are resolved once during + // client dynamic args (client credentials) are resolved once during // the decorator's execute() at load time - if (provider && !provider.resolved) { - throw new ResolutionError(`@oauthProvider "${provider.id}" failed to initialize`); + if (client && !client.resolved) { + throw new ResolutionError(`@oauthClient "${client.id}" failed to initialize`); } const resolveRequiredString = async (resolver: Resolver, argName: string): Promise => { @@ -1431,9 +1432,9 @@ export const OauthResolver: typeof Resolver = createResolver({ if (state.clientIdResolver) { clientId = await resolveRequiredString(state.clientIdResolver, 'clientId'); } else { - clientId = provider?.resolved?.clientId; + clientId = client?.resolved?.clientId; } - let clientSecret = provider?.resolved?.clientSecret; + let clientSecret = client?.resolved?.clientSecret; if (state.clientSecretResolver) { const resolved = await state.clientSecretResolver.resolve(); if (typeof resolved !== 'string' || !resolved) { @@ -1449,14 +1450,14 @@ export const OauthResolver: typeof Resolver = createResolver({ } configuredRefreshToken = resolved; } - let scope = provider?.resolved?.scope; + 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(provider?.scopesDelimiter ?? ' '); + scope = resolved.join(client?.scopesDelimiter ?? ' '); } else { throw new ResolutionError('scopes must resolve to a string or an array of strings'); } @@ -1486,14 +1487,14 @@ export const OauthResolver: typeof Resolver = createResolver({ }); // no item-level refresh token + refresh_token grant means the refresh token - // was provisioned via `varlock oauth login` and lives in a provider-level - // cache entry shared by every item using this provider - const usesProviderToken = state.grantType === 'refresh_token' && !configuredRefreshToken; - const providerCacheKey = usesProviderToken && clientId - ? buildOauthProviderCacheKey({ tokenUrl, clientId }) + // 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${provider && provider.id !== '_default' ? ` ${provider.id}` : ''}\` to provision a refresh token`; - if (usesProviderToken && !cacheStore) { + 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', }); @@ -1524,15 +1525,15 @@ export const OauthResolver: typeof Resolver = createResolver({ } // pick the refresh token: login-provisioned tokens live in the shared - // provider entry, item-configured ones rotate within the item entry - let providerEntry: OauthProviderCacheEntry | undefined; + // client entry, item-configured ones rotate within the item entry + let clientEntry: OauthClientCacheEntry | undefined; let refreshToken = entry?.refreshToken || configuredRefreshToken; - if (usesProviderToken) { - providerEntry = (await cacheStore!.get(providerCacheKey!))?.value as OauthProviderCacheEntry | undefined; - if (!providerEntry?.refreshToken) { - throw new ResolutionError('no refresh token has been provisioned for this oauth provider', { tip: loginTip }); + 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 = providerEntry.refreshToken; + refreshToken = clientEntry.refreshToken; } // jwt_bearer signs a fresh short-lived assertion per exchange @@ -1571,7 +1572,7 @@ export const OauthResolver: typeof Resolver = createResolver({ 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 (usesProviderToken) { + if (usesLoginToken) { tip.push(loginTip); } else { tip.push('Re-provision it from the provider'); @@ -1594,8 +1595,8 @@ export const OauthResolver: typeof Resolver = createResolver({ ), // rotated tokens are stored in the item entry only when the refresh // token is item-configured; login-provisioned rotation goes to the - // shared provider entry below - refreshToken: usesProviderToken ? undefined : (result.refreshToken ?? entry?.refreshToken), + // shared client entry below + refreshToken: usesLoginToken ? undefined : (result.refreshToken ?? entry?.refreshToken), scope: result.scope ?? scope, lastRefreshedAt: refreshedAt, refreshCount: (entry?.refreshCount ?? 0) + 1, @@ -1604,14 +1605,14 @@ export const OauthResolver: typeof Resolver = createResolver({ // can carry a rotated refresh token; freshness is checked via expiresAt if (cacheStore) { await cacheStore.set(itemCacheKey, newEntry, TTL_FOREVER); - if (usesProviderToken && result.refreshToken) { - const updatedProviderEntry: OauthProviderCacheEntry = { + if (usesLoginToken && result.refreshToken) { + const updatedClientEntry: OauthClientCacheEntry = { refreshToken: result.refreshToken, - grantedScope: providerEntry?.grantedScope, + grantedScope: clientEntry?.grantedScope, updatedAt: refreshedAt, source: 'rotation', }; - await cacheStore.set(providerCacheKey!, updatedProviderEntry, TTL_FOREVER); + await cacheStore.set(clientEntryCacheKey!, updatedClientEntry, TTL_FOREVER); } } else if ( result.refreshToken && result.refreshToken !== configuredRefreshToken && !warnedOauthRotationNotPersisted @@ -1625,9 +1626,9 @@ export const OauthResolver: typeof Resolver = createResolver({ // 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 provider key since they - // read and rotate the provider-level refresh token. - const lockKey = providerCacheKey ?? itemCacheKey; + // 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(); }, diff --git a/packages/varlock/src/env-graph/test/oauth-resolver.test.ts b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts index 46485bb99..7b7f09877 100644 --- a/packages/varlock/src/env-graph/test/oauth-resolver.test.ts +++ b/packages/varlock/src/env-graph/test/oauth-resolver.test.ts @@ -12,7 +12,7 @@ import { outdent } from 'outdent'; import { DotEnvFileDataSource, EnvGraph } from '../index'; import { InMemoryCacheStore } from '../../lib/cache'; import type { CacheStoreLike } from '../../lib/cache/cache-store'; -import { buildOauthProviderCacheKey, type OauthProviderCacheEntry } from '../../lib/oauth'; +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 */ @@ -207,9 +207,9 @@ describe('oauth()', () => { }); }); - describe('@oauthProvider instances', () => { + describe('@oauthClient instances', () => { function providerHeader(extraArgs = '') { - return `# @oauthProvider(id=test, tokenUrl="${endpoint.url}", clientId=$CLIENT_ID, clientSecret=$CLIENT_SECRET${extraArgs})`; + return `# @oauthClient(id=test, tokenUrl="${endpoint.url}", clientId=$CLIENT_ID, clientSecret=$CLIENT_SECRET${extraArgs})`; } const clientItems = outdent` # @internal @@ -219,8 +219,8 @@ describe('oauth()', () => { `; function seedProviderEntry(store: CacheStoreLike, refreshToken: string) { - const key = buildOauthProviderCacheKey({ tokenUrl: endpoint.url, clientId: 'client-1' }); - const entry: OauthProviderCacheEntry = { + 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); @@ -289,7 +289,7 @@ describe('oauth()', () => { `, store); expect(endpoint.requests[0].get('refresh_token')).toBe('rt-from-login'); - const updated = (await store.get(providerKey))?.value as OauthProviderCacheEntry; + const updated = (await store.get(providerKey))?.value as OauthClientCacheEntry; expect(updated.refreshToken).toBe('rt-rotated-0'); expect(updated.source).toBe('rotation'); @@ -322,55 +322,80 @@ describe('oauth()', () => { TOKEN_A=oauth(test, scopes="read") TOKEN_B=oauth(test, refreshToken=$RT) `, store); - const record = g.oauthProviders.test; + 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 preset endpoints and clientAuth, with explicit args overriding', async () => { + it('applies provider endpoints and clientAuth, with explicit args overriding', async () => { const g = await loadAndResolveWithHeader( - // tokenUrl overrides the preset so resolution hits the mock endpoint - `# @oauthProvider(id=goog, preset=google, tokenUrl="${endpoint.url}", clientId=$CLIENT_ID, clientSecret=$CLIENT_SECRET)`, + // 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(goog, refreshToken=$RT) + TOKEN=oauth(google/goog, refreshToken=$RT) `, ); expect(g.configSchema.TOKEN.errors).toEqual([]); - const record = g.oauthProviders.goog; + 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('rejects unknown provider ids, listing defined ones', async () => { + 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 provider "nope".*test/); + expect(g.configSchema.TOKEN.errors[0]?.message).toMatch(/unknown oauth client "nope".*test/); }); - it('rejects duplicate provider ids and unknown presets/args', async () => { + it('rejects duplicate client ids and unknown providers/args', async () => { const dupG = await loadAndResolveWithHeader(outdent` - # @oauthProvider(id=test, tokenUrl="${endpoint.url}", clientId="c") - # @oauthProvider(id=test, tokenUrl="${endpoint.url}", clientId="c") + # @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( - '# @oauthProvider(id=x, preset=bogus, clientId="c")', + '# @oauthClient(id=x, provider=bogus, clientId="c")', 'A=1', ); - expect(presetG.rootDataSource!.schemaErrors.some((e) => e.message.includes('unknown preset'))).toBe(true); + expect(presetG.rootDataSource!.schemaErrors.some((e) => e.message.includes('unknown provider'))).toBe(true); const argG = await loadAndResolveWithHeader( - `# @oauthProvider(id=x, tokenUrl="${endpoint.url}", clientId="c", bogus=1)`, + `# @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); diff --git a/packages/varlock/src/lib/oauth-presets.ts b/packages/varlock/src/lib/oauth-providers.ts similarity index 89% rename from packages/varlock/src/lib/oauth-presets.ts rename to packages/varlock/src/lib/oauth-providers.ts index aec92d65b..73d32b3b0 100644 --- a/packages/varlock/src/lib/oauth-presets.ts +++ b/packages/varlock/src/lib/oauth-providers.ts @@ -1,7 +1,7 @@ /** - * Data-driven presets for well-known OAuth providers, used by the - * `@oauthProvider` root decorator. A preset fills in endpoints and quirks so - * users only supply their own client credentials. + * 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. @@ -9,7 +9,7 @@ import type { OauthClientAuthMethod } from './oauth'; -export type OauthProviderPreset = { +export type OauthProviderDef = { /** display label */ label: string; tokenUrl: string; @@ -31,7 +31,7 @@ export type OauthProviderPreset = { notes?: string; }; -export const OAUTH_PROVIDER_PRESETS: Record = { +export const OAUTH_PROVIDERS: Record = { google: { label: 'Google', tokenUrl: 'https://oauth2.googleapis.com/token', @@ -69,4 +69,4 @@ export const OAUTH_PROVIDER_PRESETS: Record = { }, }; -export const OAUTH_PRESET_NAMES = Object.keys(OAUTH_PROVIDER_PRESETS); +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 index 6d3b565be..17a5e933d 100644 --- a/packages/varlock/src/lib/oauth.ts +++ b/packages/varlock/src/lib/oauth.ts @@ -117,7 +117,7 @@ export type OauthItemCacheEntry = { }; /** provider-level entry - the live home of a login-provisioned refresh token, shared across items */ -export type OauthProviderCacheEntry = { +export type OauthClientCacheEntry = { refreshToken: string; /** scopes granted at login (may be broader than any one item's request) */ grantedScope?: string; @@ -143,7 +143,7 @@ export function buildOauthItemCacheKey(parts: { } /** key for the shared provider-level refresh-token entry, written by `varlock oauth login` */ -export function buildOauthProviderCacheKey(parts: { tokenUrl: string; clientId: string }): string { +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}`; diff --git a/packages/vscode-plugin/src/intellisense-catalog.ts b/packages/vscode-plugin/src/intellisense-catalog.ts index 83ed94178..37e71be33 100644 --- a/packages/vscode-plugin/src/intellisense-catalog.ts +++ b/packages/vscode-plugin/src/intellisense-catalog.ts @@ -188,11 +188,11 @@ export const ROOT_DECORATORS: Array = [ isFunction: true, }, { - name: 'oauthProvider', + name: 'oauthClient', scope: 'root', - summary: 'Defines a named OAuth provider for oauth() items to reference.', - documentation: 'Example: `# @oauthProvider(id=google, preset=google, clientId=$GOOGLE_CLIENT_ID, clientSecret=$GOOGLE_CLIENT_SECRET)`. Presets: google, github, microsoft, slack. Provision a refresh token with `varlock oauth login `.', - insertText: '@oauthProvider(id=${1:google}, preset=${2:google}, clientId=$${3:CLIENT_ID}, clientSecret=$${4:CLIENT_SECRET})', + 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, }, ]; From d166fe35d4de0f1dbdc23de127931c6a98c27cc5 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 20:55:48 -0700 Subject: [PATCH 6/7] Add SDK/CLI consumption examples to the OAuth guide Tabbed examples per built-in provider showing the naming trick (call the item GH_TOKEN / CLOUDSDK_AUTH_ACCESS_TOKEN / SLACK_BOT_TOKEN and the CLI works under varlock run with no flags) plus minimal SDK snippets. Also a note on process lifetime vs token lifetime: short-lived processes are the standalone sweet spot, long-running servers point at the planned proxy integration. --- .../src/content/docs/guides/oauth.mdx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/packages/varlock-website/src/content/docs/guides/oauth.mdx b/packages/varlock-website/src/content/docs/guides/oauth.mdx index 1045549c6..e6a435144 100644 --- a/packages/varlock-website/src/content/docs/guides/oauth.mdx +++ b/packages/varlock-website/src/content/docs/guides/oauth.mdx @@ -3,6 +3,8 @@ 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 agent, a stray error report), the damage window is the token's remaining lifetime, not forever. @@ -83,6 +85,120 @@ DRIVE_TOKEN=oauth(google, refreshToken=$GOOGLE_REFRESH_TOKEN, scopes="https://ww 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. You skip the SDK's own OAuth plumbing entirely, since varlock already did the exchange. + + + + +`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. +::: + ## Machine-to-machine grants Not everything starts from a user consent flow. Two more grants cover service identities: From 793214249aa8d0de84386d7d3e7939d9c153fc21 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 31 Jul 2026 21:13:36 -0700 Subject: [PATCH 7/7] Tighten OAuth guide framing Leak damage is scope-bounded as well as time-bounded; passing a token to an SDK is the same code with one line different (you only bypass its refresh plumbing); and the standalone vs proxy contrast in one line: standalone keeps durable credentials out of the process, the proxy keeps all credentials out of it. --- packages/varlock-website/src/content/docs/guides/oauth.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/varlock-website/src/content/docs/guides/oauth.mdx b/packages/varlock-website/src/content/docs/guides/oauth.mdx index e6a435144..1d4bb188e 100644 --- a/packages/varlock-website/src/content/docs/guides/oauth.mdx +++ b/packages/varlock-website/src/content/docs/guides/oauth.mdx @@ -7,7 +7,7 @@ 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 agent, a stray error report), the damage window is the token's remaining lifetime, not forever. +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) @@ -90,7 +90,7 @@ This is the right form for CI, where there is no browser and no persistent login 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. You skip the SDK's own OAuth plumbing entirely, since varlock already did the exchange. +- **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. @@ -196,7 +196,7 @@ varlock run -- sh -c 'curl -H "Authorization: Bearer $SLACK_BOT_TOKEN" https://s :::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. +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