Skip to content

fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.4 - #262

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/github.com-giantswarm-mcp-oauth-1.x
Open

fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.4#262
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/github.com-giantswarm-mcp-oauth-1.x

Conversation

@renovate

@renovate renovate Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
github.com/giantswarm/mcp-oauth v1.0.13v1.2.4 age confidence

Release Notes

giantswarm/mcp-oauth (github.com/giantswarm/mcp-oauth)

v1.2.4

Compare Source

Changed

Full Changelog: giantswarm/mcp-oauth@v1.2.3...v1.2.4

v1.2.3

Compare Source

Fixed

Full Changelog: giantswarm/mcp-oauth@v1.2.2...v1.2.3

v1.2.2

Compare Source

Changed

Full Changelog: giantswarm/mcp-oauth@v1.2.1...v1.2.2

v1.2.1

Compare Source

Changed

Full Changelog: giantswarm/mcp-oauth@v1.2.0...v1.2.1

v1.2.0

Compare Source

Added

Full Changelog: giantswarm/mcp-oauth@v1.1.4...v1.2.0

v1.1.4

Compare Source

Changed

Full Changelog: giantswarm/mcp-oauth@v1.1.3...v1.1.4

v1.1.3

Compare Source

Fixed

Full Changelog: giantswarm/mcp-oauth@v1.1.2...v1.1.3

v1.1.2

Compare Source

Fixed

Full Changelog: giantswarm/mcp-oauth@v1.1.1...v1.1.2

v1.1.1

Compare Source

Fixed
  • (storage/valkey) Bound the user+client revocation set with an extend-only TTL in #​523 by @​paurosello

Full Changelog: giantswarm/mcp-oauth@v1.1.0...v1.1.1

v1.1.0

Compare Source

⚠️ DEPLOY NOTE — one-time re-login required; token-key flush recommended

This release changes the Valkey storage layout for the upstream (dex) provider
token: it is now a single shared entry per user instead of private copies
under every subject/email/access-token/refresh-token key
(rotation-race fix,
root cause 2). New code reads and writes only the unified layout — there is
no legacy read-fallback path
, so leftover old-layout keys are never
consulted (see the "hard-cutover consistency" fix below).

Deploying v1.1.0:

  1. Flush the affected Valkey token keys (recommended clean cutover). The
    provider-token copies and refresh-token families (token:*, refresh:*,
    and the related meta:* / family:* / user-client set keys under the
    configured key prefix). Because the new code never reads old-layout keys,
    leftover keys only linger as dead state until their TTL expires — flushing
    just reclaims that space immediately. Skipping the flush is safe: it
    does not corrupt anything and does not trigger false reuse/theft detection.
    A user whose shared provider entry does not yet exist behind an otherwise
    valid refresh token simply gets a single invalid_grant and re-logs in —
    no family revocation, no all-token nuking, no critical theft audit event.
  2. Every user re-authenticates exactly once. Existing sessions do not
    carry over; after the single re-login, all of a user's concurrent MCP
    sessions share one coordinated provider token and stop dying on the
    rotation race. This is cheap because affected sessions are already dying
    constantly today.

The one-time re-login is unavoidable whether or not you flush — plan a short
user-facing notice.

Added
  • Shared per-user provider-token store (storage.UserProviderTokenStore, #​512, rotation-race slice 1 of giantswarm/giantswarm#37164 root cause 2). The upstream provider (dex) token is now stored as one shared entry per user — the single source of truth — instead of private copies under every subject/email/access-token/refresh-token key. Issued tokens hold lightweight references (token → user) and every rotation writes the fresh provider token back to the one shared entry, so a user's concurrent sessions no longer hold divergent copies of a single-use dex refresh token. The shared entry carries the provider's real expiry (no ProviderTokenTTL inflation; backends keep the entry alive via their own TTL while the refresh token can renew it). The interface is optional and consumed via type assertion (mirroring RefreshTokenFamilyStore), implemented identically by the memory and Valkey backends (providertoken:/tokenref: namespaces + an atomic Lua refresh-token consume); backends that don't implement it keep the legacy copy-per-token behavior, so the Go API stays additive. mcp-oauth's own per-session refresh-token families and rotation are unchanged, and OAuth 2.1 reuse detection still fires on genuine reuse. Deploy note (see the ⚠️ banner at the top of the v1.1.0 section): the unified layout is a hard cutover with no legacy read-fallback — deploying requires a one-time flush of the token:*/refresh:* Valkey keys and a one-time re-login for every user. Slices 2–4 (cross-pod single-flight refresh lock, routing all refresh entry points through it, no-orphan reordering) complete the fix and all ship in this release.
  • Per-user single-flight refresh lock + double-checked coordination (storage.ProviderRefreshLockStore, #​513, rotation-race slice 2 of giantswarm/giantswarm#37164 root cause 2). Every refresh of the shared per-user provider token is now coordinated with a per-user single-flight lock — cross-pod on the Valkey backend (SET NX + a Lua compare-and-delete against a random lock value for owner-only release, following the DPoP replay-cache precedent; refreshlock: namespace), in-process on the memory backend. The coordination is double-checked locking: acquire → re-read the shared entry → if it is already fresh (a sibling refreshed just before) adopt it without calling the provider, otherwise refresh once and write the rotated token back to the shared entry before releasing. Freshness is judged on the provider's real expiry against the proactive-refresh threshold or on refresh-token identity (the shared token rotated past the caller's copy). A session that loses the race waits briefly (bounded by the provider-refresh timeout) and adopts the winner's freshly-written token — no provider call, no collision, no client-visible error. The lock self-expires (provider-refresh timeout + margin), so a holder that crashes mid-refresh only delays the next refresh instead of wedging the user. The interface is optional and consumed via type assertion; the Go API stays additive. Slice 3 routes the server's three provider-refresh entry points through this coordinator; the deterministic rotation-race reproductions stay red until then by design.
  • All provider-refresh entry points routed through the single-flight coordinator (#​514, rotation-race slice 3 of giantswarm/giantswarm#37164 root cause 2). The three paths that refresh the upstream provider token — the /oauth/token refresh-token grant and both the proactive (near-expiry) and reactive (expired) refreshes during validation — now obtain the provider token via the per-user lock + shared entry instead of calling the provider directly. No path refreshes the provider uncoordinated, so a user's concurrent sessions produce at most one dex refresh per rotation window; the rotated token is written back to the shared entry only under the lock (the grant no longer re-saves it afterwards, which could have clobbered a sibling's newer token). Client binding is validated before any lock acquisition or provider call on every path, so a token presented by the wrong client never triggers an upstream refresh (TestServer_RefreshAccessToken_ClientBinding_Integration stays green). This makes the deterministic rotation-race reproductions (server/rotation_race_repro_test.go) pass: benign collisions between a user's own sessions are invisible, all sessions survive, and dex is called exactly once. Behavior change: a refresh-token grant whose shared provider token is still fresh (real expiry beyond the proactive-refresh threshold) now adopts that token instead of always calling the provider — the client still receives freshly rotated mcp tokens, but the upstream credential is reused for its lifetime rather than re-fetched on every grant. Silent authentication / SSO bridging is preserved (a second client comes up from the shared entry without an interactive dex login).
  • No-orphan reordering of the mcp refresh token (#​515, rotation-race slice 4 of giantswarm/giantswarm#37164 root cause 2). Previously RefreshAccessToken atomically deleted the mcp refresh token before calling the provider, with no rollback, so a transient dex hiccup orphaned an otherwise-valid session — the client's retry re-presented the now-deleted token, tripped reuse detection, and RevokeAllTokensForUserClient nuked every one of the user's sessions for that client. The refresh grant is now reordered so the mcp refresh token is rotated/deleted only after the shared provider token is confirmed fresh. In the unified layout the front half of the grant resolves the token's user binding with a non-destructive existence + expiry check (GetRefreshTokenInfo, same not-found/expired semantics the atomic consume had), validates client binding, reads the shared provider entry, and defers the atomic consume (the concurrency gate) until after the coordinated provider refresh succeeds. A transient provider-refresh failure therefore returns with the session's refresh token intact and usable — no orphan, no reuse nuke on retry — while genuine reuse (a rotated-away, re-presented mcp refresh token) still trips reuse detection and revokes the family + all user/client tokens (the OAuth 2.1 reuse-detection machinery is untouched). The legacy copy-per-token layout is unchanged: there the provider-token copy can only be read by consuming the token, so the delete stays up front.
  • Server.RefreshSessionProvider(ctx, familyID) — a background-safe, provider-only session refresh (giantswarm/giantswarm#37164, residual graveler deauth). Refreshes the upstream (dex) provider token for a family through the per-user single-flight coordinator and fires TokenRefreshHandler with the fresh token, but — unlike Server.RefreshSession / the /oauth/token refresh-token grant — it does not rotate the client-facing mcp refresh token, advance the family generation, or issue a new access token. A background integration that keeps a fresh upstream id_token on hand (e.g. an SSO re-exchange/forwarding loop) previously had only RefreshSession, whose client-token rotation on a tight retry loop rotated the client's refresh token out from under it until OAuth 2.1 reuse detection revoked the whole family and deauthed the user. The owning user is resolved from the refresh-token family record (GetRefreshTokenFamilyByID), which is race-free across rotation — resolving via a consumable issued token would race a concurrent genuine client refresh that atomically deletes it and fail exactly when the session is healthiest. The handler fires only when the refreshed token actually carries an id_token, so a background refresh can never drive an SSO-teardown on an id_token-less provider entry (preserveRefreshToken carries the refresh token forward but not the id_token). Requires the unified UserProviderTokenStore layout and RefreshTokenFamilyByIDStore. Note RefreshSession (the rotating variant) does NOT fire TokenRefreshHandler — the stale doc claiming it did is corrected; Server.RefreshSession is now deprecated in favor of RefreshSessionProvider and remains only for in-process callers that both need a newly minted access token and can hand the rotated refresh token back to the client (removal is v2-only).
Fixed

Hardening of the v1.1.0 rotation-race fix, surfaced by adversarial review of the unified per-user shared-provider-token layout (giantswarm/giantswarm#37164 root cause 2). All ship in this release.

  • A missing or expired shared provider entry behind a still-valid mcp refresh token no longer trips refresh-token reuse detection. Previously an absent shared entry for an otherwise-valid refresh token was treated as reuse — revoking the family, revoking all of the user's/client's tokens, and emitting a critical theft audit event. It now returns a plain invalid_grant with a warning-level shared_provider_token_missing audit event, and the presented refresh token is left intact so retries stay idempotent — the client simply re-logs in.
  • Transient storage read failures during the refresh grant now surface as retryable server errors instead of invalid_grant or a false cross_client_token_theft_prevented security event. Reads of the refresh-token info, token metadata, and shared provider entry now distinguish a genuine not-found from a transient backend error; token-metadata absence is signaled by both the memory and Valkey backends with the storage.ErrTokenNotFound sentinel.
  • RFC 7009 revocation of a refresh token now hard-deletes the presented refresh-token record itself (TokenStore.DeleteRefreshToken), independent of family state — previously a family-less refresh token survived revocation fully usable. Local invalidation now always runs even when the provider-token reference is missing, and a transient family-lookup error during revocation surfaces instead of silently skipping family revocation.
  • The reuse-detection response now deletes the user's shared provider entry after successfully revoking the shared upstream refresh token, so the user's other clients fail fast into re-login instead of repeatedly presenting a now-dead credential to the provider.
  • Interactive login now merges the shared provider entry under the per-user refresh lock (bounded 2s wait). A login that carries no refresh token preserves the existing entry's refresh token instead of clobbering it — the old behavior collapsed the entry's TTL down to the access-token expiry and broke all of the user's sessions. If the lock is unavailable, a refresh-token-less login skips the shared-entry write entirely rather than resurrecting a rotated-away provider refresh token.
  • The provider-refresh single-flight gained a third adoption signal (the shared entry's expiry changed since the caller observed it), so providers that do not rotate refresh tokens and issue short-lived access tokens no longer degrade to N serialized upstream calls or 10s waiter timeouts.
  • Coordinated provider refresh is now coalesced per-user within a pod (singleflight) and the waiter loop is bound to the caller's context, so a cancelled request stops polling immediately. Only the winner's provider call and write-back are detached, so a started rotation always completes and persists.
  • Hard-cutover consistency: the Valkey legacy-key read fallbacks were removed from GetRefreshTokenInfo, GetRefreshTokenFamily, and GetTokenMetadata, so the destructive and non-destructive refresh checks and the reuse-detection reads all agree. A leftover pre-migration key can no longer pass validation, burn a provider rotation, then be misclassified as theft.
Changed
  • BREAKING (permissive-JWKS CA trust is now explicit config). The permissive JWKS clients no longer read a CA installed on the http.DefaultTransport global; CA trust flows in as an explicit *x509.CertPool. New fields: oidc.JWKSClientOptions.RootCAs, oidc.TokenExchangeClientOptions.RootCAs, server.Config.JWKSRootCAs, server.TrustedIssuer.RootCAs, and dex.Config.RootCAs; nil keeps system-pool verification. NewPrivateIPAllowedHTTPClient(timeout, rootCAs) and NewHostScopedPrivateIPHTTPClient(hosts, timeout, rootCAs) gain a rootCAs *x509.CertPool parameter (NewSSRFSafeHTTPClient is unchanged). defaultTransportRootCAs() and every read of http.DefaultTransport are removed. This deletes the *http.Transport type assertion, the build-time-snapshot caveat, and the global-mutation requirement in tests (the CA-trust tests are now parallel-safe). Consumers that relied on installing a CA on http.DefaultTransport (e.g. mcp-kubernetes via DEX_CA_FILE) MUST now pass the CA pool explicitly — this is a coordinated change: mcp-kubernetes passes the pool and drops its DefaultTransport install in the same rollout, and this mcp-oauth release must land/ship before mcp-kubernetes bumps to it. Closes #​495.
  • NewOIDCValidator() now builds one permissive JWKS client per AllowPrivateIPJWKS issuer instead of sharing a single client across all of them, so each issuer's JWKS fetch is verified against its own TrustedIssuer.RootCAs pool. With the shared client, only the first permissive issuer's pool was honored — a second issuer with a different internal CA was silently rejected with a certificate error despite correct configuration.
Security
  • CheckIPLimit (memory + valkey) no longer embeds the client IP or per-IP registration counts in the returned error. ErrClientIPLimitExceeded's message is now the generic "rate limit exceeded" and both backends return the bare sentinel, so the error value is safe to render wherever it propagates — structured WARN logs and any consumer that surfaces it — without revealing that the IP is tracked or exposing the counts (the register handler already returns its own fixed HTTP 429 message). Callers still match by identity via errors.Is (the register handler is unchanged); the IP and current/max counts are preserved in a structured WARN log at the check site. See #​519.

  • SelfIssuedExchange refuses to issue a token when the validated subject asserts an email claim without email_verified: true and no explicit Options.Email is supplied (ErrUnverifiedSubjectEmail). The issued token defaults the subject's email into its identity claims, which resource servers authorize on (allowedClaims.email patterns and subjectClaim: email remapping do not consult email_verified), so issuance now fails closed on an unproven email. Subjects without an email claim (workload identities such as Kubernetes ServiceAccount tokens) are exempt.

  • SelfIssuedExchange treats re-attaching the actor already outermost on the subject's act chain as adding no delegation hop: a re-exchange whose new actor's iss+sub equals the current outermost actor is collapsed to a bare renewal and rejected with ErrSelfRenewalDenied. Without this, a holder of a self-issued on-behalf-of token plus its own actor token could refresh the token's TTL indefinitely by re-attaching the same actor each hop. Re-exchange with a different actor still extends the chain normally.

  • SelfIssuedExchange now enforces proof-of-possession when the validated subject token carries an RFC 9449 §6.1 cnf.jkt confirmation: the request's DPoP proof key thumbprint must equal the subject's cnf.jkt, otherwise the exchange is rejected (ErrSubjectKeyMismatch, audit reason token_exchange_subject_key_mismatch). This prevents a bearer who lacks the confirmation key from laundering a key-bound token into one bound to a different key (or an unbound token). Subjects with no cnf confirmation are unaffected. server.SubjectIdentity gains a ConfirmationJKT field carrying the subject token's confirmation thumbprint.

  • SelfIssuedExchange honors a new Config.TokenExchangeAllowedResources allowlist: when non-empty, a requested resource that is neither the server's own ResourceIdentifier nor a listed value is rejected with invalid_target (ErrInvalidTarget, audit reason token_exchange_resource_not_allowed). Empty (the default) accepts any resource, preserving current behavior. This caps the audiences an unauthenticated self-issued exchange can mint tokens for.

  • The permissive JWKS clients (AllowPrivateIPJWKS / per-issuer AllowPrivateIPJWKSHosts) now keep the cross-host redirect guard and tuned timeouts. Server.getJWKSClient() (SSO forwarded-ID-token path) and NewOIDCValidator() (trusted-issuer path) previously hand-rolled a raw &http.Client{Transport: http.DefaultTransport} to honor a process-installed CA, which dropped NewPrivateIPAllowedHTTPClient/NewHostScopedPrivateIPHTTPClient's CheckRedirect: blockCrossHostRedirect and ResponseHeaderTimeout/TLSHandshakeTimeout. Because these paths already disable the SSRF dial guard, a compromised internal Dex could 302 the JWKS fetch to an arbitrary internal HTTPS target. Both constructors now copy only http.DefaultTransport's RootCAs (not the whole TLSClientConfig) onto their own transport — preserving CA trust while keeping the redirect guard, dialer semantics, and timeouts, and ensuring a global InsecureSkipVerify/ServerName/client cert cannot leak onto these clients so the "never InsecureSkipVerify" invariant holds by construction. Both call sites now use the constructors. This also closes the pre-existing gap in the trusted-issuer path. See giantswarm/giantswarm#37059 and #​493.

Fixed
  • Token metadata is now also saved for the upstream id_token returned alongside a minted access/refresh pair, keyed by the id_token string with TokenType "id" and the id_token's own exp as expiry (falling back to the access-token expiry when exp is unreadable). A bearer equal to the issued id_token presented at the resource-server front door now resolves to the same FamilyID-derived session as its sibling access token, and the session stays stable across refresh rotations. Previously the id_token fell back to the bearer-derived ext- session, which changed on every rotation. An id_token this server never issued still gets the bearer-derived ext- session.
  • The Valkey {prefix}userclient:{uid}:{cid} set backing bulk revocation (RevokeAllTokensForUserClient) no longer grows without bound. Every grant SADDs the access and refresh token IDs into the set, but nothing ever SREMs a member — access tokens expire purely by their own key TTL, which fires no set-membership cleanup — and the set itself carried no TTL, so it was only ever reclaimed by the wholesale DEL on reuse/theft detection. In the happy path it therefore leaked one member per token forever; once bloated it also fed long-dead tokens into RevokeAllTokensForUserClient's per-member provider-revocation loop, whose failure-rate threshold could then abort the local revocation. Both SADD sites now add the member and stamp an extend-only TTL on the set in a single atomic Lua script: TTL = max(existing, member horizon), so the key self-evicts after its longest-lived member's horizon and a short-lived access-token add can never shrink it below a still-live refresh-token member. Doing it atomically (rather than separate SADD + EXPIRE GT + EXPIRE NX round-trips) closes a creation-time race where concurrent grants could pin the set to a short access-token horizon and drop live refresh members, keeps the result order-independent across a grant's access/refresh writes, and halves the round-trips on the token-issuance hot path. A horizon-less add (a token metadata save with a zero/expired ExpiresAt) still bounds the set — it falls back to the store's refresh-token TTL — so the set can never be left unbounded, and a sub-second horizon is floored to 1s so it can never truncate to EXPIRE 0 and delete the set.
  • Valkey calculateTTL now clamps a positive sub-second TTL up to a one-second floor. valkey-go's Ex() builds SET with a whole-second EX argument, so a near-expiry save (e.g. 50ms remaining) truncated to EX 0 and Valkey rejected it with invalid expire time in 'set' command; the "already expired -> 0" semantics are preserved. RevokeJTI bypassed calculateTTL with its own time.Until(expiresAt) and hit the identical EX 0 truncation, silently failing to denylist a self-issued JWT revoked in the final second before its (whole-second) exp; it now routes through calculateTTL too. See #​518.
  • SSO forwarded-ID-token JWKS validation now honors a CA bundle the host process installs on http.DefaultTransport when AllowPrivateIPJWKS is set. Previously Server.getJWKSClient() built a JWKS client that verified against the system certificate pool alone, so an internal-CA Dex forwarding SSO ID tokens was rejected with x509: certificate signed by unknown authority even when the host process had installed that CA — the trusted-issuer permissive client (NewOIDCValidator) already worked this way, the forwarded-token path did not. The SSRF-safe client used when AllowPrivateIPJWKS is unset is unchanged and still verifies against the system pool. See giantswarm/giantswarm#37059.
Changed
  • Server.ExchangeSubjectToken is renamed to Server.SelfIssuedExchange and takes a SelfIssuedExchangeRequest (embedding SubjectExchange with Subject and Actor as TypedToken{Token, Type}, plus DPoPJKT and Options) in place of positional parameters. It now preserves any act chain already on the subject token (nesting the new actor above it, bounded depth) and defaults email, email_verified, name and groups from the validated subject when Options leaves them unset; an explicit Options value still takes precedence. When no resource is supplied the issued token's audience defaults to the server's resource identifier.

  • Server.BrokerExchangeSubjectToken is renamed to Server.BrokeredExchange and takes a BrokeredExchangeRequest.

  • The /oauth/token local exchange no longer requires a resource parameter; when omitted, the issued token's audience is the server's resource identifier. A resource-less, audience-less exchange therefore now succeeds where it previously returned invalid_request ("resource is required"). Deployments that relied on the mandatory resource as a policy gate must now constrain the mintable audiences with Config.TokenExchangeAllowedResources.

  • SelfIssuedExchange now rejects re-exchanging a token this server issued when no new actor_token is supplied (new server.ErrSelfRenewalDenied, audit reason token_exchange_self_renewal_denied). Re-exchanging a self-issued token is allowed only to add an actor, extending the delegation chain; a bare re-exchange would refresh the TTL open-endedly. Subject tokens from external issuers are unaffected.

  • ValidateToken now logs a forwarded-ID-token validation failure at WARN (was DEBUG) when the token carried a trusted audience — i.e. it was meant to be an SSO-forwarded ID token — but signature/JWKS validation then failed (e.g. a JWKS TLS/CA error). This surfaces real SSO misconfigurations without enabling debug logging. Tokens that simply don't carry a trusted audience are not SSO tokens and remain silent. No token material is logged (only an 8-character suffix for correlation).

  • The resource-server ValidateToken middleware now assigns a session ID to every validated token, not only those backed by stored refresh-token-family metadata. A token with a family still uses its FamilyID; any other validated token (forwarded ID token, trusted-issuer, self-issued exchange-minted JWT) falls back to SessionIDForBearer. Self-issued exchange-minted JWTs previously reached downstream handlers with no session, so resource servers could not session-scope an on-behalf-of bearer re-presented at the front door; they now carry one.

Removed
  • Server.WorkloadExchangeSubjectToken and Config.EnableWorkloadTokenExchange: the workload-authenticated brokered path (a brokered exchange with no OAuth client credentials) is removed. Brokered token exchange now always requires an authenticated confidential client; the on-behalf-of agent path uses SelfIssuedExchange instead.
  • Server.LocalMintExchanger and NewLocalMintExchanger: local token issuance is now SelfIssuedExchange. The Exchanger interface is retained for downstream/remote exchange implementations.
  • Config.DelegationDefaultResource: the issued token's audience defaults to the server's resource identifier whenever no resource is supplied, so the configurable default is no longer needed.
  • server.ExchangerResult.JTI. Consequently, brokered token_issued audit events no longer carry a jti field (the downstream issuer's token identifier is no longer surfaced). Update any dashboard or alert that correlated brokered issuance by jti.
  • Audit surface: the exchange: "workload" audit value and the token_exchange_resource_missing failure reason are no longer emitted (the workload path and the resource-required rejection are gone). Update any dashboard or alert keyed on those values. The self-issued exchange's token_issued event now carries jti and session_id.
Added
  • server.Config.TokenExchangeAllowedResources []string: optional allowlist capping the RFC 8707 resource values the self-issued RFC 8693 flow will mint a token for. Empty (the default) accepts any resource; when non-empty, a request whose resource is neither the server's own ResourceIdentifier nor a listed value is rejected with invalid_target.

  • server.Server.SessionIDForBearer(bearerToken string) string: returns the deterministic ext-<hex> session identifier for a validated bearer with no refresh-token family. Same derivation the forwarded-token and token-exchange paths use, so the value agrees across hops.

  • server.Config.DelegationDefaultResource string: RFC 8707 resource applied to a local token-exchange request that carries an actor_token but omits both audience and resource. The minted token is bound to this value (typically the server's own resource identifier), so an on-behalf-of caller that cannot set a resource itself still gets a token accepted back at this server. Gated to the delegation path: a resource-less exchange with no actor_token is still rejected with invalid_request. Empty (the default) disables it.

  • oidc.JWKSClientOptions.AllowPrivateIPHosts []string: host-scoped alternative to AllowPrivateIP. When set, the JWKS client allows private-IP resolution only for the explicitly listed hostnames; all other hosts retain the SSRF/DNS-rebinding guard. NewJWKSClientWithOptions wires a NewHostScopedPrivateIPHTTPClient when this field is non-empty and AllowPrivateIP is false.

  • oidc.NewHostScopedPrivateIPHTTPClient(allowedHosts []string, timeout time.Duration) *http.Client and oidc.HostScopedPrivateIPDialContext(dialer, allowedHosts): HTTP client and dial context that enforce the normal SSRF guard for all hosts except the listed ones, which are permitted to resolve to private IPs.

  • server.TrustedIssuer.AllowPrivateIPJWKSHosts []string: per-issuer host-scoped alternative to AllowPrivateIPJWKS. Allows the JWKS fetch to resolve to a private IP only when the URL's hostname matches one of the listed values. All other hosts retain SSRF protection. Prefer this over AllowPrivateIPJWKS when the private endpoint is a known in-cluster service (e.g. muster.agentic-platform.svc.cluster.local). NewOIDCValidator builds a per-issuer JWKSClient for each entry that sets this field; the shared permissive client is only allocated when at least one issuer sets the legacy AllowPrivateIPJWKS.

  • server.Server.EnsureConfidentialClient(ctx, clientID, clientSecret string, scopes []string) (seeded bool, err error): idempotently provisions a confidential OAuth client with a caller-supplied id and secret (unlike RegisterClientV2, which generates random ones). If the client already exists with a matching secret it is a no-op; otherwise the record is (re)written with a fresh bcrypt hash. Intended to declaratively seed a known confidential client (e.g. a token-exchange broker) from a mounted secret at startup, so a wiped client store self-heals.

  • server.TrustedIssuer.SubjectClaim string: names the verified claim whose value becomes SubjectIdentity.Subject (and thus the sub of any token minted from the identity). Empty keeps the standard sub claim. Use it when an issuer's sub is opaque but another claim (e.g. email) carries the canonical subject the downstream relies on. Fail-closed: a token whose configured claim is absent or not a non-empty string is rejected.

  • server.Server.AcceptTrustedIssuerToken(ctx, bearerToken): validates a Bearer JWT against the WithTrustedIssuers configuration and returns a ForwardedIDTokenAcceptance (same shape as AcceptForwardedIDToken, including the ext-<hex> session ID). Intended as a fallback for aggregators that receive ErrTrustedAudienceMismatch from AcceptForwardedIDToken, e.g. a raw Kubernetes ServiceAccount projected token whose aud is the server's own resource identifier rather than a TrustedAudiences entry.

  • server.LocalMintExchanger and NewLocalMintExchanger(cfg *Config) (*LocalMintExchanger, error): an Exchanger implementation that mints a signed JWT access token locally using the server's own RSA/EC key. Intended for broker-routed targets where the broker is the authoritative issuer. Requires JWT access token mode; returns an error otherwise.

  • actor_token / actor_token_type support on the direct token-exchange path (ExchangeSubjectToken): when an actor token is presented, it is validated against the server's trusted issuers and the resulting actor identity is written as the act claim of the issued JWT (act.iss = actor issuer, act.sub = actor subject). Any actor validated against the trusted issuers is accepted; self-delegation (actor equal to subject) is a no-op and omits the act claim.

  • providers.UserInfo.ActorIssuer string and providers.UserInfo.ActorSubject string: actor identity fields populated when a validated token carries an RFC 8693 §4.4 act claim. Empty for tokens without delegation.

  • providers.UserInfo.IsOBO() bool: reports whether an external-issuer token carries an RFC 8693 act claim (a delegated human identity). True exactly when ActorSubject is populated.

  • oidc.IDTokenClaims.Act *oidc.ActorClaim and oidc.ActorClaim (Issuer string, Subject string, Act *oidc.ActorClaim): decoded automatically from the act claim of a JWT when present. The nested Act field carries a multi-hop RFC 8693 §4.4 delegation chain (act.act…), so a token minted for a second A2A hop decodes with Act = the most recent actor and Act.Act = the prior one.

  • oidc.ActorClaim.Chain() []oidc.ActorClaim and providers.UserInfo.ActorChain []oidc.ActorClaim: the full delegation chain flattened from the nested act claim, ordered outermost (most recent actor) first; UserInfo.ActorIssuer/ActorSubject mirror its first element. Resource servers authorizing a multi-hop A2A call walk this slice to match any actor in the chain rather than only the leaf.

  • LocalMintExchanger now copies email, email_verified, and groups from the validated subject token into the minted access token (email_verified only alongside a non-empty email), and nests any act chain already on the subject token beneath the new actor so a multi-hop A2A delegation chain is preserved. A chain exceeding the maximum nesting depth is rejected.

  • server.Actor.Act *server.Actor: nests the prior actor when minting a token for a further delegation hop.

  • server.ExchangerResult.JTI string: lets an Exchanger surface the issued token's jti so the broker records it in the mint audit event; the broker's token_issued event now carries a jti detail when set.

  • providers.TokenSourceTrustedIssuer ("trusted-issuer") constant and the UserInfo.IsExternalIssuer() method. A Bearer validated against a WithTrustedIssuers entry is tagged trusted-issuer (instead of TokenSourceSSO), so downstream servers can distinguish an external-IdP token (needs impersonation or token-exchange) from a Dex-forwarded SSO token (can be passed through). IsOBO() further reports whether it carries an act claim.

  • providers.UserInfo.Issuer string field, populated on the external-issuer (trusted-issuer) path with the originating issuer URL (e.g. the cluster's SA OIDC issuer). Empty for TokenSourceSSO and TokenSourceOAuth.

  • server.WithTrustedAudiences([]string) Option: functional option equivalent to setting Config.TrustedAudiences, for symmetry with WithTrustedIssuers.

Changed
  • The broker now trusts its own self-minted at+jwt tokens as token-exchange subjects without a self-referential WithTrustedIssuers entry. A self-issued subject validator (verifying signature, typ, iss, exp, audience, revocation, and family through the existing self-issued JWT pipeline) is chained ahead of the trusted-issuer validator for the id_token, access_token, and jwt subject token types when running in JWT access-token mode; any token whose iss is not the broker's own is delegated to the trusted-issuer validator unchanged.

  • server.ExchangeSubjectToken signature gains two new parameters: actorToken string and actorTokenType string (inserted after subjectTokenType, before resource). Callers passing no actor token must supply empty strings. The issued JWT no longer carries a self-referential act claim; act is omitted when no actor token is presented and set to the validated actor identity when one is.

  • server.validateTrustedIssuerJWT and server.AcceptTrustedIssuerToken now set UserInfo.TokenSource to TokenSourceTrustedIssuer, plus UserInfo.Issuer, instead of the previous TokenSourceSSO. Callers that relied on userInfo.IsSSO() being true for these tokens must switch to userInfo.IsExternalIssuer() (or IsOBO()).

  • server.ExchangerRequest now carries ActorToken string, ActorTokenType string, and Actor *server.SubjectIdentity. The brokered token-exchange endpoint accepts RFC 8693 actor_token and actor_token_type form parameters; when present the actor token is validated against the server's trusted issuers (same validator path as the subject token) and the verified actor identity is forwarded to the Exchanger. Absent actor params leave Actor nil and BrokerExchangeSubjectToken behaves identically to before.

  • server.TokenExchangeUnsupportedTypeError now exposes a Role() string method returning "subject" or "actor" to identify which token in the exchange request had an unsupported type. Handlers and callers that previously matched this error via errors.As now receive accurate role information for error messages and logs.

  • providers/tokencache: new package with a generic LRU-bounded token cache (Cache) for short-lived access tokens, usable by any credential provider (OIDC exchange, GitHub App installation tokens, etc.).

  • server.TrustedIssuer.AcceptedTypHeaders: per-issuer list of accepted JWT typ header values for Bearer validation. Empty keeps the RFC 9068 §4 default (at+jwt). Kubernetes ServiceAccount tokens carry no typ header, so the previously unconditional at+jwt requirement made the documented K8s SA trust use case impossible; configure [""] (optionally with "JWT") on the cluster issuer entry to accept them. Signature, issuer, audience, and claim checks are unchanged.

  • server.Config.EnableWorkloadTokenExchange bool: enables the workload-authenticated RFC 8693 path. When true and a brokered request carries no OAuth client credentials, the request is authenticated by the subject (and optional actor) token itself, validated against TrustedIssuers. An actor_token mints a delegated token (any validated trusted-issuer actor is accepted); with no actor_token the subject token is bound to this broker's issuer and a subject-only token is minted. Default false: requests with no client credentials are rejected with invalid_client.

Changed
  • providers/oidc.TokenExchangeCache, CachedExchangeToken, TokenExchangeCacheStats, NewTokenExchangeCache, NewTokenExchangeCacheWithMaxEntries, and GenerateCacheKey have moved to the new providers/tokencache package as Cache, Token, Stats, New, NewWithMaxEntries, and GenerateCacheKey. The old names are removed; update import paths.
Fixed
  • Trusted-issuer JWKS fetch ignored a CA bundle installed on http.DefaultTransport when AllowPrivateIPJWKS was set. NewOIDCValidator's permissive JWKS client built a fresh transport that verified against the system certificate pool alone, so an in-cluster issuer presenting an internal CA (the case AllowPrivateIPJWKS exists for) failed with x509: certificate signed by unknown authority even when the host process had installed that CA on http.DefaultTransport. The permissive client is now backed by http.DefaultTransport, honoring a process-installed CA bundle. The SSRF-safe client (issuers without AllowPrivateIPJWKS) is unchanged and still verifies against the system pool.

  • Token exchange invalid_client for client secrets containing + (or other form-significant characters). providers/oidc.TokenExchangeClient.Exchange authenticated to the downstream token endpoint with http.Request.SetBasicAuth(clientID, clientSecret), which base64-encodes the raw values. RFC 6749 §2.3.1 requires both components to be application/x-www-form-urlencoded first; spec-compliant servers (e.g. Dex) url-unescape the Basic credential, so a + in the secret was decoded to a space and rejected with invalid_client. Both components are now url-encoded before the Basic credential is formed. Secrets containing only / and = were unaffected, which is why some clusters worked and others did not.

  • handleBrokeredTokenExchangeError reported subject_token_type in the error response and log when the failing token was the actor token. TokenExchangeUnsupportedTypeError now carries the token role ("subject" or "actor") and the handler uses it to emit the correct field name.

  • Actor token audience unenforced when the issuer entry had no AllowedAudiences. validateExchangeActorToken passed nil default audiences to Validate, so a ServiceAccount token minted for an unrelated audience was accepted as an actor token when the issuer's AllowedAudiences was empty. The broker's own issuer URL is now passed as the default, ensuring actor tokens are always audience-bound to this broker unless the issuer entry overrides it with an explicit AllowedAudiences.

  • Broker success audit event used act_iss (subject's issuer) alongside actor_iss (actor's issuer), making the two fields ambiguous for audit queries. The subject issuer field in the broker's token-issued event is now subject_iss.

  • ExchangerRequest.ActorTokenType could be non-empty while ActorToken and Actor were both empty when a caller passed actor_token_type without actor_token. BrokerExchangeSubjectToken now normalises ActorTokenType to "" when no actor token is present, keeping the three actor fields consistent. The HTTP handler also rejects actor_token present without actor_token_type with 400 invalid_request per RFC 8693 §2.1.

  • Subject and actor token validation failures in the brokered flow produced audit events without client_id, audience, or session_id. The early-rejection paths (no exchanger, audience not in allowlist) already carried those fields; validation failures did not. validateExchangeToken now accepts an extra-details map; BrokerExchangeSubjectToken passes the brokered flow context so every brokered failure event is fully correlated.

  • Stale issuer JWKS broke every token after an issuer signing-key rotation. oidc.JWKSClient cached a trusted issuer's JWKS for the full TTL (1h) and never refetched when a token presented a kid absent from the cached set, so a single Dex key rotation made ValidateIDToken (and therefore the token-exchange broker's subject-token validation) reject every current user token until the process was restarted (muster#847). The client now forces a single bounded refetch on an unknown kid and retries verification once. The refetch is rate-limited to at most one network fetch per DefaultJWKSRefetchBackoff (1m) per JWKS URI (negative caching), so a flood of tokens carrying genuinely bogus kids cannot hammer the issuer's JWKS endpoint. A legitimate rotation now self-heals on the first affected token without a restart. New exported sentinel oidc.ErrKeyNotFound.

Security
  • Minted actor chains are bounded. LocalMintExchanger rejects an act chain deeper than 10 hops, capping token size and parser load on every downstream that validates the token.

  • Mint path honours the rate limiter when called in-process. BrokerExchangeSubjectToken and WorkloadExchangeSubjectToken now consult the configured UserRateLimiter (keyed on the per-session ID) before minting, so a caller invoking these methods directly (e.g. an aggregator, bypassing the HTTP middleware) cannot flood mints from a single compromised session. New exported sentinel server.ErrExchangeRateLimited; the rejection emits an auth_failure audit event with reason token_exchange_rate_limited. A nil UserRateLimiter leaves the path unrestricted as before.

  • Private-IP-allowed HTTP client pins redirects to the original host. The client returned by NewPrivateIPAllowedHTTPClient (used for JWKS, discovery, and token exchange when AllowPrivateIP is set) now refuses a redirect that changes host or port and re-applies the 10-hop redirect cap. Because that client cannot filter private IPs, a discovery or JWKS endpoint can no longer 302 the fetch to an arbitrary internal address or a different port on the same host. Same-host redirects are still followed.


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate
renovate Bot requested a review from a team as a code owner July 20, 2026 18:36
@renovate renovate Bot added dependencies renovate This is an automated PR by RenovateBot labels Jul 20, 2026
@renovate

renovate Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

ℹ️ Artifact update notice

File name: go.mod

In order to perform the update(s) described in the table above, Renovate ran the go get command, which resulted in the following additional change(s):

  • 2 additional dependencies were updated

Details:

Package Change
github.com/prometheus/client_golang v1.23.2 -> v1.24.1
github.com/prometheus/procfs v0.21.0 -> v0.21.1

@renovate
renovate Bot force-pushed the renovate/github.com-giantswarm-mcp-oauth-1.x branch from c457225 to 470ff06 Compare July 21, 2026 20:35
@renovate renovate Bot changed the title fix(deps): update module github.com/giantswarm/mcp-oauth to v1.1.4 fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.0 Jul 21, 2026
@renovate
renovate Bot force-pushed the renovate/github.com-giantswarm-mcp-oauth-1.x branch from 470ff06 to 9d5be84 Compare July 22, 2026 15:47
@renovate renovate Bot changed the title fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.0 fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.1 Jul 22, 2026
@renovate
renovate Bot force-pushed the renovate/github.com-giantswarm-mcp-oauth-1.x branch 2 times, most recently from a60050d to 67b7e36 Compare July 24, 2026 10:37
@renovate renovate Bot changed the title fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.1 fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.2 Jul 24, 2026
@renovate
renovate Bot force-pushed the renovate/github.com-giantswarm-mcp-oauth-1.x branch from 67b7e36 to c75e720 Compare July 24, 2026 15:03
@renovate renovate Bot changed the title fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.2 fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.3 Jul 24, 2026
@renovate
renovate Bot force-pushed the renovate/github.com-giantswarm-mcp-oauth-1.x branch 3 times, most recently from adb810f to f4b7fa4 Compare July 28, 2026 09:29
@renovate
renovate Bot force-pushed the renovate/github.com-giantswarm-mcp-oauth-1.x branch from f4b7fa4 to 8b5ca63 Compare July 28, 2026 17:57
@renovate renovate Bot changed the title fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.3 fix(deps): update module github.com/giantswarm/mcp-oauth to v1.2.4 Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies renovate This is an automated PR by RenovateBot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants