From d1c8eb8fee99f1694b2ed268614852a4b7b9a569 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 28 Oct 2026 08:50:10 -0500 Subject: [PATCH 1/8] feat(redact): redact registered prefix-less secrets in debug logs and telemetry --- src/utils/crash-reporter.ts | 6 ++++-- src/utils/debug.ts | 6 +++--- src/utils/redact.ts | 25 +++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/utils/crash-reporter.ts b/src/utils/crash-reporter.ts index 6a994a19..510cbf00 100644 --- a/src/utils/crash-reporter.ts +++ b/src/utils/crash-reporter.ts @@ -1,6 +1,7 @@ import { analytics } from './analytics.js'; import { CliExit } from './cli-exit.js'; import { homedir } from 'node:os'; +import { redactRegisteredSecrets } from './redact.js'; // Hard ceiling the telemetry API enforces on every attribute value // (z.string().max(4096) in llm-gateway/types/telemetry-event.ts). The final @@ -16,12 +17,13 @@ let isCrashing = false; /** * Redact known credential patterns (Bearer tokens, sk_test_/sk_live_ keys, - * raw JWTs). Shared by sanitizeStack and sanitizeMessage because Node echoes + * raw JWTs) plus exact values registered via registerSecret() (e.g. WIF + * tokens). Shared by sanitizeStack and sanitizeMessage because Node echoes * `.message` into the leading `Error.stack` line, so secrets in messages also * surface in stacks. */ function redactSecrets(s: string): string { - return s + return redactRegisteredSecrets(s) .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer ') .replace(/\bsk_(test|live)_[A-Za-z0-9]+/g, 'sk_') .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, ''); diff --git a/src/utils/debug.ts b/src/utils/debug.ts index a83c2744..7176509a 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'path'; import { homedir } from 'os'; import chalk from 'chalk'; import { prepareMessage } from './logging.js'; -import { redactCredentials } from './redact.js'; +import { redactCredentials, redactRegisteredSecrets } from './redact.js'; import ui from './ui.js'; import { isJsonMode } from './output.js'; @@ -82,7 +82,7 @@ export function getLogFilePath(): string | null { function writeLog(level: 'INFO' | 'WARN' | 'ERROR', emoji: string, args: unknown[]): string { const redactedArgs = args.map((a) => (typeof a === 'object' && a !== null ? redactCredentials(a) : a)); - const msg = redactedArgs.map((a) => prepareMessage(a)).join(' '); + const msg = redactRegisteredSecrets(redactedArgs.map((a) => prepareMessage(a)).join(' ')); // Write to console if debug enabled if (debugEnabled && !isJsonMode()) { @@ -125,7 +125,7 @@ export function logError(...args: unknown[]): void { export function debug(...args: unknown[]): void { if (!isDebugEnabled()) return; - const msg = args.map((a) => prepareMessage(a)).join(' '); + const msg = redactRegisteredSecrets(args.map((a) => prepareMessage(a)).join(' ')); ui.log.info(chalk.dim(msg)); } diff --git a/src/utils/redact.ts b/src/utils/redact.ts index 74911fb5..b931ea85 100644 --- a/src/utils/redact.ts +++ b/src/utils/redact.ts @@ -2,6 +2,27 @@ * Redact sensitive values from logs */ +/** + * Exact secret values registered at runtime that have no recognizable prefix, + * such as the short-lived GitHub OIDC and WorkOS access tokens used by + * workload identity federation (src/lib/wif.ts). In memory only. + */ +const registeredSecrets = new Set(); + +/** Register a secret value so the redaction helpers strip it wherever it appears. */ +export function registerSecret(value: string | undefined): void { + if (value && value.length >= 8) registeredSecrets.add(value); +} + +/** Replace every registered secret value in `text` with `[REDACTED]`. */ +export function redactRegisteredSecrets(text: string): string { + let redacted = text; + for (const secret of registeredSecrets) { + redacted = redacted.replaceAll(secret, '[REDACTED]'); + } + return redacted; +} + export function redactApiKey(apiKey: string): string { if (!apiKey || apiKey.length < 12) return '[REDACTED]'; @@ -24,6 +45,10 @@ export function redactCredentials(obj: any): any { if (typeof value === 'string' && value.startsWith('sk_')) { redacted[key] = redactApiKey(value); } + // Redact registered secrets (prefix-less tokens), whole or embedded + else if (typeof value === 'string' && registeredSecrets.size > 0) { + redacted[key] = redactRegisteredSecrets(value); + } // Recursively redact nested objects else if (typeof value === 'object' && value !== null) { redacted[key] = redactCredentials(value); From 9808f33afb2e54fd84a85be2675dd959394e5ed1 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 28 Oct 2026 08:50:11 -0500 Subject: [PATCH 2/8] feat(api): authenticate REST commands with GitHub Actions workload identity federation When WORKOS_API_KEY and --api-key are unset and the CLI runs in GitHub Actions with WORKOS_CLIENT_ID and an OIDC token request URL, exchange the workflow's GitHub OIDC token at WORKOS_WIF_TOKEN_URL for a short-lived WorkOS access token and use it as the Bearer for REST calls, sending x-url-environment-id when WORKOS_ENVIRONMENT_ID is set. The token is cached in memory only and refreshed inside the expiry margin. --- src/bin.ts | 28 +++ src/commands/api/interactive.spec.ts | 6 +- src/commands/api/interactive.ts | 4 +- src/commands/api/request.spec.ts | 10 +- src/commands/api/request.ts | 9 +- src/commands/debug.ts | 16 +- src/commands/vault-run.spec.ts | 1 + src/commands/vault-run.ts | 6 +- src/commands/verify-login.ts | 11 + src/lib/api-key.ts | 70 +++++- src/lib/wif.ts | 357 +++++++++++++++++++++++++++ src/lib/workos-api.ts | 8 +- src/lib/workos-client.ts | 4 + src/utils/exit-codes.ts | 12 + 14 files changed, 516 insertions(+), 26 deletions(-) create mode 100644 src/lib/wif.ts diff --git a/src/bin.ts b/src/bin.ts index c5e95920..bbc991b3 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -293,6 +293,22 @@ const installerOptions = { // Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean) if (!isJsonMode() && isPromptAllowed()) await checkForUpdates(); +/** Top-level tokens (as typed) whose handlers call resolveApiKey(); see the WIF middleware. */ +const WIF_PRIMED_COMMANDS = new Set([ + 'connection', + 'connections', + 'directory', + 'audit-log', + 'vault', + 'api-key', + 'seed', + 'setup-org', + 'onboard-user', + 'verify-login', + 'debug-sso', + 'debug-sync', +]); + async function runCli(): Promise { const startTime = Date.now(); let commandName = 'root'; @@ -396,6 +412,18 @@ async function runCli(): Promise { await applyInsecureStorage(argv.insecureStorage as boolean | undefined); await maybeWarnUnclaimed(); }) + .middleware(async (argv) => { + // Workload identity federation (src/lib/wif.ts): these REST commands + // resolve their credential synchronously via resolveApiKey(), so when + // GitHub Actions WIF is the credential, exchange the OIDC token for a + // WorkOS access token before the handler runs. A no-op whenever + // --api-key / WORKOS_API_KEY is set or WIF doesn't apply. `workos api` + // and `vault run` prime themselves only when they actually call the API. + const [command, subcommand] = (argv._ ?? []).map(String); + if (!WIF_PRIMED_COMMANDS.has(command ?? '') || (command === 'vault' && subcommand === 'run')) return; + const { primeApiCredentials } = await import('./lib/api-key.js'); + await primeApiCredentials({ apiKey: argv.apiKey as string | undefined }); + }) .command('auth', 'Manage authentication (login, logout, status)', (yargs) => { yargs.options(insecureStorageOption); registerSubcommand( diff --git a/src/commands/api/interactive.spec.ts b/src/commands/api/interactive.spec.ts index b52dd444..dc6e320e 100644 --- a/src/commands/api/interactive.spec.ts +++ b/src/commands/api/interactive.spec.ts @@ -92,7 +92,7 @@ vi.mock('./request.js', () => ({ })); vi.mock('../../lib/api-key.js', () => ({ - resolveApiKey: vi.fn(() => 'sk_test'), + resolveApiKeyAsync: vi.fn(async () => 'sk_test'), resolveApiBaseUrl: vi.fn(() => 'https://api.example.com'), })); @@ -113,6 +113,7 @@ vi.mock('../../utils/ui.js', () => ({ const { apiInteractive } = await import('./interactive.js'); const { CliExit } = await import('../../utils/cli-exit.js'); +const { resolveApiKeyAsync } = await import('../../lib/api-key.js'); async function expectExit(promise: Promise, code: number): Promise { try { @@ -301,7 +302,7 @@ describe('apiInteractive', () => { ); }); - it('uses the provided apiKey override instead of resolveApiKey()', async () => { + it('uses the provided apiKey override instead of resolveApiKeyAsync()', async () => { mockSelect.mockResolvedValueOnce('Users').mockResolvedValueOnce(mockCatalog.endpoints[0]); mockConfirm.mockResolvedValueOnce(true); mockApiRequest.mockResolvedValueOnce(buildResponse()); @@ -309,6 +310,7 @@ describe('apiInteractive', () => { await apiInteractive({ apiKey: 'sk_override' }); expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'sk_override' })); + expect(resolveApiKeyAsync).not.toHaveBeenCalled(); }); it('exits with code 1 when the response status is >= 400', async () => { diff --git a/src/commands/api/interactive.ts b/src/commands/api/interactive.ts index 88e41f99..a1f242b2 100644 --- a/src/commands/api/interactive.ts +++ b/src/commands/api/interactive.ts @@ -2,7 +2,7 @@ import ui from '../../utils/ui.js'; import { loadCatalog, endpointsByTag, type EndpointInfo } from './catalog.js'; import { apiRequest } from './request.js'; import { colorMethod, printResponse } from './format.js'; -import { resolveApiKey, resolveApiBaseUrl } from '../../lib/api-key.js'; +import { resolveApiKeyAsync, resolveApiBaseUrl } from '../../lib/api-key.js'; import { ExitCode, exitWithCode } from '../../utils/exit-codes.js'; import { exitWithError } from '../../utils/output.js'; @@ -143,7 +143,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise ({ - resolveApiKey: vi.fn(() => 'sk_test_default'), + resolveApiKeyAsync: vi.fn(async () => 'sk_test_default'), resolveApiBaseUrl: vi.fn(() => 'https://api.example.com'), })); const { apiRequest } = await import('./request.js'); -const { resolveApiKey, resolveApiBaseUrl } = await import('../../lib/api-key.js'); +const { resolveApiKeyAsync, resolveApiBaseUrl } = await import('../../lib/api-key.js'); function buildResponse(body: string, init: ResponseInit = {}): Response { return new Response(body, init); @@ -39,14 +39,14 @@ describe('apiRequest', () => { }), }), ); - expect(resolveApiKey).not.toHaveBeenCalled(); + expect(resolveApiKeyAsync).not.toHaveBeenCalled(); expect(resolveApiBaseUrl).not.toHaveBeenCalled(); }); - it('falls back to resolveApiKey and resolveApiBaseUrl when not provided', async () => { + it('falls back to resolveApiKeyAsync and resolveApiBaseUrl when not provided', async () => { const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(buildResponse('{}')); await apiRequest({ method: 'GET', path: '/users' }); - expect(resolveApiKey).toHaveBeenCalled(); + expect(resolveApiKeyAsync).toHaveBeenCalled(); expect(resolveApiBaseUrl).toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledWith( 'https://api.example.com/users', diff --git a/src/commands/api/request.ts b/src/commands/api/request.ts index 0636d925..0057c85c 100644 --- a/src/commands/api/request.ts +++ b/src/commands/api/request.ts @@ -1,4 +1,5 @@ -import { resolveApiKey, resolveApiBaseUrl } from '../../lib/api-key.js'; +import { resolveApiKeyAsync, resolveApiBaseUrl } from '../../lib/api-key.js'; +import { wifAuthHeaders } from '../../lib/wif.js'; export interface ApiRequestOptions { method: string; @@ -16,7 +17,7 @@ export interface ApiResponse { } export async function apiRequest(options: ApiRequestOptions): Promise { - const apiKey = options.apiKey ?? resolveApiKey(); + const apiKey = options.apiKey ?? (await resolveApiKeyAsync()); const baseUrl = options.baseUrl ?? resolveApiBaseUrl(); let path = options.path; @@ -25,7 +26,9 @@ export async function apiRequest(options: ApiRequestOptions): Promise = { - Authorization: `Bearer ${apiKey}`, + // An API key passes through as `Bearer `; a WIF token is refreshed + // if needed and carries x-url-environment-id when configured. + ...(await wifAuthHeaders(apiKey)), Accept: 'application/json', }; diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 91b77872..c5c93847 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -381,7 +381,21 @@ interface EnvVarInfo { export const ENV_VAR_CATALOG: { name: string; effect: string }[] = [ // Credentials { name: 'WORKOS_API_KEY', effect: 'Bypasses credential resolution — used directly for API calls' }, - { name: 'WORKOS_CLIENT_ID', effect: 'WorkOS client ID used during credential resolution' }, + { + name: 'WORKOS_CLIENT_ID', + effect: + 'WorkOS client ID used during credential resolution; in GitHub Actions with no API key, the M2M app client ID that turns on workload identity federation (WIF)', + }, + { + name: 'WORKOS_WIF_TOKEN_URL', + effect: 'WIF token endpoint (https:///oauth2/token); required for WIF, no default yet', + }, + { name: 'WORKOS_WIF_AUDIENCE', effect: 'Audience for the GitHub OIDC token used by WIF (default: https://api.workos.com)' }, + { name: 'WORKOS_WIF_SCOPE', effect: 'Optional scope sent with the WIF token exchange' }, + { + name: 'WORKOS_ENVIRONMENT_ID', + effect: 'Environment ID sent as x-url-environment-id with WIF access tokens (machine tokens need it)', + }, // Interaction & output { name: 'WORKOS_MODE', effect: 'Controls interaction behavior: human, agent, or CI' }, { name: 'WORKOS_AGENT', effect: 'Set to "1" to force agent interaction mode' }, diff --git a/src/commands/vault-run.spec.ts b/src/commands/vault-run.spec.ts index 5e284615..c01babf6 100644 --- a/src/commands/vault-run.spec.ts +++ b/src/commands/vault-run.spec.ts @@ -16,6 +16,7 @@ vi.mock('../lib/workos-client.js', () => ({ const mockResolveApiKey = vi.fn(() => 'sk_test_resolved'); vi.mock('../lib/api-key.js', () => ({ resolveApiKey: (...args: unknown[]) => mockResolveApiKey(...(args as [])), + resolveApiKeyAsync: async (...args: unknown[]) => mockResolveApiKey(...(args as [])), resolveApiBaseUrl: () => 'https://api.workos.com', })); diff --git a/src/commands/vault-run.ts b/src/commands/vault-run.ts index eac1e2f0..1d22f385 100644 --- a/src/commands/vault-run.ts +++ b/src/commands/vault-run.ts @@ -88,8 +88,10 @@ export async function fetchSecrets( async function resolveRunApiKey(envName: string | undefined, flagApiKey?: string): Promise { if (!envName) { - const { resolveApiKey } = await import('../lib/api-key.js'); - return resolveApiKey({ apiKey: flagApiKey }); + // Async variant: primes a WIF token in GitHub Actions (bin.ts skips + // `vault run`, whose --env and --dry-run paths need no WIF exchange). + const { resolveApiKeyAsync } = await import('../lib/api-key.js'); + return resolveApiKeyAsync({ apiKey: flagApiKey }); } const { getConfig } = await import('../lib/config-store.js'); diff --git a/src/commands/verify-login.ts b/src/commands/verify-login.ts index 0b467ae3..b1d9b9d9 100644 --- a/src/commands/verify-login.ts +++ b/src/commands/verify-login.ts @@ -10,6 +10,7 @@ import chalk from 'chalk'; import { createWorkOSClient } from '../lib/workos-client.js'; +import { isWifAccessToken } from '../lib/wif.js'; import { outputJson, isJsonMode, exitWithError } from '../utils/output.js'; import { exitWithCode, ExitCode } from '../utils/exit-codes.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; @@ -122,6 +123,16 @@ export async function runVerifyLogin(options: VerifyLoginOptions): Promise // users, so it must never touch production. Refuse on either the stored env // type OR a production-format key (belt-and-suspenders for keys passed via // --api-key / WORKOS_API_KEY with no matching stored env). + // A WIF access token has no sk_test_/sk_live_ prefix, so the target + // environment's type can't be confirmed: refuse rather than risk production. + if (isWifAccessToken(options.apiKey)) { + exitWithError({ + code: 'wif_unsupported_command', + message: + 'verify-login creates and deletes real users and cannot confirm that a workload identity federation (WIF) ' + + 'token targets a non-production environment. Set WORKOS_API_KEY to a sandbox (sk_test_) key for this step.', + }); + } if (options.envType === 'production' || options.apiKey.startsWith('sk_live_')) { exitWithError({ code: 'production_env_refused', diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts index c43abd3d..6aafcd86 100644 --- a/src/lib/api-key.ts +++ b/src/lib/api-key.ts @@ -1,15 +1,25 @@ /** - * API key resolution for management commands. + * Credential resolution for the REST commands (`workos api`, connection, + * directory, audit-log, vault, api-key, and the workflow/debug commands). * - * Priority chain: + * Priority chain (resolveApiKey): * 1. --api-key flag * 2. WORKOS_API_KEY environment variable - * 3. Active environment's stored API key + * 3. Workload identity federation in GitHub Actions (see wif.ts): a + * short-lived WorkOS access token, primed by primeApiCredentials() + * 4. Active environment's stored API key */ import { getActiveEnvironment } from './config-store.js'; import { exitWithError } from '../utils/output.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; +import { + exitWithMissingIdTokenPermission, + exitWithWifUnsupported, + getWifAccessToken, + getWifState, + peekWifAccessToken, +} from './wif.js'; const DEFAULT_BASE_URL = 'https://api.workos.com'; @@ -17,9 +27,25 @@ export interface ApiKeyOptions { apiKey?: string; } +/** + * Resolve the credential for a REST call. Synchronous, so when workload + * identity federation applies the token must already be primed (see + * primeApiCredentials); an unprimed call site exits rather than silently + * falling back to a stored key. + */ export function resolveApiKey(options?: ApiKeyOptions): string { - const apiKey = resolveOptionalApiKey(options); - if (apiKey) return apiKey; + const explicit = resolveExplicitApiKey(options); + if (explicit) return explicit; + + const wifState = getWifState(); + if (wifState === 'ready') { + return peekWifAccessToken() ?? exitWithWifUnsupported(); + } + + const stored = getActiveEnvironment()?.apiKey; + if (stored) return stored; + + if (wifState === 'missing_id_token_env') exitWithMissingIdTokenPermission(); exitWithError({ code: 'no_api_key', @@ -27,15 +53,43 @@ export function resolveApiKey(options?: ApiKeyOptions): string { }); } +/** + * Async half of credential resolution. When WIF is the credential that + * resolveApiKey() will return, exchange (or refresh) the access token now. + * A no-op otherwise, so the API key and profile paths never touch the network. + */ +export async function primeApiCredentials(options?: ApiKeyOptions): Promise { + if (resolveExplicitApiKey(options)) return; + if (getWifState() === 'ready') await getWifAccessToken(); +} + +/** resolveApiKey() for call sites that can await: primes WIF first. */ +export async function resolveApiKeyAsync(options?: ApiKeyOptions): Promise { + await primeApiCredentials(options); + return resolveApiKey(options); +} + +/** + * Flag → WORKOS_API_KEY → stored key, without WIF. Used by `workos + * migrations`, which hands the key to a runtime bundle that can't refresh a + * short-lived token. + */ export function resolveOptionalApiKey(options?: ApiKeyOptions): string | undefined { + const explicit = resolveExplicitApiKey(options); + if (explicit) return explicit; + + const activeEnv = getActiveEnvironment(); + if (activeEnv?.apiKey) return activeEnv.apiKey; + + return undefined; +} + +function resolveExplicitApiKey(options?: ApiKeyOptions): string | undefined { if (options?.apiKey) return options.apiKey; const envVar = process.env.WORKOS_API_KEY; if (envVar) return envVar; - const activeEnv = getActiveEnvironment(); - if (activeEnv?.apiKey) return activeEnv.apiKey; - return undefined; } diff --git a/src/lib/wif.ts b/src/lib/wif.ts new file mode 100644 index 00000000..675bd91b --- /dev/null +++ b/src/lib/wif.ts @@ -0,0 +1,357 @@ +/** + * Workload identity federation (WIF) for GitHub Actions. + * + * Lets the REST commands (`workos api`, connection, directory, audit-log, + * vault, api-key, ...) run in a GitHub Actions workflow with no + * WORKOS_API_KEY. The workflow's GitHub OIDC ID token is exchanged at WorkOS + * for a short-lived access token (RFC 7523 `jwt-bearer` grant), which is then + * the Bearer for API calls. + * + * The workflow (or job) needs `permissions: id-token: write`, otherwise + * GitHub does not expose ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN. + * + * Credential order lives in api-key.ts: `--api-key` and WORKOS_API_KEY always + * win; WIF applies only when getWifState() is 'ready'; otherwise the stored + * profile key and today's errors apply unchanged. + * + * The access token is cached in memory only and never written to disk, a + * profile or config. Neither the GitHub JWT nor the WorkOS access token may + * appear in output, errors, logs or telemetry: both are registered with the + * redaction helpers and no message here interpolates either value. + */ + +import { exitWithError } from '../utils/output.js'; +import { redactRegisteredSecrets, registerSecret } from '../utils/redact.js'; + +export const WIF_DEFAULT_AUDIENCE = 'https://api.workos.com'; +export const WIF_RESOURCE = 'https://api.workos.com'; +export const WIF_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; +export const ENVIRONMENT_ID_HEADER = 'x-url-environment-id'; + +/** Refresh once less than this much lifetime is left, or less than 20% of it, whichever is smaller. */ +const REFRESH_MARGIN_MAX_MS = 60_000; +const REFRESH_MARGIN_FRACTION = 0.2; +/** Lifetime to assume when the server omits `expires_in`: the documented minimum (5 min). */ +const DEFAULT_LIFETIME_SECONDS = 300; +/** Cap on server-provided `error_description` text echoed into an error message. */ +const MAX_DESCRIPTION_LENGTH = 500; + +export const WIF_ERROR_CODES = { + idTokenUnavailable: 'wif_id_token_unavailable', + idTokenRequestFailed: 'wif_id_token_request_failed', + tokenUrlMissing: 'wif_token_url_missing', + tokenUrlInvalid: 'wif_token_url_invalid', + exchangeFailed: 'wif_exchange_failed', + unsupportedCommand: 'wif_unsupported_command', +} as const; + +const ID_TOKEN_PERMISSION_HINT = + 'Add `permissions: id-token: write` to the workflow or job so GitHub Actions can issue an OIDC token.'; + +/** OAuth errors the token endpoint returns, with a one-line hint each. Exit code: `wif_`. */ +const OAUTH_ERROR_HINTS: Record = { + invalid_grant: + "The M2M application's trust rules didn't match this repository or ref, or the GitHub OIDC token expired.", + invalid_target: `The resource or audience is wrong: the trust rule's audience must match WORKOS_WIF_AUDIENCE (default ${WIF_DEFAULT_AUDIENCE}).`, + invalid_client: 'Check that WORKOS_CLIENT_ID is the client ID of the M2M application that has the GitHub trust rule.', + invalid_scope: + 'WORKOS_WIF_SCOPE asked for a scope the M2M application is not allowed. Fix or unset WORKOS_WIF_SCOPE.', + invalid_request: + 'The token request was malformed. Check that WORKOS_WIF_TOKEN_URL points at the /oauth2/token endpoint.', +}; + +/** + * - `off`: not GitHub Actions, or no WORKOS_CLIENT_ID. WIF is not attempted. + * - `missing_id_token_env`: GitHub Actions with WORKOS_CLIENT_ID, but the job + * can't request an OIDC token (no `id-token: write` permission). + * - `ready`: all WIF conditions hold. + */ +export type WifState = 'off' | 'missing_id_token_env' | 'ready'; + +export function getWifState(): WifState { + if (process.env.GITHUB_ACTIONS !== 'true' || !process.env.WORKOS_CLIENT_ID) return 'off'; + if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL || !process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) { + return 'missing_id_token_env'; + } + return 'ready'; +} + +/** Exit for the `missing_id_token_env` state when no other credential exists. */ +export function exitWithMissingIdTokenPermission(): never { + exitWithError({ + code: WIF_ERROR_CODES.idTokenUnavailable, + message: `WORKOS_CLIENT_ID is set in GitHub Actions, but this job can't request an OIDC token (ACTIONS_ID_TOKEN_REQUEST_URL / ACTIONS_ID_TOKEN_REQUEST_TOKEN are missing). ${ID_TOKEN_PERMISSION_HINT} Or set WORKOS_API_KEY.`, + }); +} + +/** Exit when WIF is the credential but the call site can't wait for the exchange. */ +export function exitWithWifUnsupported(command = 'This command'): never { + exitWithError({ + code: WIF_ERROR_CODES.unsupportedCommand, + message: `${command} can't authenticate with workload identity federation (WIF) yet. Set WORKOS_API_KEY for this step.`, + }); +} + +interface CachedToken { + value: string; + /** Epoch ms after which the token is exchanged again. */ + refreshAt: number; +} + +let cached: CachedToken | undefined; +let inFlight: Promise | undefined; +/** Every access token this process obtained, so request layers can recognize one. */ +const issuedTokens = new Set(); + +/** True when `credential` is a WorkOS access token obtained through WIF in this process. */ +export function isWifAccessToken(credential: string | undefined): boolean { + return credential !== undefined && issuedTokens.has(credential); +} + +/** + * The latest WIF access token, for synchronous credential resolution. + * Undefined until getWifAccessToken() has resolved once. Request layers swap + * it for a fresh one via wifAuthHeaders(), so a stale value is never sent. + */ +export function peekWifAccessToken(): string | undefined { + return cached?.value; +} + +/** + * A valid WorkOS access token, exchanging only when there is none or the + * cached one is inside the refresh margin. Concurrent callers share one + * in-flight exchange. Failures exit via exitWithError and are not cached. + */ +export function getWifAccessToken(): Promise { + if (cached && Date.now() < cached.refreshAt) return Promise.resolve(cached.value); + inFlight ??= exchangeForAccessToken() + .then((token) => { + cached = token; + issuedTokens.add(token.value); + return token.value; + }) + .finally(() => { + inFlight = undefined; + }); + return inFlight; +} + +/** + * Request headers for a REST credential. An API key passes through exactly as + * before; a WIF token is refreshed if needed and gets `x-url-environment-id` + * when WORKOS_ENVIRONMENT_ID is set (machine tokens need it). + */ +export async function wifAuthHeaders(credential: string): Promise> { + if (!isWifAccessToken(credential)) return { Authorization: `Bearer ${credential}` }; + const token = await getWifAccessToken(); + const environmentId = process.env.WORKOS_ENVIRONMENT_ID; + return { + Authorization: `Bearer ${token}`, + ...(environmentId && { [ENVIRONMENT_ID_HEADER]: environmentId }), + }; +} + +/** + * fetch for the WorkOS SDK, which fixes its Authorization header at + * construction: re-applies wifAuthHeaders() on every request so a long run + * never sends an expired token. + */ +export function createWifFetch(credential: string): typeof fetch { + return async (input, init) => { + const headers = new Headers(init?.headers); + for (const [name, value] of Object.entries(await wifAuthHeaders(credential))) { + headers.set(name, value); + } + return fetch(input, { ...init, headers }); + }; +} + +/** Test-only: forget cached and issued tokens. */ +export function resetWifCacheForTests(): void { + cached = undefined; + inFlight = undefined; + issuedTokens.clear(); +} + +function resolveTokenUrl(): string { + const raw = process.env.WORKOS_WIF_TOKEN_URL; + // No default yet: the WorkOS side of this grant (`management-api-oauth`) is + // still being built and has no production endpoint. The default will come + // once the server ships. + if (!raw) { + exitWithError({ + code: WIF_ERROR_CODES.tokenUrlMissing, + message: + 'WORKOS_WIF_TOKEN_URL is required for workload identity federation in GitHub Actions. ' + + 'The WorkOS server side is still being built, so there is no default yet. ' + + 'Set it to your token endpoint, shaped like https:///oauth2/token.', + }); + } + let url: URL; + try { + url = new URL(raw); + } catch { + exitWithError({ + code: WIF_ERROR_CODES.tokenUrlInvalid, + message: 'WORKOS_WIF_TOKEN_URL is not a valid URL. Expected https:///oauth2/token.', + }); + } + // The assertion is a bearer credential: plain http only to this machine. + const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) { + exitWithError({ + code: WIF_ERROR_CODES.tokenUrlInvalid, + message: 'WORKOS_WIF_TOKEN_URL must use https (http is allowed only for localhost).', + }); + } + return raw; +} + +function describeNetworkError(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error); + return redactRegisteredSecrets(detail); +} + +async function readJsonObject(response: Response): Promise | undefined> { + let text: string; + try { + text = await response.text(); + } catch { + return undefined; + } + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +async function requestGitHubIdToken(audience: string): Promise { + const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!requestUrl || !requestToken) exitWithMissingIdTokenPermission(); + registerSecret(requestToken); + + let url: URL; + try { + url = new URL(requestUrl); + } catch { + exitWithError({ + code: WIF_ERROR_CODES.idTokenRequestFailed, + message: 'ACTIONS_ID_TOKEN_REQUEST_URL is not a valid URL, so no GitHub OIDC token could be requested.', + }); + } + url.searchParams.set('audience', audience); + + let response: Response; + try { + response = await fetch(url.toString(), { + method: 'GET', + headers: { Authorization: `Bearer ${requestToken}`, Accept: 'application/json' }, + }); + } catch (error) { + exitWithError({ + code: WIF_ERROR_CODES.idTokenRequestFailed, + message: `Could not reach GitHub to request an OIDC token: ${describeNetworkError(error)}`, + }); + } + + if (response.status === 401 || response.status === 403) { + exitWithError({ + code: WIF_ERROR_CODES.idTokenUnavailable, + message: `GitHub refused to issue an OIDC token (HTTP ${response.status}). ${ID_TOKEN_PERMISSION_HINT}`, + apiContext: { status: response.status }, + }); + } + if (!response.ok) { + exitWithError({ + code: WIF_ERROR_CODES.idTokenRequestFailed, + message: `GitHub OIDC token request failed (HTTP ${response.status}).`, + apiContext: { status: response.status }, + }); + } + + const body = await readJsonObject(response); + const value = body?.value; + if (typeof value !== 'string' || value === '') { + exitWithError({ + code: WIF_ERROR_CODES.idTokenRequestFailed, + message: `GitHub OIDC token response had no token (HTTP ${response.status}).`, + apiContext: { status: response.status }, + }); + } + registerSecret(value); + return value; +} + +async function exchangeForAccessToken(): Promise { + // Fail on configuration before any network call. + const tokenUrl = resolveTokenUrl(); + const audience = process.env.WORKOS_WIF_AUDIENCE || WIF_DEFAULT_AUDIENCE; + const assertion = await requestGitHubIdToken(audience); + + const form = new URLSearchParams({ + grant_type: WIF_GRANT_TYPE, + assertion, + client_id: process.env.WORKOS_CLIENT_ID ?? '', + resource: WIF_RESOURCE, + }); + const scope = process.env.WORKOS_WIF_SCOPE; + if (scope) form.set('scope', scope); + + // Lifetime counts from before the request, so the cache errs early. + const requestedAt = Date.now(); + let response: Response; + try { + response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: form.toString(), + }); + } catch (error) { + exitWithError({ + code: WIF_ERROR_CODES.exchangeFailed, + message: `Could not reach WORKOS_WIF_TOKEN_URL for the WIF token exchange: ${describeNetworkError(error)}`, + }); + } + + const body = await readJsonObject(response); + const oauthError = typeof body?.error === 'string' ? body.error : undefined; + const description = + typeof body?.error_description === 'string' && body.error_description + ? redactRegisteredSecrets(body.error_description).slice(0, MAX_DESCRIPTION_LENGTH) + : undefined; + + if (oauthError && Object.hasOwn(OAUTH_ERROR_HINTS, oauthError)) { + exitWithError({ + code: `wif_${oauthError}`, + message: `WorkOS rejected the WIF token exchange with ${oauthError}${description ? `: ${description}` : ''}. ${OAUTH_ERROR_HINTS[oauthError]}`, + apiContext: { status: response.status, code: oauthError }, + }); + } + + const accessToken = body?.access_token; + if (!response.ok || typeof accessToken !== 'string' || accessToken === '') { + let reason: string; + if (!body) reason = 'the response was not JSON'; + else if (oauthError) reason = `${oauthError}${description ? `: ${description}` : ''}`; + else if (!response.ok) reason = 'unexpected error response'; + else reason = 'the response had no access_token'; + exitWithError({ + code: WIF_ERROR_CODES.exchangeFailed, + message: `WIF token exchange at WORKOS_WIF_TOKEN_URL failed (HTTP ${response.status}): ${reason}.`, + apiContext: { status: response.status }, + }); + } + registerSecret(accessToken); + + const reportedLifetime = Number(body?.expires_in); + const expiresIn = + Number.isFinite(reportedLifetime) && reportedLifetime > 0 ? reportedLifetime : DEFAULT_LIFETIME_SECONDS; + const lifetimeMs = expiresIn * 1000; + const marginMs = Math.min(REFRESH_MARGIN_MAX_MS, lifetimeMs * REFRESH_MARGIN_FRACTION); + return { value: accessToken, refreshAt: requestedAt + lifetimeMs - marginMs }; +} diff --git a/src/lib/workos-api.ts b/src/lib/workos-api.ts index dd7ce036..3e7d355c 100644 --- a/src/lib/workos-api.ts +++ b/src/lib/workos-api.ts @@ -3,6 +3,8 @@ * Thin fetch wrapper with auth, error parsing, and query param support. */ +import { wifAuthHeaders } from './wif.js'; + const DEFAULT_BASE_URL = 'https://api.workos.com'; export interface WorkOSRequestOptions { @@ -49,9 +51,9 @@ export async function workosRequest(options: WorkOSRequestOptions): Promise = { - Authorization: `Bearer ${apiKey}`, - }; + // An API key passes through as `Bearer `; a WIF token is refreshed if + // needed and carries x-url-environment-id when configured. + const headers: Record = await wifAuthHeaders(apiKey); const fetchOptions: RequestInit = { method, headers }; diff --git a/src/lib/workos-client.ts b/src/lib/workos-client.ts index 89756a8f..6457b7dd 100644 --- a/src/lib/workos-client.ts +++ b/src/lib/workos-client.ts @@ -13,6 +13,7 @@ import { WorkOS } from '@workos-inc/node'; import { workosRequest, type WorkOSListResponse } from './workos-api.js'; import { resolveApiKey, resolveApiBaseUrl } from './api-key.js'; +import { createWifFetch, isWifAccessToken } from './wif.js'; export interface AuditLogAction { action: string; @@ -106,6 +107,9 @@ export function createWorkOSClient(apiKey?: string, baseUrl?: string): WorkOSCLI apiHostname: url.hostname, ...(url.port && { port: Number(url.port) }), ...(url.protocol === 'http:' && { https: false }), + // A WIF token is short-lived and needs x-url-environment-id: re-apply + // both on every SDK request instead of the headers fixed at construction. + ...(isWifAccessToken(key) && { fetchFn: createWifFetch(key) }), }); return { diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts index 24f0bd8f..a6ab7baf 100644 --- a/src/utils/exit-codes.ts +++ b/src/utils/exit-codes.ts @@ -28,6 +28,18 @@ const ERROR_CODE_MAP: Record Date: Wed, 28 Oct 2026 08:50:11 -0500 Subject: [PATCH 3/8] test(wif): cover credential order, exchange, caching, errors and token leaks --- src/commands/debug.ts | 5 +- src/lib/api-key.spec.ts | 56 ++++ src/lib/wif.spec.ts | 615 ++++++++++++++++++++++++++++++++++ src/lib/workos-client.spec.ts | 17 + 4 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 src/lib/wif.spec.ts diff --git a/src/commands/debug.ts b/src/commands/debug.ts index c5c93847..97bade3d 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -390,7 +390,10 @@ export const ENV_VAR_CATALOG: { name: string; effect: string }[] = [ name: 'WORKOS_WIF_TOKEN_URL', effect: 'WIF token endpoint (https:///oauth2/token); required for WIF, no default yet', }, - { name: 'WORKOS_WIF_AUDIENCE', effect: 'Audience for the GitHub OIDC token used by WIF (default: https://api.workos.com)' }, + { + name: 'WORKOS_WIF_AUDIENCE', + effect: 'Audience for the GitHub OIDC token used by WIF (default: https://api.workos.com)', + }, { name: 'WORKOS_WIF_SCOPE', effect: 'Optional scope sent with the WIF token exchange' }, { name: 'WORKOS_ENVIRONMENT_ID', diff --git a/src/lib/api-key.spec.ts b/src/lib/api-key.spec.ts index 9d5e1a91..7ad7798e 100644 --- a/src/lib/api-key.spec.ts +++ b/src/lib/api-key.spec.ts @@ -51,6 +51,11 @@ describe('api-key', () => { delete process.env.WORKOS_API_KEY; delete process.env.WORKOS_API_URL; delete process.env.WORKOS_API_BASE_URL; + // Keep these tests deterministic when the suite itself runs in GitHub Actions. + delete process.env.GITHUB_ACTIONS; + delete process.env.WORKOS_CLIENT_ID; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; }); afterEach(() => { @@ -117,6 +122,57 @@ describe('api-key', () => { }); expect(resolveApiKey({ apiKey: '' })).toBe('sk_stored'); }); + + // Full WIF coverage (exchange, caching, errors) lives in wif.spec.ts. + describe('in GitHub Actions with WORKOS_CLIENT_ID (workload identity federation)', () => { + beforeEach(() => { + process.env.GITHUB_ACTIONS = 'true'; + process.env.WORKOS_CLIENT_ID = 'client_m2m'; + }); + + it('WORKOS_API_KEY still wins', () => { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://token.example.com/idtoken?api-version=2.0'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'request-token'; + process.env.WORKOS_API_KEY = 'sk_env_var'; + expect(resolveApiKey()).toBe('sk_env_var'); + }); + + it('exits wif_unsupported_command when WIF applies but was not primed', () => { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://token.example.com/idtoken?api-version=2.0'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'request-token'; + saveConfig({ + activeEnvironment: 'prod', + environments: { prod: { name: 'prod', type: 'production', apiKey: 'sk_stored' } }, + }); + expect(() => resolveApiKey()).toThrow(ExitError); + expect(mockExitWithError).toHaveBeenCalledWith(expect.objectContaining({ code: 'wif_unsupported_command' })); + }); + + it('without the OIDC request variables, uses a stored key as today', () => { + saveConfig({ + activeEnvironment: 'prod', + environments: { prod: { name: 'prod', type: 'production', apiKey: 'sk_stored' } }, + }); + expect(resolveApiKey()).toBe('sk_stored'); + }); + + it('without the OIDC request variables or any key, asks for id-token: write', () => { + mockExitWithError.mockClear(); + expect(() => resolveApiKey()).toThrow(ExitError); + expect(mockExitWithError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'wif_id_token_unavailable', + message: expect.stringContaining('permissions: id-token: write'), + }), + ); + }); + + it('resolveOptionalApiKey ignores WIF', () => { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://token.example.com/idtoken?api-version=2.0'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'request-token'; + expect(resolveOptionalApiKey()).toBeUndefined(); + }); + }); }); describe('resolveOptionalApiKey', () => { diff --git a/src/lib/wif.spec.ts b/src/lib/wif.spec.ts new file mode 100644 index 00000000..31f23b21 --- /dev/null +++ b/src/lib/wif.spec.ts @@ -0,0 +1,615 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Stored profile for the credential-order tests; undefined = no profile. +let activeEnv: { name: string; type: 'production' | 'sandbox'; apiKey: string } | undefined; +vi.mock('./config-store.js', () => ({ + getActiveEnvironment: () => activeEnv, +})); + +const { + getWifAccessToken, + getWifState, + isWifAccessToken, + resetWifCacheForTests, + WIF_GRANT_TYPE, + ENVIRONMENT_ID_HEADER, +} = await import('./wif.js'); +const { resolveApiKey, resolveApiKeyAsync, resolveOptionalApiKey, primeApiCredentials } = await import('./api-key.js'); +const { apiRequest } = await import('../commands/api/request.js'); +const { workosRequest } = await import('./workos-api.js'); +const { createWorkOSClient } = await import('./workos-client.js'); +const { runVerifyLogin } = await import('../commands/verify-login.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { setOutputMode } = await import('../utils/output.js'); +const { sanitizeMessage, sanitizeStack } = await import('../utils/crash-reporter.js'); +const { redactCredentials } = await import('../utils/redact.js'); + +// Distinctive values so any leak is easy to spot. +const GITHUB_JWT = 'eyJhbGciOiJSUzI1NiJ9.eyJyZXBvIjoiYWNtZS9hcHAifQ.github-jwt-signature-SECRET'; +const ACCESS_TOKEN = 'wos_m2m_access_token_SECRET_0001'; +const ACCESS_TOKEN_2 = 'wos_m2m_access_token_SECRET_0002'; +const REQUEST_TOKEN = 'actions-runtime-request-token-SECRET'; + +const ID_TOKEN_REQUEST_URL = 'https://pipelines.actions.githubusercontent.com/abc123/idtoken?api-version=2.0'; +const TOKEN_URL = 'https://auth.example.com/oauth2/token'; +const API_BASE = 'https://api.workos.com'; + +type Handler = (url: string, init: RequestInit | undefined) => Response | Promise; + +interface FetchCall { + url: string; + init: RequestInit | undefined; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +function headersOf(call: FetchCall): Headers { + return new Headers(call.init?.headers); +} + +let calls: FetchCall[]; +let githubHandler: Handler; +let tokenHandler: Handler; +let apiHandler: Handler; +let accessTokens: string[]; + +function githubCalls(): FetchCall[] { + return calls.filter((c) => c.url.startsWith('https://pipelines.actions.githubusercontent.com/')); +} +function tokenCalls(): FetchCall[] { + return calls.filter((c) => c.url === TOKEN_URL); +} +function apiCalls(): FetchCall[] { + return calls.filter((c) => c.url.startsWith(API_BASE)); +} + +/** Await a promise expected to reject with CliExit; return it with the structured error that was printed. */ +async function expectCliExit(promise: Promise): Promise> { + const error = await promise.then( + () => { + throw new Error('expected a CliExit'); + }, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(CliExit); + return error as InstanceType; +} + +let stderr: string[]; +let stdout: string[]; + +describe('workload identity federation (wif)', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_URL; + delete process.env.WORKOS_API_BASE_URL; + delete process.env.WORKOS_WIF_AUDIENCE; + delete process.env.WORKOS_WIF_SCOPE; + delete process.env.WORKOS_ENVIRONMENT_ID; + process.env.GITHUB_ACTIONS = 'true'; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ID_TOKEN_REQUEST_URL; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = REQUEST_TOKEN; + process.env.WORKOS_CLIENT_ID = 'client_m2m_123'; + process.env.WORKOS_WIF_TOKEN_URL = TOKEN_URL; + + activeEnv = undefined; + resetWifCacheForTests(); + setOutputMode('human'); + + calls = []; + accessTokens = [ACCESS_TOKEN, ACCESS_TOKEN_2]; + githubHandler = () => json({ value: GITHUB_JWT }); + tokenHandler = () => json({ access_token: accessTokens.shift(), token_type: 'Bearer', expires_in: 300 }); + apiHandler = () => json({ data: [], list_metadata: { before: null, after: null } }); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + calls.push({ url, init }); + if (url.startsWith('https://pipelines.actions.githubusercontent.com/')) return githubHandler(url, init); + if (url === TOKEN_URL) return tokenHandler(url, init); + return apiHandler(url, init); + }); + + stderr = []; + stdout = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderr.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + stdout.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + process.env = originalEnv; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('credential order', () => { + it('WORKOS_API_KEY wins: used exactly as today, with no WIF exchange', async () => { + process.env.WORKOS_API_KEY = 'sk_test_env_key'; + activeEnv = { name: 'prod', type: 'production', apiKey: 'sk_test_stored' }; + + expect(await resolveApiKeyAsync()).toBe('sk_test_env_key'); + expect(resolveApiKey()).toBe('sk_test_env_key'); + expect(calls).toHaveLength(0); + }); + + it('--api-key wins over WIF too', async () => { + expect(await resolveApiKeyAsync({ apiKey: 'sk_test_flag' })).toBe('sk_test_flag'); + expect(calls).toHaveLength(0); + }); + + it('uses WIF next, ahead of a stored profile key', async () => { + activeEnv = { name: 'prod', type: 'production', apiKey: 'sk_test_stored' }; + + expect(await resolveApiKeyAsync()).toBe(ACCESS_TOKEN); + expect(resolveApiKey()).toBe(ACCESS_TOKEN); + expect(githubCalls()).toHaveLength(1); + expect(tokenCalls()).toHaveLength(1); + expect(isWifAccessToken(ACCESS_TOKEN)).toBe(true); + }); + + it('falls back to the stored profile key outside GitHub Actions', async () => { + delete process.env.GITHUB_ACTIONS; + activeEnv = { name: 'prod', type: 'production', apiKey: 'sk_test_stored' }; + + expect(getWifState()).toBe('off'); + expect(await resolveApiKeyAsync()).toBe('sk_test_stored'); + expect(calls).toHaveLength(0); + }); + + it('falls back to the stored profile key when WORKOS_CLIENT_ID is unset', async () => { + delete process.env.WORKOS_CLIENT_ID; + activeEnv = { name: 'prod', type: 'production', apiKey: 'sk_test_stored' }; + + expect(getWifState()).toBe('off'); + expect(await resolveApiKeyAsync()).toBe('sk_test_stored'); + expect(calls).toHaveLength(0); + }); + + it("keeps today's no_api_key error when nothing is configured and WIF doesn't apply", async () => { + delete process.env.GITHUB_ACTIONS; + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('no_api_key'); + expect(exit.exitCode).toBe(4); + expect(stderr.join('\n')).toContain('No API key configured'); + expect(calls).toHaveLength(0); + }); + + it('requires GITHUB_ACTIONS to be exactly "true"', () => { + process.env.GITHUB_ACTIONS = '1'; + expect(getWifState()).toBe('off'); + }); + + it('uses a stored profile key as today when the Actions OIDC variables are missing', async () => { + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + activeEnv = { name: 'ci', type: 'sandbox', apiKey: 'sk_test_stored' }; + + expect(getWifState()).toBe('missing_id_token_env'); + expect(await resolveApiKeyAsync()).toBe('sk_test_stored'); + expect(calls).toHaveLength(0); + }); + + it('resolveOptionalApiKey (workos migrations) never uses WIF', async () => { + expect(resolveOptionalApiKey()).toBeUndefined(); + activeEnv = { name: 'ci', type: 'sandbox', apiKey: 'sk_test_stored' }; + expect(resolveOptionalApiKey()).toBe('sk_test_stored'); + expect(calls).toHaveLength(0); + }); + + it('primeApiCredentials is a no-op when an API key is set', async () => { + await primeApiCredentials({ apiKey: 'sk_test_flag' }); + process.env.WORKOS_API_KEY = 'sk_test_env_key'; + await primeApiCredentials(); + expect(calls).toHaveLength(0); + }); + + it('a synchronous caller that was never primed exits instead of falling back', async () => { + activeEnv = { name: 'ci', type: 'sandbox', apiKey: 'sk_test_stored' }; + + const exit = await expectCliExit(Promise.resolve().then(() => resolveApiKey())); + expect(exit.context?.errorCode).toBe('wif_unsupported_command'); + expect(stderr.join('\n')).toContain('Set WORKOS_API_KEY'); + }); + }); + + describe('GitHub OIDC token request', () => { + it('requests the default audience with the Actions request token', async () => { + await getWifAccessToken(); + + const [call] = githubCalls(); + const url = new URL(call!.url); + expect(url.origin + url.pathname).toBe('https://pipelines.actions.githubusercontent.com/abc123/idtoken'); + expect(url.searchParams.get('api-version')).toBe('2.0'); + expect(url.searchParams.get('audience')).toBe('https://api.workos.com'); + expect(call!.init?.method).toBe('GET'); + expect(headersOf(call!).get('authorization')).toBe(`Bearer ${REQUEST_TOKEN}`); + }); + + it('WORKOS_WIF_AUDIENCE overrides the audience, URL-encoded as a proper parameter', async () => { + process.env.WORKOS_WIF_AUDIENCE = 'https://custom.example.com/aud?x=1&y=2'; + await getWifAccessToken(); + + const url = new URL(githubCalls()[0]!.url); + expect(url.searchParams.get('audience')).toBe('https://custom.example.com/aud?x=1&y=2'); + expect(url.searchParams.get('api-version')).toBe('2.0'); + expect(url.searchParams.getAll('audience')).toHaveLength(1); + }); + }); + + describe('token exchange', () => { + it('posts exactly the jwt-bearer form fields, without scope by default', async () => { + await getWifAccessToken(); + + const [call] = tokenCalls(); + expect(call!.init?.method).toBe('POST'); + expect(headersOf(call!).get('content-type')).toBe('application/x-www-form-urlencoded'); + const form = new URLSearchParams(String(call!.init?.body)); + expect(Object.fromEntries(form)).toEqual({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: GITHUB_JWT, + client_id: 'client_m2m_123', + resource: 'https://api.workos.com', + }); + expect(WIF_GRANT_TYPE).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + }); + + it('adds scope only when WORKOS_WIF_SCOPE is set', async () => { + process.env.WORKOS_WIF_SCOPE = 'organizations:read organizations:write'; + await getWifAccessToken(); + + const form = new URLSearchParams(String(tokenCalls()[0]!.init?.body)); + expect(form.get('scope')).toBe('organizations:read organizations:write'); + expect([...form.keys()].sort()).toEqual(['assertion', 'client_id', 'grant_type', 'resource', 'scope']); + }); + }); + + describe('API calls', () => { + it('sends the access token and x-url-environment-id when WORKOS_ENVIRONMENT_ID is set', async () => { + process.env.WORKOS_ENVIRONMENT_ID = 'environment_01ABC'; + + await apiRequest({ method: 'GET', path: '/organizations', baseUrl: API_BASE }); + + const headers = headersOf(apiCalls()[0]!); + expect(headers.get('authorization')).toBe(`Bearer ${ACCESS_TOKEN}`); + expect(headers.get(ENVIRONMENT_ID_HEADER)).toBe('environment_01ABC'); + }); + + it('omits x-url-environment-id when WORKOS_ENVIRONMENT_ID is unset', async () => { + await apiRequest({ method: 'GET', path: '/organizations', baseUrl: API_BASE }); + + const headers = headersOf(apiCalls()[0]!); + expect(headers.get('authorization')).toBe(`Bearer ${ACCESS_TOKEN}`); + expect(headers.has(ENVIRONMENT_ID_HEADER)).toBe(false); + }); + + it('never adds x-url-environment-id to an API key request', async () => { + process.env.WORKOS_API_KEY = 'sk_test_env_key'; + process.env.WORKOS_ENVIRONMENT_ID = 'environment_01ABC'; + + await apiRequest({ method: 'GET', path: '/organizations', baseUrl: API_BASE }); + + const headers = headersOf(apiCalls()[0]!); + expect(headers.get('authorization')).toBe('Bearer sk_test_env_key'); + expect(headers.has(ENVIRONMENT_ID_HEADER)).toBe(false); + expect(tokenCalls()).toHaveLength(0); + }); + + it('workosRequest and the SDK client both send the WIF headers', async () => { + process.env.WORKOS_ENVIRONMENT_ID = 'environment_01ABC'; + const token = await resolveApiKeyAsync(); + + await workosRequest({ method: 'GET', path: '/audit_logs/actions', apiKey: token, baseUrl: API_BASE }); + await createWorkOSClient(token, API_BASE).sdk.organizations.listOrganizations(); + + const sent = apiCalls(); + expect(sent).toHaveLength(2); + for (const call of sent) { + expect(headersOf(call).get('authorization')).toBe(`Bearer ${ACCESS_TOKEN}`); + expect(headersOf(call).get(ENVIRONMENT_ID_HEADER)).toBe('environment_01ABC'); + } + }); + }); + + describe('caching', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + it('does not exchange again while the token is fresh', async () => { + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN); + vi.advanceTimersByTime(200_000); + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN); + expect(await resolveApiKeyAsync()).toBe(ACCESS_TOKEN); + + expect(githubCalls()).toHaveLength(1); + expect(tokenCalls()).toHaveLength(1); + }); + + it('exchanges again once less than 60 s is left of a 300 s token', async () => { + await getWifAccessToken(); + vi.advanceTimersByTime(239_000); + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN); + vi.advanceTimersByTime(2_000); + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN_2); + + expect(githubCalls()).toHaveLength(2); + expect(tokenCalls()).toHaveLength(2); + }); + + it('uses 20% of the lifetime when that margin is smaller than 60 s', async () => { + tokenHandler = () => json({ access_token: accessTokens.shift(), token_type: 'Bearer', expires_in: 100 }); + + await getWifAccessToken(); + vi.advanceTimersByTime(79_000); + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN); + vi.advanceTimersByTime(2_000); + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN_2); + }); + + it('uses 60 s, not 20%, for a 600 s token', async () => { + tokenHandler = () => json({ access_token: accessTokens.shift(), token_type: 'Bearer', expires_in: 600 }); + + await getWifAccessToken(); + vi.advanceTimersByTime(539_000); + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN); + vi.advanceTimersByTime(2_000); + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN_2); + }); + + it('a request holding an old token sends a refreshed one, never the expiring one', async () => { + const token = await resolveApiKeyAsync(); + vi.advanceTimersByTime(290_000); + + await apiRequest({ method: 'GET', path: '/organizations', apiKey: token, baseUrl: API_BASE }); + await createWorkOSClient(token, API_BASE).sdk.organizations.listOrganizations(); + + for (const call of apiCalls()) { + expect(headersOf(call).get('authorization')).toBe(`Bearer ${ACCESS_TOKEN_2}`); + } + expect(tokenCalls()).toHaveLength(2); + }); + + it('concurrent callers share one in-flight exchange', async () => { + const tokens = await Promise.all([ + getWifAccessToken(), + getWifAccessToken(), + resolveApiKeyAsync(), + primeApiCredentials().then(() => resolveApiKey()), + apiRequest({ method: 'GET', path: '/organizations', baseUrl: API_BASE }).then(() => ACCESS_TOKEN), + ]); + + expect(new Set(tokens)).toEqual(new Set([ACCESS_TOKEN])); + expect(githubCalls()).toHaveLength(1); + expect(tokenCalls()).toHaveLength(1); + }); + + it('does not cache a failed exchange', async () => { + tokenHandler = () => new Response('upstream down', { status: 503 }); + await expectCliExit(getWifAccessToken()); + + tokenHandler = () => json({ access_token: ACCESS_TOKEN, token_type: 'Bearer', expires_in: 300 }); + expect(await getWifAccessToken()).toBe(ACCESS_TOKEN); + expect(tokenCalls()).toHaveLength(2); + }); + }); + + describe('errors', () => { + it('missing Actions OIDC variables with no other credential: tells the user to add id-token: write', async () => { + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_id_token_unavailable'); + expect(exit.exitCode).toBe(4); + expect(stderr.join('\n')).toContain('permissions: id-token: write'); + expect(calls).toHaveLength(0); + }); + + it.each([401, 403])('GitHub OIDC request %i: tells the user to add id-token: write', async (status) => { + githubHandler = () => json({ message: 'Unauthorized' }, status); + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_id_token_unavailable'); + expect(exit.exitCode).toBe(4); + expect(stderr.join('\n')).toContain('permissions: id-token: write'); + expect(stderr.join('\n')).toContain(`HTTP ${status}`); + expect(tokenCalls()).toHaveLength(0); + }); + + it('GitHub OIDC request 500: generic message with the status', async () => { + githubHandler = () => new Response('oops', { status: 500 }); + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_id_token_request_failed'); + expect(exit.exitCode).toBe(1); + expect(stderr.join('\n')).toContain('HTTP 500'); + }); + + it('GitHub OIDC response without a token value', async () => { + githubHandler = () => json({ count: 0 }); + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_id_token_request_failed'); + expect(stderr.join('\n')).toContain('had no token (HTTP 200)'); + }); + + it('missing WORKOS_WIF_TOKEN_URL: says it is required, why, and its shape, with no fallback', async () => { + delete process.env.WORKOS_WIF_TOKEN_URL; + activeEnv = { name: 'ci', type: 'sandbox', apiKey: 'sk_test_stored' }; + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_token_url_missing'); + expect(exit.exitCode).toBe(4); + const message = stderr.join('\n'); + expect(message).toContain('WORKOS_WIF_TOKEN_URL is required'); + expect(message).toContain('still being built'); + expect(message).toContain('https:///oauth2/token'); + expect(calls).toHaveLength(0); + }); + + it('rejects a plain-http WORKOS_WIF_TOKEN_URL that is not localhost', async () => { + process.env.WORKOS_WIF_TOKEN_URL = 'http://auth.example.com/oauth2/token'; + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_token_url_invalid'); + expect(calls).toHaveLength(0); + }); + + it.each([ + ['invalid_grant', 'trust rules'], + ['invalid_target', 'audience'], + ['invalid_client', 'WORKOS_CLIENT_ID'], + ['invalid_scope', 'WORKOS_WIF_SCOPE'], + ['invalid_request', '/oauth2/token'], + ])('%s: names the error, includes error_description and a hint', async (error, hint) => { + tokenHandler = () => json({ error, error_description: `server says ${error} happened` }, 400); + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe(`wif_${error}`); + expect(exit.exitCode).toBe(4); + const message = stderr.join('\n'); + expect(message).toContain(error); + expect(message).toContain(`server says ${error} happened`); + expect(message).toContain(hint); + }); + + it('non-JSON response: generic message with the HTTP status', async () => { + tokenHandler = () => new Response('Bad Gateway', { status: 502 }); + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_exchange_failed'); + expect(exit.exitCode).toBe(1); + expect(stderr.join('\n')).toContain('HTTP 502'); + expect(stderr.join('\n')).toContain('not JSON'); + expect(stderr.join('\n')).not.toContain('Bad Gateway'); + }); + + it('network error: generic message', async () => { + tokenHandler = () => { + throw new TypeError('fetch failed: ECONNREFUSED'); + }; + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_exchange_failed'); + expect(stderr.join('\n')).toContain('Could not reach WORKOS_WIF_TOKEN_URL'); + expect(stderr.join('\n')).toContain('ECONNREFUSED'); + }); + + it('success status without access_token: generic message with the HTTP status', async () => { + tokenHandler = () => json({ token_type: 'Bearer', expires_in: 300 }); + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_exchange_failed'); + expect(stderr.join('\n')).toContain('HTTP 200'); + expect(stderr.join('\n')).toContain('no access_token'); + }); + + it('unrecognized OAuth error: generic message with the status and error', async () => { + tokenHandler = () => json({ error: 'server_error', error_description: 'try later' }, 500); + + const exit = await expectCliExit(resolveApiKeyAsync()); + expect(exit.context?.errorCode).toBe('wif_exchange_failed'); + expect(stderr.join('\n')).toContain('HTTP 500'); + expect(stderr.join('\n')).toContain('server_error: try later'); + }); + + it('verify-login refuses a WIF token, since it cannot confirm the environment is not production', async () => { + const token = await resolveApiKeyAsync(); + + const exit = await expectCliExit( + runVerifyLogin({ apiKey: token, clientId: 'client_x', baseUrl: API_BASE, envType: null, method: 'password' }), + ); + expect(exit.context?.errorCode).toBe('wif_unsupported_command'); + expect(stderr.join('\n')).toContain('sandbox'); + expect(apiCalls()).toHaveLength(0); + }); + }); + + it('never leaks either token', async () => { + const messages: string[] = []; + const writes: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }); + + async function capture(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + messages.push(String(error), error instanceof Error ? `${error.message}\n${error.stack}` : ''); + } + resetWifCacheForTests(); + } + + for (const mode of ['human', 'json'] as const) { + setOutputMode(mode); + + // Server echoes the assertion back in error_description. + tokenHandler = () => json({ error: 'invalid_grant', error_description: `bad assertion ${GITHUB_JWT}` }, 400); + await capture(() => resolveApiKeyAsync()); + + // Network error whose message carries the assertion. + tokenHandler = () => { + throw new TypeError(`socket hang up while sending ${GITHUB_JWT}`); + }; + await capture(() => resolveApiKeyAsync()); + + // Unrecognized error echoing both tokens. + tokenHandler = () => + json({ error: 'server_error', error_description: `${GITHUB_JWT} / ${ACCESS_TOKEN}`, access_token: '' }, 500); + await capture(() => resolveApiKeyAsync()); + + // Non-JSON body echoing the request. + tokenHandler = () => new Response(`assertion=${GITHUB_JWT}`, { status: 400 }); + await capture(() => resolveApiKeyAsync()); + + // Successful exchange, then failures downstream of it. + tokenHandler = () => json({ access_token: ACCESS_TOKEN, token_type: 'Bearer', expires_in: 300 }); + await capture(async () => { + const token = await resolveApiKeyAsync(); + await runVerifyLogin({ + apiKey: token, + clientId: 'client_x', + baseUrl: API_BASE, + envType: null, + method: 'password', + }); + }); + await capture(async () => { + const token = await resolveApiKeyAsync(); + apiHandler = () => json({ message: 'Unauthorized' }, 401); + await workosRequest({ method: 'GET', path: '/organizations', apiKey: token, baseUrl: API_BASE }); + }); + apiHandler = () => json({ data: [], list_metadata: { before: null, after: null } }); + } + + // Debug-log and telemetry redaction helpers know both values. + const redactedForTelemetry = [ + sanitizeMessage(`failed with ${GITHUB_JWT} and ${ACCESS_TOKEN}`), + sanitizeStack(`Error: ${ACCESS_TOKEN}\n at x (/src/lib/wif.ts:1:1)`), + JSON.stringify(redactCredentials({ assertion: GITHUB_JWT, nested: { token: ACCESS_TOKEN } })), + ]; + + const everything = [...messages, ...writes, ...stdout, ...stderr, ...redactedForTelemetry].join('\n'); + expect(stderr.length).toBeGreaterThan(0); + expect(everything).not.toContain(GITHUB_JWT); + expect(everything).not.toContain(ACCESS_TOKEN); + expect(everything).not.toContain(REQUEST_TOKEN); + }); +}); diff --git a/src/lib/workos-client.spec.ts b/src/lib/workos-client.spec.ts index 90f3a2e9..f995f6a6 100644 --- a/src/lib/workos-client.spec.ts +++ b/src/lib/workos-client.spec.ts @@ -22,6 +22,13 @@ vi.mock('./api-key.js', () => ({ resolveApiBaseUrl: () => 'https://api.workos.com', })); +// WIF: treat one sentinel value as an issued access token (wif.spec.ts covers the real exchange). +const mockWifFetch = vi.fn(); +vi.mock('./wif.js', () => ({ + isWifAccessToken: (credential: string | undefined) => credential === 'wif_access_token', + createWifFetch: () => mockWifFetch, +})); + const { workosRequest, WorkOSApiError } = await import('./workos-api.js'); const mockRequest = vi.mocked(workosRequest); @@ -46,6 +53,16 @@ describe('workos-client', () => { expect(client.sdk.baseURL).toBe('https://api.workos.com'); }); + it('leaves the SDK fetch untouched for an API key', () => { + const client = createWorkOSClient('sk_test_123'); + expect((client.sdk as unknown as { options: { fetchFn?: unknown } }).options.fetchFn).toBeUndefined(); + }); + + it('gives the SDK a refreshing fetch for a WIF access token', () => { + const client = createWorkOSClient('wif_access_token', 'https://api.workos.com'); + expect((client.sdk as unknown as { options: { fetchFn?: unknown } }).options.fetchFn).toBe(mockWifFetch); + }); + it('exposes sdk, redirectUris, corsOrigins, homepageUrl', () => { const client = createWorkOSClient('sk_test_123'); expect(client.sdk).toBeDefined(); From fcf5220ca08ed4419f972d0f5805575b1f4c35f1 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 28 Oct 2026 08:50:11 -0500 Subject: [PATCH 4/8] docs(examples): add GitHub Actions workload identity federation example --- .../.github/workflows/wif-test.yml | 178 ++++++++++++++++++ examples/wif-github-actions/README.md | 86 +++++++++ 2 files changed, 264 insertions(+) create mode 100644 examples/wif-github-actions/.github/workflows/wif-test.yml create mode 100644 examples/wif-github-actions/README.md diff --git a/examples/wif-github-actions/.github/workflows/wif-test.yml b/examples/wif-github-actions/.github/workflows/wif-test.yml new file mode 100644 index 00000000..6bf3708f --- /dev/null +++ b/examples/wif-github-actions/.github/workflows/wif-test.yml @@ -0,0 +1,178 @@ +# Calls the WorkOS API from GitHub Actions with no WORKOS_API_KEY, using +# workload identity federation (WIF): the job's GitHub OIDC token is exchanged +# for a short-lived WorkOS access token. See ../../README.md for setup. +# +# The WorkOS server side (the `management-api-oauth` flag) is not deployed +# yet, so this only works against an environment where it has been enabled. + +name: WorkOS WIF test + +on: + workflow_dispatch: + inputs: + cli_ref: + description: >- + workos/cli branch, tag or SHA to run from source in the CLI job. Leave empty to install + the published `workos` package (WIF is not in a published release yet). + required: false + default: '' + +permissions: + id-token: write # lets the job request a GitHub OIDC token + contents: read + +env: + WORKOS_CLIENT_ID: ${{ vars.WORKOS_CLIENT_ID }} + WORKOS_WIF_TOKEN_URL: ${{ vars.WORKOS_WIF_TOKEN_URL }} + WORKOS_API_URL: ${{ vars.WORKOS_API_URL || 'https://api.workos.com' }} + WORKOS_ENVIRONMENT_ID: ${{ vars.WORKOS_ENVIRONMENT_ID }} + # Must match the audience in the M2M application's trust rule. + WORKOS_WIF_AUDIENCE: https://api.workos.com + +jobs: + curl: + name: Plain curl + runs-on: ubuntu-latest + env: + ORG_NAME: wif-test-${{ github.run_id }} + steps: + # One step on purpose: the access token stays in this shell's memory and + # is never written to GITHUB_ENV or a file. No `set -x` anywhere, and + # both tokens are masked before anything else can print. + - name: Exchange the OIDC token, then list and create organizations + shell: bash + run: | + set -euo pipefail + + : "${WORKOS_CLIENT_ID:?Set the WORKOS_CLIENT_ID repository variable}" + : "${WORKOS_WIF_TOKEN_URL:?Set the WORKOS_WIF_TOKEN_URL repository variable}" + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "::error::No OIDC token available. Add 'permissions: id-token: write' to the workflow or job." + exit 1 + fi + + echo "::group::1. Request a GitHub OIDC token (audience ${WORKOS_WIF_AUDIENCE})" + id_token=$(curl -sS --fail \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + --get --data-urlencode "audience=${WORKOS_WIF_AUDIENCE}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}" | jq -r '.value // empty') + if [ -z "$id_token" ]; then + echo "::error::GitHub returned no OIDC token." + exit 1 + fi + echo "::add-mask::${id_token}" + echo "Got a GitHub OIDC token." + echo "::endgroup::" + + echo "::group::2. Exchange it at WORKOS_WIF_TOKEN_URL" + form=( + --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" + --data-urlencode "assertion=${id_token}" + --data-urlencode "client_id=${WORKOS_CLIENT_ID}" + --data-urlencode "resource=https://api.workos.com" + ) + if [ -n "${WORKOS_WIF_SCOPE:-}" ]; then + form+=(--data-urlencode "scope=${WORKOS_WIF_SCOPE}") + fi + response=$(curl -sS -X POST "${WORKOS_WIF_TOKEN_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'Accept: application/json' \ + "${form[@]}" \ + -w '\n%{http_code}') + status=${response##*$'\n'} + body=${response%$'\n'*} + access_token=$(printf '%s' "$body" | jq -r '.access_token // empty' 2>/dev/null || true) + if [ "$status" != "200" ] || [ -z "$access_token" ]; then + # Print only the OAuth error fields, never the whole body. + printf '%s' "$body" | jq -c '{error, error_description}' 2>/dev/null || echo "(response was not JSON)" + echo "::error::Token exchange failed (HTTP ${status}). See the README's common errors." + exit 1 + fi + echo "::add-mask::${access_token}" + echo "Got a WorkOS access token (expires in $(printf '%s' "$body" | jq -r '.expires_in')s)." + echo "::endgroup::" + + auth=(-H "Authorization: Bearer ${access_token}") + if [ -n "${WORKOS_ENVIRONMENT_ID:-}" ]; then + auth+=(-H "x-url-environment-id: ${WORKOS_ENVIRONMENT_ID}") + fi + + echo "::group::3. GET /organizations" + curl -sS --fail-with-body "${auth[@]}" "${WORKOS_API_URL}/organizations?limit=5" | + jq '{count: (.data | length), names: [.data[].name]}' + echo "::endgroup::" + + echo "::group::4. POST /organizations (${ORG_NAME})" + org=$(curl -sS --fail-with-body "${auth[@]}" \ + -H 'Content-Type: application/json' \ + -d "{\"name\": \"${ORG_NAME}\"}" \ + "${WORKOS_API_URL}/organizations") + org_id=$(printf '%s' "$org" | jq -r '.id') + printf '%s' "$org" | jq '{id, name}' + echo "::endgroup::" + + echo "::group::5. Confirm it is listed" + curl -sS --fail-with-body "${auth[@]}" "${WORKOS_API_URL}/organizations?limit=100" | + jq -e --arg id "$org_id" 'any(.data[]; .id == $id)' >/dev/null + echo "::endgroup::" + + echo "Created and listed ${ORG_NAME} (${org_id}) with a WIF access token." + + cli: + name: workos CLI + runs-on: ubuntu-latest + env: + ORG_NAME: wif-test-${{ github.run_id }}-cli + CLI_REF: ${{ inputs.cli_ref }} + steps: + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # 2.2.0 + + - name: Install the published CLI + if: inputs.cli_ref == '' + run: bun add --global workos@latest + + - name: Check out the CLI branch + if: inputs.cli_ref != '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 7.0.1 + with: + repository: workos/cli + ref: ${{ inputs.cli_ref }} + path: workos-cli + persist-credentials: false + + - name: Run the CLI branch from source + if: inputs.cli_ref != '' + working-directory: workos-cli + run: | + bun install --frozen-lockfile + mkdir -p "$RUNNER_TEMP/bin" + printf '#!/usr/bin/env bash\nexec bun "%s/src/bin.ts" "$@"\n' "$PWD" > "$RUNNER_TEMP/bin/workos" + chmod +x "$RUNNER_TEMP/bin/workos" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + + - name: Check there is no API key, so the CLI must use WIF + run: | + if [ -n "${WORKOS_API_KEY:-}" ]; then + echo "::error::Unset WORKOS_API_KEY: an API key always wins over WIF." + exit 1 + fi + workos --version + + # Each `workos` process does its own exchange and keeps the token in memory only. + - name: workos api GET /organizations + run: | + workos api '/organizations?limit=5' | jq '{count: (.data | length), names: [.data[].name]}' + + - name: workos api POST /organizations + id: create + run: | + org=$(workos api /organizations --method POST --data "{\"name\": \"${ORG_NAME}\"}" --yes) + printf '%s' "$org" | jq '{id, name}' + echo "org_id=$(printf '%s' "$org" | jq -r '.id')" >> "$GITHUB_OUTPUT" + + - name: Confirm it is listed + env: + ORG_ID: ${{ steps.create.outputs.org_id }} + run: | + workos api '/organizations?limit=100' | jq -e --arg id "$ORG_ID" 'any(.data[]; .id == $id)' >/dev/null + echo "Created and listed ${ORG_NAME} (${ORG_ID}) through the CLI with no WORKOS_API_KEY." diff --git a/examples/wif-github-actions/README.md b/examples/wif-github-actions/README.md new file mode 100644 index 00000000..acfb92b2 --- /dev/null +++ b/examples/wif-github-actions/README.md @@ -0,0 +1,86 @@ +# WorkOS API from GitHub Actions with workload identity federation + +This example calls the WorkOS API from a GitHub Actions workflow with **no `WORKOS_API_KEY`**. The job asks GitHub for an OIDC ID token, exchanges it at WorkOS for a short-lived access token (the OAuth `urn:ietf:params:oauth:grant-type:jwt-bearer` grant), and uses that token as the Bearer for API calls. + +> **The server side isn't deployed yet.** The WorkOS token endpoint for this grant is still being built, behind the `management-api-oauth` feature flag, and there is no production endpoint. This example only works against an environment where that flag is on, with `WORKOS_WIF_TOKEN_URL` pointing at its token endpoint. + +[`.github/workflows/wif-test.yml`](.github/workflows/wif-test.yml) has two jobs that do the same thing: + +- **`curl`**: plain bash and curl. It requests the OIDC token, does the exchange, runs `GET /organizations`, creates an organization with `POST /organizations` named `wif-test-`, and checks that the new organization is listed. +- **`cli`**: the same through the `workos` CLI (`workos api ...`). The CLI does the exchange itself when it runs in GitHub Actions with `WORKOS_CLIENT_ID` set and no API key. It names its organization `wif-test--cli`. + +Neither job prints a token. Both tokens are masked with `::add-mask::`, nothing uses `set -x`, and the curl job keeps the access token in shell memory instead of writing it to `GITHUB_ENV`. + +## 1. Create a test repository + +Create a new repository (a private one is fine) and copy the workflow into it: + +```bash +mkdir -p .github/workflows +cp path/to/workos-cli/examples/wif-github-actions/.github/workflows/wif-test.yml .github/workflows/ +git add .github/workflows/wif-test.yml +git commit -m "Add WorkOS WIF test workflow" +git push +``` + +The workflow runs only when you start it (`workflow_dispatch`). It needs `permissions: id-token: write` (already set) so GitHub hands the job an OIDC token, and `contents: read`. + +## 2. Set the repository variables + +Under **Settings → Secrets and variables → Actions → Variables**, add these as **variables**, not secrets. None of them is a credential: + +| Variable | Value | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `WORKOS_CLIENT_ID` | Client ID of the M2M application from step 3 | +| `WORKOS_WIF_TOKEN_URL` | The token endpoint, shaped like `https:///oauth2/token` | +| `WORKOS_API_URL` | WorkOS API base URL. Leave it unset for `https://api.workos.com` | +| `WORKOS_ENVIRONMENT_ID` | The environment the M2M application belongs to (`environment_...`), sent as the `x-url-environment-id` header. Machine tokens need it | + +The CLI also reads `WORKOS_WIF_AUDIENCE` (default `https://api.workos.com`) and `WORKOS_WIF_SCOPE` (optional, sent as `scope` only when set). The workflow pins the audience to the default. + +## 3. Seed the M2M application and its trust rule in WorkOS + +In the WorkOS environment you're testing against: + +1. Make sure the **`management-api-oauth`** feature flag is on for the environment. Without it the token endpoint rejects the grant. +2. Create an M2M application. Its client ID is `WORKOS_CLIENT_ID`. +3. Add a GitHub trust rule to the application: + - **Issuer:** `https://token.actions.githubusercontent.com` + - **Audience:** `https://api.workos.com`. It must match the `audience` the job requests (`WORKOS_WIF_AUDIENCE`). + - **Claims:** `repository_id` set to your test repository's numeric ID, and `ref` set to the branch you'll run from, such as `refs/heads/main`. + +To find the repository ID, run `gh api repos// --jq .id`. Use `repository_id` rather than the repository name, because the name can be renamed or reused. + +## 4. Run the workflow + +Go to **Actions → WorkOS WIF test → Run workflow** and pick the branch named in the trust rule's `ref` claim. + +The `cli` job takes an optional `cli_ref` input: + +- **Empty (default):** installs the published `workos` package with `oven-sh/setup-bun` and `bun add --global workos@latest`. WIF support isn't in a published release yet, so until it is, use `cli_ref`. +- **A workos/cli branch, tag or SHA:** checks that ref out, runs `bun install --frozen-lockfile`, and puts a `workos` wrapper on `PATH` that runs `bun src/bin.ts` from source. + +## 5. What success looks like + +Both jobs go green. + +- The **curl** job log shows the masked token steps, an organization count from `GET /organizations`, the `{ id, name }` of the new `wif-test-` organization, and finally `Created and listed wif-test- (org_...) with a WIF access token.` +- The **cli** job shows the same for `wif-test--cli` and ends with `... through the CLI with no WORKOS_API_KEY.` + +Both new organizations show up in the WorkOS dashboard for that environment. Delete them when you're done. + +## Common errors + +| Symptom | Fix | +| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `No OIDC token available` / CLI `wif_id_token_unavailable` | Add `permissions: id-token: write` to the workflow or job. | +| CLI `wif_token_url_missing` | Set the `WORKOS_WIF_TOKEN_URL` variable. There is no default until the server side ships. | +| `invalid_grant` | The trust rule didn't match: check the issuer, the `repository_id` value, and that the run's `ref` (branch) matches the rule. The GitHub token may also have expired. | +| `invalid_client` | `WORKOS_CLIENT_ID` isn't the M2M application with the trust rule, or that application is in another environment. | +| `invalid_target` | Resource or audience mismatch. The trust rule's audience must be `https://api.workos.com`, the same as `WORKOS_WIF_AUDIENCE`. | +| `invalid_scope` | `WORKOS_WIF_SCOPE` asks for a scope the application isn't allowed. Fix or unset it. | +| `invalid_request` | Malformed request, often a `WORKOS_WIF_TOKEN_URL` that isn't the `/oauth2/token` endpoint. | +| Token exchange fails with HTTP 404, or the response is not JSON | `WORKOS_WIF_TOKEN_URL` is wrong, or the `management-api-oauth` flag is off for the environment. | +| The API returns 401 or 403 after a successful exchange | Set `WORKOS_ENVIRONMENT_ID` to the M2M application's environment, and check `WORKOS_API_URL` points at the same deployment that issued the token. | +| The CLI job uses an API key instead of WIF | `WORKOS_API_KEY` (or `--api-key`) always wins. Remove it from the job's environment. | +| CLI `wif_unsupported_command` | That command can't use WIF (for example `workos verify-login`, which won't create users when it can't confirm the environment isn't production). Use a sandbox `WORKOS_API_KEY` for that step. `workos migrations` ignores WIF entirely. | From 77f9a339fa765aa6d13478c1b2c8d6efb50c06a2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 28 Oct 2026 08:50:11 -0500 Subject: [PATCH 5/8] docs: document workload identity federation env vars --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 803158c9..0e35937a 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,13 @@ All resource commands follow the same pattern: `workos [args Most resource commands — organization, user, role, permission, membership, invitation, session, event, feature-flag, org-domain, portal, webhook, config, authkit, branding — authenticate with your WorkOS dashboard session from `workos auth login` and target the active environment (see `workos profile`). Pass `--environment-id ` on any of them to target a different environment for a single invocation. Access tokens refresh automatically while the session is valid, so a logged-in machine keeps working headlessly; a dead session exits with code 4. -The remaining commands (`connection`, `directory`, `audit-log`, `api-key`, `vault`) still use the REST plane, as does the raw escape hatch `workos api`. Those resolve an API key via: `--api-key` flag → `WORKOS_API_KEY` env var → active environment's stored key. +The remaining commands (`connection`, `directory`, `audit-log`, `api-key`, `vault`) still use the REST plane, as does the raw escape hatch `workos api`. Those resolve an API key via: `--api-key` flag → `WORKOS_API_KEY` env var → GitHub Actions workload identity federation (below) → active environment's stored key. + +#### GitHub Actions without an API key (workload identity federation) + +> Preview: the WorkOS server side is still being built (behind the `management-api-oauth` flag) and has no production endpoint yet. + +In a GitHub Actions job with `permissions: id-token: write`, `WORKOS_CLIENT_ID` set to an M2M application's client ID, and no `WORKOS_API_KEY`, the REST commands above (plus `seed`, `setup-org`, `onboard-user`, `debug-sso`, `debug-sync`) exchange the job's GitHub OIDC token at `WORKOS_WIF_TOKEN_URL` for a short-lived WorkOS access token. The token is held in memory only and refreshed before it expires. `verify-login` refuses WIF tokens because it can't confirm the environment isn't production, and `migrations` ignores WIF. See [`examples/wif-github-actions`](examples/wif-github-actions/) for a workflow and setup guide. #### organization @@ -686,6 +692,11 @@ workos install --api-key sk_test_xxx --client-id client_xxx 2>/dev/null | Variable | Effect | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `WORKOS_API_KEY` | API key for `workos api`, the still-REST commands (connection, directory, audit-log, api-key, vault), and workflow/debug commands. Resource commands use the `workos auth login` session instead | +| `WORKOS_CLIENT_ID` | In GitHub Actions with no API key: the M2M application's client ID, which turns on workload identity federation for the REST commands | +| `WORKOS_WIF_TOKEN_URL` | Workload identity federation token endpoint (`https:///oauth2/token`). Required for WIF; no default until the server side ships | +| `WORKOS_WIF_AUDIENCE` | Audience requested for the GitHub OIDC token (default `https://api.workos.com`); must match the M2M application's trust rule | +| `WORKOS_WIF_SCOPE` | Optional `scope` sent with the WIF token exchange | +| `WORKOS_ENVIRONMENT_ID` | Sent as `x-url-environment-id` with WIF access tokens; machine tokens need it | | `WORKOS_API_URL` | Override the API base URL for all CLI commands (e.g. a local API) | | `WORKOS_API_BASE_URL` | Accepted alias for `WORKOS_API_URL` (what `workos dev` sets) | | `WORKOS_MODE` | Interaction mode: `human`, `agent`, or `ci` | From bfc50f8f5127971c05a706d381a5a0597cc9eef1 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 28 Oct 2026 08:50:11 -0500 Subject: [PATCH 6/8] fix(api): exit with the original error when the SDK wraps a CliExit A failed WIF token refresh inside the SDK's fetch is already reported; re-throw it instead of printing a second 'Unexpected error'. --- src/lib/api-error-handler.spec.ts | 9 +++++++++ src/lib/api-error-handler.ts | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/src/lib/api-error-handler.spec.ts b/src/lib/api-error-handler.spec.ts index a6ee00a9..c1744509 100644 --- a/src/lib/api-error-handler.spec.ts +++ b/src/lib/api-error-handler.spec.ts @@ -166,6 +166,15 @@ describe('createApiErrorHandler', () => { invoke(handler, null); expect(parseError().error.code).toBe('unknown_error'); }); + + it('re-throws a CliExit the SDK wrapped as "Unexpected error" without printing again', () => { + // e.g. a failed WIF token refresh inside the SDK's fetch, already reported. + const original = new CliExit(4, { reason: 'auth_required', errorCode: 'wif_invalid_grant' }); + const handler = createApiErrorHandler('Thing'); + expect(invoke(handler, new Error(`Unexpected error: ${original}`, { cause: original }))).toBe(original); + expect(invoke(handler, original)).toBe(original); + expect(stderrOutput).toEqual([]); + }); }); describe('telemetry apiContext', () => { diff --git a/src/lib/api-error-handler.ts b/src/lib/api-error-handler.ts index 5711e8a0..0c2d5c9a 100644 --- a/src/lib/api-error-handler.ts +++ b/src/lib/api-error-handler.ts @@ -1,5 +1,6 @@ import { WorkOSApiError } from './workos-api.js'; import { exitWithError } from '../utils/output.js'; +import { CliExit } from '../utils/cli-exit.js'; export function isSdkException( error: unknown, @@ -55,6 +56,13 @@ function getApiErrorMessage(error: NormalizedApiError, label: string): string { */ export function createApiErrorHandler(resourceName: string) { return (error: unknown, context?: string): never => { + // A CliExit raised mid-request (e.g. a failed WIF token refresh inside the + // SDK's fetch) has already printed its error, but the SDK wraps it as + // "Unexpected error". Re-throw the original so it exits as intended. + const exit = + error instanceof CliExit ? error : error instanceof Error && error.cause instanceof CliExit ? error.cause : null; + if (exit) throw exit; + if (error instanceof TypeError && error.message.includes('errors is not iterable')) { exitWithError({ code: 'unprocessable_entity', From fba0bd8c3e925ca6318ddfb2e2fac84e601c6453 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 28 Oct 2026 08:50:11 -0500 Subject: [PATCH 7/8] refactor(wif): tidy verify-login refusal ordering and unsupported-command message --- src/commands/verify-login.ts | 14 ++++++++------ src/lib/wif.ts | 5 +++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/commands/verify-login.ts b/src/commands/verify-login.ts index b1d9b9d9..d6bcaf2d 100644 --- a/src/commands/verify-login.ts +++ b/src/commands/verify-login.ts @@ -119,12 +119,9 @@ export async function runVerifyLogin(options: VerifyLoginOptions): Promise const method: VerifyLoginMethod = options.method ?? 'password'; const environment = options.envName ?? null; - // 1. Production refusal — before any SDK call. verify-login mutates real - // users, so it must never touch production. Refuse on either the stored env - // type OR a production-format key (belt-and-suspenders for keys passed via - // --api-key / WORKOS_API_KEY with no matching stored env). - // A WIF access token has no sk_test_/sk_live_ prefix, so the target - // environment's type can't be confirmed: refuse rather than risk production. + // 0. A workload identity federation (WIF) access token has no sk_test_/ + // sk_live_ prefix, so the target environment's type can't be confirmed: + // refuse rather than risk production. if (isWifAccessToken(options.apiKey)) { exitWithError({ code: 'wif_unsupported_command', @@ -133,6 +130,11 @@ export async function runVerifyLogin(options: VerifyLoginOptions): Promise 'token targets a non-production environment. Set WORKOS_API_KEY to a sandbox (sk_test_) key for this step.', }); } + + // 1. Production refusal — before any SDK call. verify-login mutates real + // users, so it must never touch production. Refuse on either the stored env + // type OR a production-format key (belt-and-suspenders for keys passed via + // --api-key / WORKOS_API_KEY with no matching stored env). if (options.envType === 'production' || options.apiKey.startsWith('sk_live_')) { exitWithError({ code: 'production_env_refused', diff --git a/src/lib/wif.ts b/src/lib/wif.ts index 675bd91b..9005bbc2 100644 --- a/src/lib/wif.ts +++ b/src/lib/wif.ts @@ -85,10 +85,11 @@ export function exitWithMissingIdTokenPermission(): never { } /** Exit when WIF is the credential but the call site can't wait for the exchange. */ -export function exitWithWifUnsupported(command = 'This command'): never { +export function exitWithWifUnsupported(): never { exitWithError({ code: WIF_ERROR_CODES.unsupportedCommand, - message: `${command} can't authenticate with workload identity federation (WIF) yet. Set WORKOS_API_KEY for this step.`, + message: + "This command can't authenticate with workload identity federation (WIF) yet. Set WORKOS_API_KEY for this step.", }); } From 17f5a929f9066aa28724ad97fd50eb7156888627 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 28 Oct 2026 08:59:04 -0500 Subject: [PATCH 8/8] test(wif): make the token-leak test independent of earlier tests Clear registered secrets before every test and give the leak test its own token values, so each case only echoes values the CLI has already handled. --- src/lib/wif.spec.ts | 59 +++++++++++++++++++++++++++++---------------- src/utils/redact.ts | 5 ++++ 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/lib/wif.spec.ts b/src/lib/wif.spec.ts index 31f23b21..ca6e0bd0 100644 --- a/src/lib/wif.spec.ts +++ b/src/lib/wif.spec.ts @@ -22,7 +22,8 @@ const { runVerifyLogin } = await import('../commands/verify-login.js'); const { CliExit } = await import('../utils/cli-exit.js'); const { setOutputMode } = await import('../utils/output.js'); const { sanitizeMessage, sanitizeStack } = await import('../utils/crash-reporter.js'); -const { redactCredentials } = await import('../utils/redact.js'); +const { redactCredentials, redactRegisteredSecrets, resetRegisteredSecretsForTests } = + await import('../utils/redact.js'); // Distinctive values so any leak is easy to spot. const GITHUB_JWT = 'eyJhbGciOiJSUzI1NiJ9.eyJyZXBvIjoiYWNtZS9hcHAifQ.github-jwt-signature-SECRET'; @@ -99,6 +100,7 @@ describe('workload identity federation (wif)', () => { activeEnv = undefined; resetWifCacheForTests(); + resetRegisteredSecretsForTests(); setOutputMode('human'); calls = []; @@ -537,6 +539,21 @@ describe('workload identity federation (wif)', () => { }); it('never leaks either token', async () => { + // Fresh values used only here, so redaction can't lean on an earlier test + // having registered them. The GitHub token is deliberately not JWT-shaped, + // so the telemetry checks exercise registration rather than the eyJ pattern. + const githubToken = 'gh-oidc-token-only-in-leak-test-SECRET'; + const accessToken = 'wos-access-token-only-in-leak-test-SECRET'; + const requestToken = 'actions-request-token-only-in-leak-test-SECRET'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = requestToken; + githubHandler = () => json({ value: githubToken }); + + // Explicit starting state: beforeEach cleared the registry, and a value is + // only registered once the CLI has handled it. + for (const value of [githubToken, accessToken, requestToken]) { + expect(redactRegisteredSecrets(value)).toBe(value); + } + const messages: string[] = []; const writes: string[] = []; vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { @@ -554,35 +571,30 @@ describe('workload identity federation (wif)', () => { } catch (error) { messages.push(String(error), error instanceof Error ? `${error.message}\n${error.stack}` : ''); } + // Clears the token cache only; registered secrets stay, as in a real run. resetWifCacheForTests(); } for (const mode of ['human', 'json'] as const) { setOutputMode(mode); - // Server echoes the assertion back in error_description. - tokenHandler = () => json({ error: 'invalid_grant', error_description: `bad assertion ${GITHUB_JWT}` }, 400); + // Before any access token exists, a failed exchange can only echo the GitHub token. + tokenHandler = () => json({ error: 'invalid_grant', error_description: `bad assertion ${githubToken}` }, 400); await capture(() => resolveApiKeyAsync()); - // Network error whose message carries the assertion. tokenHandler = () => { - throw new TypeError(`socket hang up while sending ${GITHUB_JWT}`); + throw new TypeError(`socket hang up while sending ${githubToken}`); }; await capture(() => resolveApiKeyAsync()); - // Unrecognized error echoing both tokens. - tokenHandler = () => - json({ error: 'server_error', error_description: `${GITHUB_JWT} / ${ACCESS_TOKEN}`, access_token: '' }, 500); - await capture(() => resolveApiKeyAsync()); - - // Non-JSON body echoing the request. - tokenHandler = () => new Response(`assertion=${GITHUB_JWT}`, { status: 400 }); + tokenHandler = () => new Response(`assertion=${githubToken}`, { status: 400 }); await capture(() => resolveApiKeyAsync()); - // Successful exchange, then failures downstream of it. - tokenHandler = () => json({ access_token: ACCESS_TOKEN, token_type: 'Bearer', expires_in: 300 }); + // A successful exchange gives the CLI an access token; then failures downstream of it. + tokenHandler = () => json({ access_token: accessToken, token_type: 'Bearer', expires_in: 300 }); await capture(async () => { const token = await resolveApiKeyAsync(); + expect(token).toBe(accessToken); await runVerifyLogin({ apiKey: token, clientId: 'client_x', @@ -597,19 +609,24 @@ describe('workload identity federation (wif)', () => { await workosRequest({ method: 'GET', path: '/organizations', apiKey: token, baseUrl: API_BASE }); }); apiHandler = () => json({ data: [], list_metadata: { before: null, after: null } }); + + // A later exchange fails and the server echoes both the new assertion and the access token it issued earlier. + tokenHandler = () => + json({ error: 'server_error', error_description: `${githubToken} / ${accessToken}`, access_token: '' }, 500); + await capture(() => resolveApiKeyAsync()); } - // Debug-log and telemetry redaction helpers know both values. + // Debug-log and telemetry redaction helpers know all three values. const redactedForTelemetry = [ - sanitizeMessage(`failed with ${GITHUB_JWT} and ${ACCESS_TOKEN}`), - sanitizeStack(`Error: ${ACCESS_TOKEN}\n at x (/src/lib/wif.ts:1:1)`), - JSON.stringify(redactCredentials({ assertion: GITHUB_JWT, nested: { token: ACCESS_TOKEN } })), + sanitizeMessage(`failed with ${githubToken} and ${accessToken} via ${requestToken}`), + sanitizeStack(`Error: ${accessToken}\n at x (/src/lib/wif.ts:1:1)`), + JSON.stringify(redactCredentials({ assertion: githubToken, nested: { token: accessToken } })), ]; const everything = [...messages, ...writes, ...stdout, ...stderr, ...redactedForTelemetry].join('\n'); expect(stderr.length).toBeGreaterThan(0); - expect(everything).not.toContain(GITHUB_JWT); - expect(everything).not.toContain(ACCESS_TOKEN); - expect(everything).not.toContain(REQUEST_TOKEN); + expect(everything).not.toContain(githubToken); + expect(everything).not.toContain(accessToken); + expect(everything).not.toContain(requestToken); }); }); diff --git a/src/utils/redact.ts b/src/utils/redact.ts index b931ea85..44913437 100644 --- a/src/utils/redact.ts +++ b/src/utils/redact.ts @@ -14,6 +14,11 @@ export function registerSecret(value: string | undefined): void { if (value && value.length >= 8) registeredSecrets.add(value); } +/** Test-only: forget every registered secret, so each test starts from a known state. */ +export function resetRegisteredSecretsForTests(): void { + registeredSecrets.clear(); +} + /** Replace every registered secret value in `text` with `[REDACTED]`. */ export function redactRegisteredSecrets(text: string): string { let redacted = text;