stacklok / stacklok/toolhive

Implement X.509-SVID OAuth client authentication (spiffe_x509)

Open
#6,202 1 comment 0 reactions 1 assignee View on GitHub

@jhrozek is already working on this.

Since Aug 14, 2026.

authentication authorization enhancement go
Dominant language
Go
Stars
2.2k
Forks
300
Avg merge
1d 15h
Merged PRs (30d)
184

Description

Description

Implement the spiffe_x509 client-authentication method: a workload presents
its X.509-SVID on a mutually authenticated TLS connection, and the
authorization server authenticates the OAuth client from the verified
certificate instead of from a shared secret.

This is the path the spiffee-authserver branch proved out, and the reason
#6082 called option D the strongest answer for Kubernetes: there is no secret
to create, mount, rotate or revoke, and the client ID is the SPIFFE ID, so it
is knowable before the workload ever connects.

Scope

  • Add TLS and optional mutual-TLS listener support to the relevant proxy
    transports.
  • Validate X.509-SVID chains against the configured trust-domain bundle from
    sub-issue 2.
  • Extract the SPIFFE ID from the certificate and resolve it through the shared
    client-association policy from sub-issue 1.
  • Require an explicit client_id associated with the presented SPIFFE ID.
  • Advertise spiffe_x509 only when the method is fully configured and
    operational.
  • Keep authorization-server certificate validation separate from SPIFFE workload
    certificate validation — the AS's own WebPKI trust is not the workload trust
    bundle.

Prior art

The branch implemented this as a custom fosite.ClientAuthenticationStrategy
that authenticates from a verified X.509-SVID and delegates to the default
strategy when no SPIFFE ID is present
, which is what leaves browser and
public-client flows untouched. Clients auto-register just-in-time with
Public: false and no secret, enabled by a three-line seam in shared code
(registration.Config.SkipSecretHash).

Fail-closed was verified on the branch: a caller presenting
client_id=spiffe://… without a client certificate falls through to the
default strategy and fails there. The public-client rejection was not relaxed.
Preserve both properties.

Two weaknesses on the branch must be fixed, not carried: a nil policy meant
allow-all, and auto-registered clients received every supported scope and every
allowed audience. Per-identity narrowing comes from sub-issue 1's association
policy.

Acceptance criteria

Certificate shape:

  • The certificate is a leaf certificate with CA=false.
  • The certificate has the digitalSignature key-usage bit.
  • The certificate has exactly one URI SAN containing a valid, non-root SPIFFE
    ID. Additional DNS SANs are allowed; additional URI SANs are rejected.
  • The chain validates against the configured trust-domain bundle.

Client resolution:

  • The request contains an explicit client_id associated with the certificate's
    SPIFFE ID.

Fail-closed:

  • A missing certificate cannot produce a SPIFFE-authenticated principal.
  • A malformed or unauthorized certificate cannot fall back to public or
    secret-based client authentication.
  • Existing authorization-code and browser flows continue to work where
    configured.

Dependencies

  • Sub-issue 1 (identity, association and configuration model)
  • Sub-issue 2 (trust-domain bundle management) — for chain validation material

Can proceed in parallel with the JWT-SVID sub-issue once both foundations exist.

Coordination with the sibling credential type

#6202 and #6203 replace the same fosite hook: a single
ClientAuthenticationStrategy
(func(context.Context, *http.Request, url.Values) (fosite.Client, error)).
#6202 dispatches on a SPIFFE ID placed in the request context by the mTLS
listener; #6203 dispatches on client_assertion_type in the request form.

That dispatch is introduced once, separately, as a no-behaviour-change change
before either issue starts — both SPIFFE arms stubbed, everything else falling
through to fosite's default strategy. This issue owns one arm only and must
not modify the dispatch itself.
A needed change there goes back to the shared
change rather than being patched in place.

Two things to know when filling in an arm:

  • Fosite's default strategy already dispatches on client_assertion_type for
    RFC 7523 urn:ietf:params:oauth:client-assertion-type:jwt-bearer. The SPIFFE
    assertion type is different and must be matched exactly, or existing
    private-key-JWT client authentication breaks.
  • Both arms resolve through SPIFFEAssociationRegistry.Resolve from sub-issue
    1, which already fails closed on unknown association, wrong client ID, and
    disabled method. Do not add a parallel lookup — that registry is what makes
    the "equivalent authorization outcome" guarantee enforceable rather than
    merely tested.

Related

  • Part of the SPIFFE client-authentication epic
  • #6082 (option D)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.