Kai Ase Siren
Skip to content

OAuth2: a credential this runtime mints

Every other value mcp-beaver presents is read from somewhere that already holds it. An OAuth client_credentials upstream holds nothing until a token is fetched, so that value has to be minted. oauth2-client declares which clients exist, and {oauth2:<name>} or value oauth2 "<name>" presents the token.

umbra's pkg/tokenmint does the minting: it posts grant_type=client_credentials, caches the token to its own expires_in, renews ahead of expiry, and serializes concurrent first calls so they do not stampede the token endpoint. This repo declares the clients and registers the provider.

Spec mode

oauth2-client "vendor" {
    token-url "https://auth.vendor.example/oauth/token"
    client-id "mcp-beaver"
    client-secret env "VENDOR_CLIENT_SECRET"
    scope "read:things"
}

wrap ward mcp things {
    base-url "https://api.vendor.example/v1"
    auth bearer { value oauth2 "vendor" }
    can get thing { path "/things/{id}" }
}

The client secret is a <provider> "<address>" pair, the same shape auth's own value takes, so it arrives from a Secret exactly as every other credential does and rotating it takes effect without a restart. auth-style is basic or post, and basic is the default because RFC 6749 requires a server to support it.

Upstream mode

serve-upstream mounts no guardfile, so the same declaration is one flag:

mcp-beaver serve-upstream \
  --upstream https://vendor.example/api/mcp/http \
  --oauth2-client 'name=vendor,token-url=https://auth.vendor.example/oauth/token,client-id=mcp-beaver,client-secret={env:VENDOR_CLIENT_SECRET}' \
  --upstream-header 'Authorization=Bearer {oauth2:vendor}' \
  --tool search_things

The secret is a {provider:address} span for the reason --upstream-header requires one: a literal would put the client secret in argv, visible in ps and in the pod spec. Unlike that flag there is no {literal:...} escape, because a constant client secret in argv is never what someone means.

In the chart, upstream.oauth2Clients carries the same entries.

What fails closed, and where

A token-url that is not https, and not http to a loopback address, is refused: the client secret crosses that hop. Loopback is exempt because it never reaches a wire, and a co-located auth sidecar is a shape this runtime already serves.

A client secret resolved through oauth2 itself is refused, because a minted value cannot seed a mint.

{oauth2:typo} naming a client no oauth2-client declares is a build error, not a 401 on the first call. That is the point of addressing a client by name rather than by endpoint: the address is checkable offline, so lint catches it in CI.

One registry, so a validator sees what the runtime has

Six sites used to call umbra's valuesource.Builtins() for themselves, and two were validators. A consumer-registered provider therefore resolved at runtime and was rejected at validation, which is a capability that exists and cannot be reached. They now share one ProviderSet, built once per server. A minted provider carries a token cache, so rebuilding it per call would re-mint every request and stampede the endpoint the cache exists to protect.

What never leaves the process

/admin/describe reports oauth2Clients as names only: never a token, never a client secret, never the token endpoint. A mint that fails names the endpoint in its error with the credential redacted, which is umbra's behavior and is tested there.

Not this

authorization_code and refresh_token grants, which need a browser flow a pod cannot complete, are #82. A session bootstrap that trades a durable password for a short-lived token, which is a different protocol shape, is #91.