Authenticate headless runners with a machine credential - #495
Conversation
A machine credential could be provisioned and registered, and the server would accept a machine's token — but nothing could OBTAIN one. There was no client_credentials grant anywhere in the CLI, no call to the token endpoint, and nothing read KCAP_CLIENT_ID/KCAP_CLIENT_SECRET: every occurrence was README, help text, two Console.Error lines, or a test asserting the help mentioned them. The help even promised "on the runner, both variables must be in the environment". This is that missing half. MachineAuth reads the two variables and resolves the token endpoint. MachineToken- Provider exchanges them for a bearer via client_credentials and caches it IN MEMORY ONLY — never the token store, which would cost the property the design rests on (a machine's bearer exists only for the life of the process that needs it) and buy nothing, since client_credentials returns no refresh token and "refresh" here means "mint another" from a credential the runner already has. One wiring point: CreateClientCoreAsync, the single place every authenticated CLI call resolves a token. Placed after the None check and before the token-store paths, because on a runner those find nothing and advise `kcap login` — which a runner cannot do. No UnauthorizedRetryHandler, since it refreshes through TokenStore; a 401 arrives as rejectedAccessToken and re-minting is the repair. Gated on either variable being present, not both, so a half-configured runner is told which one is missing instead of getting that same wrong advice. The token endpoint is hardcoded with a KCAP_WORKOS_TOKEN_URL override, exactly as AuthProxyEndpoint hardcodes the proxy: it is one value for the whole fleet, and it CANNOT be derived from the tenant's /auth/config because the field that would carry it, authkit_domain, is blank on every tenant. Verified live: that host answers this grant with an OAuth2 credential rejection; api.workos.com/oauth2/token 404s. A rejection never echoes the response body. A token endpoint's error body is attacker-influenced and can reflect the request, which contains the secret. Tests go through the real HTTP exchange against WireMock, not shapes around it — including the wire format, which is what would be silently wrong. The end-to-end test asserts a constructed client actually carries the minted bearer with no profile present; its absence is why the previous slice of this feature shipped completely non-functional with a clean build and a clean review. Mutation-verified: disabling the branch fails 4 tests including that one. 11/11. Part of AI-990. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoAuthenticate headless runners using machine credentials
AI Description
Diagram
High-Level Assessment
Files changed (5)
|
Code Review by Qodo
1.
|
Two medium findings, both real, fixed at the root rather than patched. The failure reason was a process-wide mutable static (MachineAuthProblem, and MachineTokenProvider.Problem). Two concurrent failures would race, so a caller could report the other one's reason — and a success clearing the field could erase a concurrent failure's. The daemon makes exactly these concurrent calls and is a primary consumer of machine credentials, so it was reachable. Both statics are gone: GetTokenAsync returns a MachineTokenResult, and CreateClientCoreAsync threads the reason out through its return tuple. That also removes the memory-ordering question the reviewer raised separately — a SemaphoreSlim release is not a barrier in the C# memory model, so a static written inside the gate and read after it was not guaranteed visible on ARM64. Nothing crosses the gate now. KCAP_WORKOS_TOKEN_URL was a redirect primitive: the REQUEST direction carries the secret, so anyone able to set one environment variable — a ConfigMap rather than a Secret, a CI "variable" rather than a "secret" — could point the mint at their own host and harvest it. The no-echo rule only ever protected the response direction. Now refused unless https or loopback. The reviewer proposed requiring https outright and assumed the tests already used it; they use WireMock over http, so that rule would have broken them and invited someone to weaken it later. https-except-loopback is the same carve-out OAuth redirect-URI rules make, for the same reason: a credential cannot leave the machine over 127.0.0.1. It reports rather than throws, matching this path's contract, and it refuses rather than falling back to the default — a silent fallback would send the real credential to the real endpoint while the developer believed they were pointed at a stub. Also: the token cache is keyed on client id AND token URL, so a second credential cannot receive the first one's token; a comment records why Intended must not be widened, since it diverts an interactive user off their own profile; the mint's Content-Type is now asserted; the end-to-end asserts a mint actually happened, not just that a header ended up set; and the help text states that a runner needs no login and that the exchange needs egress to WorkOS. Four new tests: plaintext non-loopback refused with NO request leaving the process, loopback allowed, malformed URL refused, and a different credential minting its own token. 15/15 here, 20/20 on the help-text tests after editing the help. Part of AI-990. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TryResolveTokenUrl admitted a URL if it was https OR loopback, but Uri.IsLoopback is host-only — so ftp://127.0.0.1 or ws://localhost passed, being loopback but not a credential-safe POST target. The remote-http exfiltration vector was already blocked (not loopback, not https); this closes the odd-scheme loopback gap. Now: https anywhere, or http on loopback. New parameterised test refuses ftp/ws loopback URLs with zero requests leaving the process. 17/17. Part of AI-990. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Qodo re-scored after the review-flow fixes: its cache-scoping and static-race findings resolved, two remained. Both real; neither the flow surfaced. 1. No automatic 401 re-mint. The machine client got no retry handler, unlike the token-store path, and the common CreateAuthenticatedClientAsync does not thread rejectedAccessToken — so a token revoked mid-life (unexpired by the local clock, so the proactive renew margin never fires) produced repeated 401s until the cache aged out. New MachineUnauthorizedRetryHandler mirrors UnauthorizedRetryHandler: on a 401 it re-mints via the credential (client_credentials needs no refresh token) and resends once. Installed on the same autoRetryUnauthorized terms, so the MCP servers' own 401 loops are not double-retried. Test drives a real request through the constructed client — 401 the first token, re-mint, 200 — asserting exactly one extra mint. 2. Unsanitised token URL in Problem strings. The URL and the caught exception message reach stderr, so both now go through the same helpers HttpClientExtensions.RenderUnreachableError uses — UnusableUrlDiagnostic.Sanitize drops userinfo (a KCAP_WORKOS_TOKEN_URL of https://id:secret@host would otherwise print the secret) and StripControlCharacters (made internal) removes control chars so a crafted value cannot inject lines into stderr. Test proves userinfo does not survive into the Problem. 19/19. Part of AI-990. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both open Qodo findings addressed in 1. No automatic 401 re-mint — real, and the important one. The machine client installed no retry handler (unlike the token-store path), and the common 2. Unsanitized token URL in error strings — the URL and caught exception message reach stderr, so both now go through the same helpers Findings 3 (cache scoping) and 4 (static race) were already resolved by the review-flow round that removed the static entirely and keyed the cache on 🤖 Addressed by Claude Code |
Supplies the half of machine credentials that was never built: a runner obtaining a token from one.
The gap
kcap machine createcould provision a credential and register it, and the server would accept a machine's token. But nothing could get one. Verified by grep before writing a line:client_credentialsgrant anywhere in the CLI — zero hits.KCAP_CLIENT_ID/KCAP_CLIENT_SECRET. Every occurrence wasREADME.md,help-machine.txt, twoConsole.Error.WriteLineAsynclines, and a test asserting the help mentions them.help-machine.txtstep 3 says "On the runner, both variables must be in the environment" — promising behaviour that did not exist. The tests asserted the help mentions the variables, which is the same defect class as the dead-on-arrivalkcap machinefound the same day: testing the artifact I was asked to get right instead of the path that has to execute.What this adds
MachineAuth— reads the two variables, resolves the token endpoint.MachineTokenProvider— exchanges them viaclient_credentials, caches the bearer in memory only, single-flighted behind a semaphore so a burst of callers (hooks, watcher, MCP servers) mints once.Never the token store: that would cost the property the design rests on — a machine's bearer existing only for the life of the process that needs it — and buy nothing, since
client_credentialsreturns no refresh token. "Refresh" here means "mint another", which needs only the credential the runner already has.One wiring point —
CreateClientCoreAsync, the single place every authenticated CLI call resolves a token:Nonecheck (a server needing no auth needs no credential).kcap login, which a runner cannot do.UnauthorizedRetryHandler— it refreshes throughTokenStore, and there is no refresh token. A 401 arrives asrejectedAccessToken; re-minting is the repair.A runner needs no profile: that path already honours
KCAP_URL, and/auth/configdiscovery is unauthenticated.Two decisions worth scrutinising
The token endpoint is hardcoded (
https://signin.kcap.ai/oauth2/token) with aKCAP_WORKOS_TOKEN_URLoverride — exactly asAuthProxyEndpointhardcodes the proxy withKCAP_AUTH_PROXY_URL. It is one value for the whole fleet (a single WorkOS environment and application), and it cannot be derived from the tenant's/auth/config, because the field that would carry it —authkit_domain— is blank on every tenant, so deriving it would yield a broken URL everywhere. Verified live: that host answers this grant with an OAuth2 credential rejection, whileapi.workos.com/oauth2/token404s.A rejection never echoes the response body. A token endpoint's error body is attacker-influenced and can reflect the request — which contains the secret. The status is the diagnostic; the body isn't worth the risk. There's a test for that, using a stub that reflects the credential back.
Tests
11 tests, going through the real HTTP exchange against WireMock rather than asserting shapes around it:
access_tokenis a failure, not an empty bearer.NotAuthenticatedrather than a silently unauthenticated client.Mutation-verified: disabling the branch (
Intended => false) fails 4 tests including the end-to-end one, proving the branch is reached rather than merely present.MachineTokenResponseis registered inCapacitorJsonContextand read through it explicitly, so the AOT build has no reflection path.Not done yet, deliberately
The live end-to-end on a real tenant — create a machine, record a session as it, confirm ownership/attribution/visibility, confirm no seat consumed, revoke, confirm the coded refusal. That runs from a dev build before this is tagged. I am not calling this feature done until it has, because a green build and a clean review have already hidden a dead version of it once.
Part of AI-990.