Embedded auth server should accept SPIFFE SVIDs as client credentials
@jhrozek is already working on this.
Since Aug 13, 2026.
- Dominant language
- Go
- Stars
- 2.2k
- Forks
- 300
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 184
Description
Description
ToolHive's embedded authorization server can only authenticate a confidential
client with a shared secret. In Kubernetes that is the wrong credential: the
secret has to be created, mounted, rotated and revoked out of band, and
#6082 established that in practice no production path can create such a
client at all — DCR and CIMD both hardcode public clients, and RunConfig
has no client-seeding field. The token-exchange grant is implemented, tested,
and unreachable.
SPIFFE sidesteps the secret entirely. A workload attested by SPIRE already
holds a short-lived, automatically rotated credential that names it. If the
authorization server accepts that credential directly, the client ID can be
the SPIFFE ID — stable, knowable before the workload ever connects, and
writable into configuration ahead of time.
This epic adds secretless workload authentication to the embedded AS using
both credential shapes defined by
draft-ietf-oauth-spiffe-client-auth-02:
spiffe_x509— X.509-SVID presented over mutual TLSspiffe_jwt— JWT-SVID presented as an OAuth client assertion
Both must resolve to the same authenticated SPIFFE principal and share one
client association, one set of grants, scopes, resources and audiences, and one
authorization outcome. Both must work with client_credentials and with the
RFC 8693 delegation flow from #5194.
This is option D of the four provisioning options designed in #6082, whose
recommendation was to harvest it as its own issue. It is complementary to
option A (pre-provisioned clients in RunConfig) rather than competing with
it: A serves non-Kubernetes deployments, D is the strongest answer for
Kubernetes, and the identity-as-client-ID idea is worth adopting regardless of
credential type.
Prior art
An unmerged spiffee-authserver branch (62 commits, Apr 2026, no PR) proved
the approach end to end. It has already been partially harvested via #5713
(Cedar nested map claims) and #5881 (token-exchange fosite wiring). What it
demonstrated:
- A custom
fosite.ClientAuthenticationStrategyauthenticates the client from
a verified X.509-SVID and delegates to the default strategy when no SPIFFE ID
is present, so browser and public-client flows are untouched. - Clients auto-register just-in-time with
Public: falseand no secret,
enabled by a three-line seam in shared code (registration.Config.SkipSecretHash). - It made discovery metadata dynamic, which independently fixes #6082's
second blocker. - Fail-closed was verified:
client_id=spiffe://…without a client
certificate falls through to the default strategy and fails.
Two weaknesses must not be lifted with it: a nil policy means allow-all, and
every auto-registered client receives all supported scopes and all allowed
audiences, so there is no per-identity narrowing.
The branch is also stale. Its diff against main re-adds the RFC 8693 handler,
its fosite wiring, both token validators, Cedar act support and act audit
capture — all of which have since landed independently (#5812, #5813, #5814,
#5881, #5713, #6035). None of that is in scope here, and each sub-issue below
is written against current main, not against the branch.
Design constraints
These hold across every sub-issue:
- Existing browser and public-client OAuth behaviour must remain unchanged.
- Missing or malformed SPIFFE credentials must not downgrade to another
authentication method. - A workload must be explicitly associated with an OAuth client and its
permitted grants, scopes, resources and audiences. A missing association
policy fails closed. - X.509 and JWT credentials for the same SPIFFE ID must produce equivalent
authorization outcomes. - Authorization-server WebPKI trust stays separate from SPIFFE workload trust.
- Bundle endpoints and trust domains are explicitly configured, never derived
from claims in the credential being validated. - Credential and trust-bundle rotation must not require an application restart.
- The SPIRE Workload API is the supported production credential-delivery
mechanism. cert-manager CSI is documented as an optional development path
only, with its different attestation and RBAC properties made explicit.
Reuse: do not add a third way to declare external trust
A parallel unpushed branch (xaa-spike-1, inbound trust and ID-JAG) explored
this configuration surface first. Its artifacts are now largely superseded —
read it for the design rule, not for the code.
What actually happened: the spike added TrustedIssuers []TrustedIssuerRunConfig
on 2026-08-04. Three days later #6149 landed TrustedIssuers []tokenexchange.TrustedIssuer on main independently, in the same two structs
with the same trusted_issuers JSON tag. main's type is a superset — it
carries ExpectedAudience (which the spike put on a separate grant struct), plus
per-issuer InsecureAllowHTTP and AllowPrivateIPs and the whole #6149 consent
model. The spike no longer merges: 16 conflicts against main.
Current state:
tokenexchange.TrustedIssueronmainis the trusted-issuer type. Do not
replace it with the spike's thinnerTrustedIssuerRunConfig; that regresses
consent.InboundGrantsRunConfigis introduced by sub-issue 1 as{SPIFFEClientAuth}.
It is the single "which grant purposes are enabled for which trust root" type;
ID-JAG and token-exchange lists are added to it later, additively.pkg/oauthproto/jwks.goshould not be adopted.mainalready has two jwx
jwk.Cacheimplementations that are better than the spike's hand-rolled
go-jose one; consolidating them is tracked separately as #6319 and is not a
dependency of this epic.
The rule to carry over is still exactly right: trust registration and grant
enablement are separate declarations, and neither may silently broaden the
other. An issuer trusted but enabled for no grant is a configuration error,
not a no-op. That is this epic's fail-closed constraint.
Equally important is the pattern that branch rejected: external trust was
first modelled as a jwks-trust upstream-provider type, then deleted in favour
of a first-class config field. The spiffee-authserver branch contains both
deleted patterns (upstream/oidc_trust.go, upstream/spiffe.go). A SPIFFE
trust domain is a trust declaration, not an identity provider.
Sequencing, resolved: sub-issue 1 is based on current main and merges
clean, so this epic defines the shape of InboundGrantsRunConfig and the ID-JAG
work adds to it afterwards. The one thing that would flip that is landing the
spike's InboundGrantsRunConfig first — do not.
Dependency graph
1. Identity, association, and configuration model
|
v
2. Trust-domain bundle management and rotation
+---------+---------+
| |
v v
3. X.509-SVID authentication 4. JWT-SVID authentication
| |
+---------+---------+
|
v
5. OAuth grants and discovery integration
|
v
6. SPIRE and operator deployment integration
|
v
7. Interoperability, security, and E2E coverage
3 and 4 can proceed in parallel once the shared identity, policy and bundle
foundations exist.
External dependencies
#6149 / #5989 delegation consent ──► 5
#5815 actor_token / id_token subject ──► 5
#6082 blocker 2 (dynamic discovery) ──► 5
#6082 storage + registration bugs ──► 1
Sub-issue 5 carries three external dependencies and should not start until the
discovery-metadata and consent pieces have landed or been explicitly claimed.
Completion criteria
- A SPIRE-attested workload authenticates to ToolHive with either an
X.509-SVID or a JWT-SVID, with no static OAuth client secret. - Both methods resolve to the same configured workload identity and
authorization policy. - The workload obtains a resource-scoped
client_credentialstoken. - The workload participates as the actor in the RFC 8693 delegation flow.
- Trust bundles and workload credentials rotate without restarting ToolHive or
the workload. - Positive and negative behaviour is covered by automated integration and
end-to-end tests against real SPIRE-issued credentials. - Existing non-SPIFFE OAuth deployments retain their current behaviour.
Out of scope
- WIT-SVID authentication.
- An agent credential sidecar.
- Options A, B and C from #6082 (pre-provisioned clients, the CRD adapter, and
confidential DCR). A in particular should land first as the substrate for
non-Kubernetes deployments.
Related
- Builds on: #5194 (RFC 8693 token exchange in the embedded AS)
- Designed in: #6082 (option D), which recommends this epic
- Depends on: #6149 / #5989, #5815, and #6082's discovery and storage fixes
- Spec:
draft-ietf-oauth-spiffe-client-auth-02
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.