Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 150 additions & 57 deletions authbridge/authlib/plugins/tokenexchange/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"sync/atomic"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
Expand All @@ -29,16 +28,37 @@ import (
// Inline doc comments retain long-form rationale; struct tags carry
// single-line summaries for templating. See pipeline/schema.go.
type tokenExchangeConfig struct {
// TokenURL is the OAuth token endpoint. Explicit value wins; else
// derived from KeycloakURL + KeycloakRealm using Keycloak's
// convention.
TokenURL string `json:"token_url" description:"OAuth token endpoint URL. Required unless keycloak_url + keycloak_realm are both set (the plugin derives token_url from the pair)."`

// KeycloakURL and KeycloakRealm are a convenience for deriving
// TokenURL when the operator prefers to supply Keycloak base + realm
// rather than the full token endpoint.
KeycloakURL string `json:"keycloak_url" description:"Internal Keycloak base URL. Required (with keycloak_realm) when token_url is empty."`
KeycloakRealm string `json:"keycloak_realm" description:"Keycloak realm name. Required (with keycloak_url) when token_url is empty."`
// TokenURL is the OAuth token endpoint. Explicit value always wins.
// When empty, derived from Provider + ProviderURL + ProviderRealm
// (or the deprecated keycloak_url + keycloak_realm pair).
TokenURL string `json:"token_url" description:"OAuth token endpoint URL. Required unless provider + provider_url (or keycloak_url + keycloak_realm) are set for automatic derivation."`

// Provider selects the IdP for automatic endpoint derivation.
// Supported: keycloak, entra-id, okta, generic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — This comment still says "Supported: keycloak, entra-id, okta, generic" (and the provider_url/provider_realm comments below at lines 46-53 describe entra-id/okta interpretations), but the enum is now correctly keycloak,generic. Minor staleness — either trim the comment to match the enum, or mark the not-yet-registered ones as "(future)" so it doesn't read as currently supported.

// When empty and keycloak_url is set, defaults to "keycloak" for
// backward compatibility. When "generic" or empty, token_url must
// be supplied explicitly.
Provider string `json:"provider" description:"IdP provider for endpoint derivation and client auth. Only registered providers are accepted." enum:"keycloak,generic"`

// ProviderURL is the IdP base URL used for endpoint derivation.
// Interpretation depends on Provider:
// keycloak: internal Keycloak base URL (e.g. https://keycloak.example.com)
// entra-id: ignored (endpoints are derived from ProviderRealm/tenant)
// okta: Okta domain URL (e.g. https://dev-123.okta.com)
ProviderURL string `json:"provider_url" description:"IdP base URL for endpoint derivation. Interpretation varies by provider."`

// ProviderRealm is the IdP-specific namespace/tenant:
// keycloak: realm name (e.g. kagenti)
// entra-id: tenant ID or domain (e.g. contoso.onmicrosoft.com)
// okta: authorization server ID (optional, omit for org-level)
ProviderRealm string `json:"provider_realm" description:"IdP-specific realm/tenant/auth-server. Keycloak: realm name. Entra ID: tenant ID. Okta: auth server ID (optional)."`

// KeycloakURL and KeycloakRealm are deprecated aliases for
// ProviderURL and ProviderRealm with Provider="keycloak".
// Supported for backward compatibility; prefer provider_url +
// provider_realm + provider instead.
KeycloakURL string `json:"keycloak_url" description:"DEPRECATED: use provider_url + provider=keycloak instead. Internal Keycloak base URL."`
KeycloakRealm string `json:"keycloak_realm" description:"DEPRECATED: use provider_realm + provider=keycloak instead. Keycloak realm name."`

// DefaultPolicy is applied when a request's host matches no route:
// "passthrough" (default) forwards the request unchanged;
Expand Down Expand Up @@ -74,28 +94,26 @@ type tokenExchangeIdentity struct {
// Type is one of "spiffe" or "client-secret".
Type string `json:"type" required:"true" description:"Identity scheme: spiffe (JWT-SVID assertion) or client-secret." enum:"spiffe,client-secret"`

// ClientID identifies the client in Keycloak. Explicit value wins;
// else read from ClientIDFile at Configure time (or by Init if the
// file isn't yet available).
ClientID string `json:"client_id" description:"Inline Keycloak client ID. One of client_id or client_id_file is required."`
// ClientID identifies the OAuth client at the IdP. Explicit value
// wins; else read from ClientIDFile at Configure time (or by Init
// if the file isn't yet available).
ClientID string `json:"client_id" description:"OAuth client ID. One of client_id or client_id_file is required."`
ClientIDFile string `json:"client_id_file" description:"Read client ID from this file. Default: /shared/client-id.txt."`

// ClientSecret / ClientSecretFile are the client-secret credentials
// (type=client-secret).
ClientSecret string `json:"client_secret" description:"Inline Keycloak client secret (type=client-secret)."`
ClientSecret string `json:"client_secret" description:"OAuth client secret (type=client-secret)."`
ClientSecretFile string `json:"client_secret_file" description:"Read client secret from file. Default: /shared/client-secret.txt."`

// JWTAudience is the audience claim minted on the JWT-SVID used as
// the RFC 8693 client assertion. Required when Type=="spiffe";
// ignored otherwise. Lives on the plugin (not the framework spiffe
// block) because only the spiffe identity path consumes it.
// ignored otherwise.
JWTAudience string `json:"jwt_audience" description:"Audience claim minted on the JWT-SVID assertion. REQUIRED when type=spiffe; ignored otherwise."`

// jwt_svid_path was historically a per-plugin path to the JWT-SVID
// file written by spiffe-helper. Removed in favor of injection via
// the framework spiffe.Provider (see the top-level spiffe block in
// authlib/config). T11 wires the Provider into TokenExchange and
// supplies the JWTSource directly.
// AssertionType is the client_assertion_type URN used with spiffe
// identity. Default: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe
// (supported by Keycloak). Set to jwt-bearer for Okta compatibility.
AssertionType string `json:"assertion_type" description:"Client assertion type URN for spiffe identity. Default: jwt-spiffe. Use jwt-bearer for Okta." enum:"jwt-spiffe,jwt-bearer"`
}

type tokenExchangeRoutes struct {
Expand All @@ -120,12 +138,38 @@ type tokenExchangeRoute struct {
}

func (c *tokenExchangeConfig) applyDefaults() {
if c.TokenURL == "" && c.KeycloakURL != "" && c.KeycloakRealm != "" {
base := strings.TrimRight(c.KeycloakURL, "/") + "/realms/" + c.KeycloakRealm
c.TokenURL = base + "/protocol/openid-connect/token"
// Backward compatibility: migrate keycloak_url/keycloak_realm to
// provider_url/provider_realm when the new fields are empty.
// Also infer provider=keycloak when any deprecated keycloak_* field
// is present (handles mixed legacy/new paths).
if c.KeycloakURL != "" || c.KeycloakRealm != "" {
if c.Provider == "" {
c.Provider = DefaultProvider
}
}
if c.KeycloakURL != "" && c.ProviderURL == "" {
c.ProviderURL = c.KeycloakURL
slog.Warn("token-exchange: keycloak_url is deprecated; use provider_url + provider=keycloak instead")
}
if c.KeycloakRealm != "" && c.ProviderRealm == "" {
c.ProviderRealm = c.KeycloakRealm
slog.Warn("token-exchange: keycloak_realm is deprecated; use provider_realm + provider=keycloak instead")
}

// Derive token_url and default assertion_type from the registered
// IdP provider when not set explicitly.
if c.Provider != "" {
if p := LookupProvider(c.Provider); p != nil {
if c.TokenURL == "" {
c.TokenURL = p.TokenEndpoint(c.ProviderURL, c.ProviderRealm)
}
if c.Identity.AssertionType == "" && c.Identity.Type == SpiffeIdentity {
c.Identity.AssertionType = p.DefaultAssertionType()
}
}
}
if c.DefaultPolicy == "" {
c.DefaultPolicy = "passthrough"
c.DefaultPolicy = PassthroughPolicy
}
if c.NoTokenPolicy == "" {
c.NoTokenPolicy = auth.NoTokenPolicyDeny
Expand All @@ -143,13 +187,13 @@ func (c *tokenExchangeConfig) applyDefaults() {
c.Routes.File = "/etc/authproxy/routes.yaml"
}
switch c.Identity.Type {
case "spiffe":
case SpiffeIdentity:
if c.Identity.ClientID == "" && c.Identity.ClientIDFile == "" {
c.Identity.ClientIDFile = "/shared/client-id.txt"
}
// JWT-SVID source is injected via the framework SPIFFE provider
// (T11) rather than read from a per-plugin file path.
case "client-secret":
case ClientSecretIdentity:
if c.Identity.ClientID == "" && c.Identity.ClientIDFile == "" {
c.Identity.ClientIDFile = "/shared/client-id.txt"
}
Expand All @@ -160,21 +204,27 @@ func (c *tokenExchangeConfig) applyDefaults() {
}

func (c *tokenExchangeConfig) validate() error {
// Reject unknown provider names early.
if c.Provider != "" && c.Provider != GenericProvider {
if LookupProvider(c.Provider) == nil {
return fmt.Errorf("provider %q is not registered (available providers are registered via init())", c.Provider)
}
}
if c.TokenURL == "" {
return errors.New("token_url is required (or set keycloak_url + keycloak_realm)")
return errors.New("token_url is required (or set provider + provider_url + provider_realm)")
}
switch c.DefaultPolicy {
case "exchange", "passthrough":
case ExchangePolicy, PassthroughPolicy:
default:
return fmt.Errorf("default_policy must be exchange or passthrough, got %q", c.DefaultPolicy)
return fmt.Errorf("default_policy must be %s or %s, got %q", ExchangePolicy, PassthroughPolicy, c.DefaultPolicy)
}
switch c.NoTokenPolicy {
case auth.NoTokenPolicyAllow, auth.NoTokenPolicyDeny, auth.NoTokenPolicyClientCredentials:
default:
return fmt.Errorf("no_token_policy must be allow, deny, or client-credentials, got %q", c.NoTokenPolicy)
}
switch c.Identity.Type {
case "spiffe":
case SpiffeIdentity:
// applyDefaults fills the identity file paths when the
// matching inline values are empty, so no per-field check
// for client_id here — Configure's best-effort read logs a
Expand All @@ -189,14 +239,36 @@ func (c *tokenExchangeConfig) validate() error {
if c.Identity.JWTAudience == "" {
return errors.New("tokenexchange: identity.type=spiffe requires identity.jwt_audience to be set")
}
case "client-secret":
// Reject invalid assertion_type (non-empty but unknown).
if c.Identity.AssertionType != "" {
if _, ok := AssertionTypeURN[c.Identity.AssertionType]; !ok {
return fmt.Errorf("tokenexchange: unknown assertion_type %q (supported: %s, %s)", c.Identity.AssertionType, JWTSpiffeAssertion, JWTBearerAssertion)
}
}
case ClientSecretIdentity:
// applyDefaults fills the identity file paths when the
// matching inline values are empty.
case "":
return errors.New("identity.type is required (spiffe or client-secret)")
return fmt.Errorf("identity.type is required (%s or %s)", SpiffeIdentity, ClientSecretIdentity)
default:
return fmt.Errorf("unknown identity.type %q", c.Identity.Type)
}
// Validate provider↔identity compatibility when a provider is set.
if c.Provider != "" && c.Provider != GenericProvider {
if p := LookupProvider(c.Provider); p != nil {
supported := p.SupportedIdentityTypes()
found := false
for _, s := range supported {
if s == c.Identity.Type {
found = true
break
}
}
if !found {
return fmt.Errorf("provider %q does not support identity.type=%q (supported: %v)", c.Provider, c.Identity.Type, supported)
}
}
}
return nil
}

Expand Down Expand Up @@ -281,7 +353,7 @@ func (p *TokenExchange) Name() string { return "token-exchange" }

func (p *TokenExchange) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{
Description: "RFC 8693 outbound token exchange against Keycloak per route.",
Description: "RFC 8693 outbound token exchange per route. Supports Keycloak, Entra ID, Okta, and any RFC 8693-compliant IdP.",
}
}

Expand Down Expand Up @@ -350,8 +422,7 @@ func (p *TokenExchange) Configure(raw json.RawMessage) error {
}

jwtSrc := p.jwtSource(c.Identity.JWTAudience)
clientAuth, err := buildClientAuthFrom(c.Identity.Type,
c.Identity.ClientID, c.Identity.ClientSecret, jwtSrc)
clientAuth, err := buildClientAuth(c.Provider, c.Identity, jwtSrc)
if err != nil {
return fmt.Errorf("token-exchange: %w", err)
}
Expand Down Expand Up @@ -399,9 +470,9 @@ func credentialsAreReady(id tokenExchangeIdentity, jwtSrc fwspiffe.JWTSource) bo
return false
}
switch id.Type {
case "client-secret":
case ClientSecretIdentity:
return id.ClientSecret != ""
case "spiffe":
case SpiffeIdentity:
// SPIFFE identity is ready iff a JWTSource was injected (via the
// framework Provider) and the operator's Secret mount has
// supplied the client_id.
Expand All @@ -410,34 +481,53 @@ func credentialsAreReady(id tokenExchangeIdentity, jwtSrc fwspiffe.JWTSource) bo
return false
}

// buildClientAuthFrom constructs an exchange.ClientAuth from explicit
// args. Used both by Configure (against the local `c`, before p.cfg is
// assigned) and by pollCredentials (which reads its credential values
// from goroutine locals, not from the immutable p.cfg). Pure function
// — no reads from the receiver.
// buildClientAuth delegates client auth construction to the registered
//
// The "spiffe" identity path requires a non-nil JWTSource — supplied by
// the framework spiffe.Provider via SetSPIFFEProvider (see
// plugins.BuildWithSPIFFE). When the provider hasn't been wired in,
// this returns an explicit configuration error rather than panicking.
func buildClientAuthFrom(identityType, clientID, clientSecret string, jwtSrc fwspiffe.JWTSource) (exchange.ClientAuth, error) {
switch identityType {
case "spiffe":
// IdP provider. When no provider is set (generic/explicit token_url),
// falls back to a default implementation supporting client-secret and
// spiffe with jwt-spiffe assertion type.
func buildClientAuth(providerName string, identity tokenExchangeIdentity, jwtSrc fwspiffe.JWTSource) (exchange.ClientAuth, error) {
id := IdentityConfig{
Type: identity.Type,
ClientID: identity.ClientID,
ClientSecret: identity.ClientSecret,
AssertionType: identity.AssertionType,
JWTAudience: identity.JWTAudience,
}
if p := LookupProvider(providerName); p != nil {
return p.BuildClientAuth(id, jwtSrc)
}
// Fallback for generic/empty provider — basic client-secret and
// spiffe with default jwt-spiffe assertion.
switch identity.Type {
case SpiffeIdentity:
if jwtSrc == nil {
return nil, errors.New("spiffe identity requires a SPIFFE provider to be injected")
}
assertionType := identity.AssertionType
if assertionType == "" {
assertionType = DefaultAssertion
}
urn, ok := AssertionTypeURN[assertionType]
if !ok {
return nil, fmt.Errorf("unknown assertion_type %q", assertionType)
}
return &exchange.JWTAssertionAuth{
ClientID: clientID,
AssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe",
ClientID: identity.ClientID,
AssertionType: urn,
TokenSource: jwtSrc.FetchToken,
}, nil
case "client-secret":
case ClientSecretIdentity:
return &exchange.ClientSecretAuth{
ClientID: clientID,
ClientSecret: clientSecret,
ClientID: identity.ClientID,
ClientSecret: identity.ClientSecret,
}, nil
default:
return nil, fmt.Errorf("unknown identity.type %q", identityType)
return nil, fmt.Errorf("unknown identity.type %q", identity.Type)
}
}

Expand All @@ -456,7 +546,7 @@ func buildRouterFrom(defaultPolicy string, routes tokenExchangeRoutes) (*routing
for _, rc := range routes.Rules {
action := rc.Action
if action == "" && rc.Passthrough {
action = "passthrough"
action = PassthroughPolicy
}
rules = append(rules, routing.Route{
Host: rc.Host,
Expand Down Expand Up @@ -484,7 +574,7 @@ func buildRouterFrom(defaultPolicy string, routes tokenExchangeRoutes) (*routing
// process-lifetime context (see bgCancel) so Pipeline.Start's 60s
// budget doesn't kill it. Shutdown cancels the poller.
func (p *TokenExchange) Init(ctx context.Context) error {
if p.cfg.Identity.Type == "spiffe" && p.provider != nil && p.cfg.Identity.JWTAudience != "" {
if p.cfg.Identity.Type == SpiffeIdentity && p.provider != nil && p.cfg.Identity.JWTAudience != "" {
if err := p.provider.MirrorJWT(ctx, p.cfg.Identity.JWTAudience); err != nil {
// Mirror failures are non-fatal: the in-memory
// JWTSource keeps working even if the file mirror
Expand Down Expand Up @@ -535,7 +625,10 @@ func (p *TokenExchange) pollCredentials(ctx context.Context, needID, needSecret
}
clientSecret = v
}
clientAuth, err := buildClientAuthFrom(p.cfg.Identity.Type, clientID, clientSecret, p.jwtSource(p.cfg.Identity.JWTAudience))
pollIdentity := p.cfg.Identity
pollIdentity.ClientID = clientID
pollIdentity.ClientSecret = clientSecret
clientAuth, err := buildClientAuth(p.cfg.Provider, pollIdentity, p.jwtSource(p.cfg.Identity.JWTAudience))
if err != nil {
slog.Warn("token-exchange: failed to rebuild client auth after credential load", "error", err)
return
Expand Down
Loading
Loading