diff --git a/authbridge/authlib/plugins/tokenexchange/plugin.go b/authbridge/authlib/plugins/tokenexchange/plugin.go index 2fa315cb1..2e429acd4 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin.go @@ -8,7 +8,6 @@ import ( "fmt" "log/slog" "net/http" - "strings" "sync/atomic" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" @@ -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. + // 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; @@ -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 { @@ -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 @@ -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" } @@ -160,13 +204,19 @@ 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: @@ -174,7 +224,7 @@ func (c *tokenExchangeConfig) validate() error { 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 @@ -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 } @@ -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.", } } @@ -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) } @@ -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. @@ -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) } } @@ -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, @@ -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 @@ -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 diff --git a/authbridge/authlib/plugins/tokenexchange/plugin_test.go b/authbridge/authlib/plugins/tokenexchange/plugin_test.go index 38102e7d1..632a0a021 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin_test.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin_test.go @@ -379,3 +379,255 @@ func TestTokenExchange_SPIFFE_Identity_ErrorsWhenNoJWTSource(t *testing.T) { t.Fatal("expected error when spiffe identity has no JWTSource, got nil") } } + +// ======================================== +// IdP provider registry and endpoint resolution tests +// ======================================== + +func mustKeycloakProvider(t *testing.T) IdPProvider { + t.Helper() + p := LookupProvider("keycloak") + if p == nil { + t.Fatal("keycloak provider not registered") + } + return p +} + +func TestKeycloakProvider_TokenEndpoint(t *testing.T) { + p := mustKeycloakProvider(t) + got := p.TokenEndpoint("https://keycloak.example.com", "my-realm") + want := "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestKeycloakProvider_TokenEndpoint_TrailingSlash(t *testing.T) { + p := mustKeycloakProvider(t) + got := p.TokenEndpoint("https://keycloak.example.com/", "my-realm") + want := "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestKeycloakProvider_DefaultAssertionType(t *testing.T) { + p := mustKeycloakProvider(t) + if got := p.DefaultAssertionType(); got != "jwt-spiffe" { + t.Errorf("keycloak default assertion type: got %q, want jwt-spiffe", got) + } +} + +func TestKeycloakProvider_SupportedIdentityTypes(t *testing.T) { + p := mustKeycloakProvider(t) + supported := p.SupportedIdentityTypes() + want := map[string]bool{"client-secret": true, "spiffe": true} + if len(supported) != len(want) { + t.Fatalf("keycloak supported identity types: got %v, want %v", supported, want) + } + for _, s := range supported { + if !want[s] { + t.Errorf("unexpected identity type %q in supported list", s) + } + } +} + +func TestKeycloakProvider_BuildClientAuth_ClientSecret(t *testing.T) { + p := mustKeycloakProvider(t) + id := IdentityConfig{Type: "client-secret", ClientID: "agent-1", ClientSecret: "secret"} + auth, err := p.BuildClientAuth(id, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if auth == nil { + t.Fatal("expected non-nil ClientAuth") + } +} + +func TestKeycloakProvider_BuildClientAuth_Spiffe(t *testing.T) { + p := mustKeycloakProvider(t) + jwt := &fakeJWTSource{token: "test-jwt"} + id := IdentityConfig{Type: "spiffe", ClientID: "agent-1", JWTAudience: "http://kc/realms/test"} + auth, err := p.BuildClientAuth(id, jwt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if auth == nil { + t.Fatal("expected non-nil ClientAuth") + } +} + +func TestKeycloakProvider_BuildClientAuth_UnsupportedType(t *testing.T) { + p := mustKeycloakProvider(t) + id := IdentityConfig{Type: "certificate", ClientID: "agent-1"} + _, err := p.BuildClientAuth(id, nil) + if err == nil { + t.Fatal("expected error for unsupported identity type") + } +} + +func TestKeycloakProvider_MissingFields(t *testing.T) { + p := LookupProvider("keycloak") + if got := p.TokenEndpoint("", "realm"); got != "" { + t.Errorf("missing URL should return empty, got %q", got) + } + if got := p.TokenEndpoint("https://kc.example.com", ""); got != "" { + t.Errorf("missing realm should return empty, got %q", got) + } +} + +func TestLookupProvider_UnknownReturnsNil(t *testing.T) { + if p := LookupProvider("unknown-idp"); p != nil { + t.Errorf("unknown provider should return nil, got %v", p) + } +} + +func TestLookupProvider_EmptyReturnsNil(t *testing.T) { + if p := LookupProvider(""); p != nil { + t.Errorf("empty provider should return nil, got %v", p) + } +} + +// ======================================== +// Backward compatibility: keycloak_url/keycloak_realm +// ======================================== + +func TestConfigure_BackwardCompat_KeycloakURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // token endpoint + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "tok", "token_type": "Bearer", "expires_in": 3600, + }) + })) + defer srv.Close() + + // Use keycloak_url + keycloak_realm (deprecated) — should still work + raw, _ := json.Marshal(map[string]any{ + "keycloak_url": srv.URL, + "keycloak_realm": "test-realm", + "identity": map[string]any{"type": "client-secret", "client_id": "agent-1", "client_secret": "secret"}, + }) + p := NewTokenExchange() + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure with keycloak_url/keycloak_realm should succeed: %v", err) + } + // Verify provider was set to keycloak + if p.cfg.Provider != "keycloak" { + t.Errorf("provider should be auto-set to keycloak, got %q", p.cfg.Provider) + } +} + +func TestConfigure_ProviderURL_Preferred(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "tok", "token_type": "Bearer", "expires_in": 3600, + }) + })) + defer srv.Close() + + // Use provider + provider_url + provider_realm (new way) + raw, _ := json.Marshal(map[string]any{ + "provider": "keycloak", + "provider_url": srv.URL, + "provider_realm": "test-realm", + "identity": map[string]any{"type": "client-secret", "client_id": "agent-1", "client_secret": "secret"}, + }) + p := NewTokenExchange() + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure with provider fields should succeed: %v", err) + } +} + +// ======================================== +// Assertion type configurability +// ======================================== + +func TestBuildClientAuth_DefaultAssertionType(t *testing.T) { + jwt := &fakeJWTSource{token: "test-jwt"} + id := tokenExchangeIdentity{Type: "spiffe", ClientID: "client-1"} + auth, err := buildClientAuth("keycloak", id, jwt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + form := make(map[string][]string) + if err := auth.Apply(context.Background(), form); err != nil { + t.Fatalf("Apply failed: %v", err) + } + got := "" + if vals, ok := form["client_assertion_type"]; ok && len(vals) > 0 { + got = vals[0] + } + want := "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + if got != want { + t.Errorf("default assertion type: got %q, want %q", got, want) + } +} + +func TestBuildClientAuth_JWTBearerAssertionType(t *testing.T) { + jwt := &fakeJWTSource{token: "test-jwt"} + id := tokenExchangeIdentity{Type: "spiffe", ClientID: "client-1", AssertionType: "jwt-bearer"} + // Use generic provider (no registered provider) to test fallback + auth, err := buildClientAuth("", id, jwt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + form := make(map[string][]string) + if err := auth.Apply(context.Background(), form); err != nil { + t.Fatalf("Apply failed: %v", err) + } + got := "" + if vals, ok := form["client_assertion_type"]; ok && len(vals) > 0 { + got = vals[0] + } + want := "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + if got != want { + t.Errorf("jwt-bearer assertion type: got %q, want %q", got, want) + } +} + +func TestValidate_InvalidAssertionType(t *testing.T) { + c := tokenExchangeConfig{ + TokenURL: "http://example.com/token", + DefaultPolicy: "passthrough", + NoTokenPolicy: "deny", + Identity: tokenExchangeIdentity{ + Type: "spiffe", + JWTAudience: "http://kc/realms/test", + AssertionType: "invalid-type", + }, + } + if err := c.validate(); err == nil { + t.Fatal("expected error for invalid assertion_type, got nil") + } +} + +func TestValidate_ProviderIdentityIncompatibility(t *testing.T) { + c := tokenExchangeConfig{ + Provider: "keycloak", + TokenURL: "http://example.com/token", + DefaultPolicy: "passthrough", + NoTokenPolicy: "deny", + Identity: tokenExchangeIdentity{ + Type: "certificate", // keycloak doesn't support certificate + ClientID: "agent-1", + }, + } + if err := c.validate(); err == nil { + t.Fatal("expected error for unsupported identity type on keycloak, got nil") + } +} + +func TestValidate_UnknownProviderRejected(t *testing.T) { + c := tokenExchangeConfig{ + Provider: "nonexistent-idp", + TokenURL: "http://example.com/token", + DefaultPolicy: "passthrough", + NoTokenPolicy: "deny", + Identity: tokenExchangeIdentity{Type: "client-secret", ClientID: "agent-1", ClientSecret: "s"}, + } + if err := c.validate(); err == nil { + t.Fatal("expected error for unknown provider, got nil") + } +} diff --git a/authbridge/authlib/plugins/tokenexchange/provider.go b/authbridge/authlib/plugins/tokenexchange/provider.go new file mode 100644 index 000000000..807c1b25f --- /dev/null +++ b/authbridge/authlib/plugins/tokenexchange/provider.go @@ -0,0 +1,127 @@ +package tokenexchange + +import ( + "fmt" + "sync" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange/exchange" + fwspiffe "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" +) + +// Identity type constants. +const ( + ClientSecretIdentity = "client-secret" + SpiffeIdentity = "spiffe" +) + +// Assertion type short names (keys into AssertionTypeURN). +const ( + JWTSpiffeAssertion = "jwt-spiffe" + JWTBearerAssertion = "jwt-bearer" +) + +// Outbound default policy constants. +const ( + PassthroughPolicy = "passthrough" + ExchangePolicy = "exchange" +) + +// Provider name constants. +const ( + KeycloakProvider = "keycloak" + GenericProvider = "generic" +) + +// Default values. +const ( + DefaultAssertion = JWTSpiffeAssertion + DefaultProvider = KeycloakProvider +) + +// IdPProvider defines the contract for an Identity Provider backend. +// Each IdP (Keycloak, Entra ID, Okta, etc.) implements this interface +// to provide endpoint derivation and client authentication from its +// conventions. +// +// Adding a new IdP: +// 1. Create a new file (e.g. provider_okta.go) +// 2. Implement IdPProvider +// 3. Call RegisterProvider() in an init() function +// +// The init() auto-registration pattern means any provider file that +// is compiled into the binary is automatically available — no central +// list to maintain. +type IdPProvider interface { + // Name returns the provider identifier used in config + // (e.g. "keycloak", "entra-id", "okta"). + Name() string + + // TokenEndpoint derives the OAuth token endpoint URL from the + // provider base URL and realm/tenant. Returns "" if the inputs + // are insufficient (caller must supply explicit token_url). + TokenEndpoint(providerURL, providerRealm string) string + + // DefaultAssertionType returns the default client_assertion_type + // URN for this provider when using SPIFFE/JWT identity. + // E.g. "jwt-spiffe" for Keycloak, "jwt-bearer" for Okta. + // Returns "" if the provider does not support JWT assertions. + DefaultAssertionType() string + + // SupportedIdentityTypes returns the identity.type values this + // provider supports (e.g. ["client-secret", "spiffe"] for + // Keycloak, ["client-secret", "certificate"] for Entra ID). + // Used at Configure() to reject unsupported combinations early. + SupportedIdentityTypes() []string + + // BuildClientAuth constructs the provider-appropriate ClientAuth + // from the identity config. Each provider owns its auth strategy — + // Keycloak uses ClientSecretAuth or JWTAssertionAuth(jwt-spiffe), + // Okta would use JWTAssertionAuth(jwt-bearer), Entra ID would use + // CertificateAuth (future). + BuildClientAuth(identity IdentityConfig, jwtSrc fwspiffe.JWTSource) (exchange.ClientAuth, error) +} + +// IdentityConfig carries the identity fields a provider needs to +// construct its ClientAuth. Extracted from tokenExchangeIdentity to +// avoid exporting the full plugin config struct. +type IdentityConfig struct { + Type string + ClientID string + ClientSecret string + AssertionType string + JWTAudience string +} + +var ( + providersMu sync.RWMutex + providers = map[string]IdPProvider{} +) + +// RegisterProvider registers an IdP provider. Called from init() in +// each provider's file. Panics on duplicate names. +func RegisterProvider(p IdPProvider) { + providersMu.Lock() + defer providersMu.Unlock() + name := p.Name() + if _, exists := providers[name]; exists { + panic(fmt.Sprintf("token-exchange: duplicate IdP provider registration: %q", name)) + } + providers[name] = p +} + +// LookupProvider returns the registered provider for the given name, +// or nil if not found. +func LookupProvider(name string) IdPProvider { + providersMu.RLock() + defer providersMu.RUnlock() + return providers[name] +} + +// AssertionTypeURN maps short assertion type names to their full URN +// as used in the RFC 8693 client_assertion_type parameter. Providers +// use this in BuildClientAuth to resolve the configured assertion type +// to the wire-format URN. Also used by validate() to reject unknown values. +var AssertionTypeURN = map[string]string{ + JWTSpiffeAssertion: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + JWTBearerAssertion: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", +} diff --git a/authbridge/authlib/plugins/tokenexchange/provider_keycloak.go b/authbridge/authlib/plugins/tokenexchange/provider_keycloak.go new file mode 100644 index 000000000..b864ce279 --- /dev/null +++ b/authbridge/authlib/plugins/tokenexchange/provider_keycloak.go @@ -0,0 +1,68 @@ +package tokenexchange + +import ( + "errors" + "strings" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange/exchange" + fwspiffe "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" +) + +// keycloakProvider derives endpoints and builds client auth from +// Keycloak's conventions. +// +// Config example: +// +// provider: keycloak +// provider_url: https://keycloak.example.com +// provider_realm: my-realm +// identity: +// type: client-secret # or spiffe +type keycloakProvider struct{} + +func (keycloakProvider) Name() string { return KeycloakProvider } + +func (keycloakProvider) TokenEndpoint(providerURL, providerRealm string) string { + base := strings.TrimRight(providerURL, "/") + if base == "" || providerRealm == "" { + return "" + } + return base + "/realms/" + providerRealm + "/protocol/openid-connect/token" +} + +func (keycloakProvider) DefaultAssertionType() string { return DefaultAssertion } + +func (keycloakProvider) SupportedIdentityTypes() []string { + return []string{ClientSecretIdentity, SpiffeIdentity} +} + +func (keycloakProvider) BuildClientAuth(id IdentityConfig, jwtSrc fwspiffe.JWTSource) (exchange.ClientAuth, error) { + switch id.Type { + case SpiffeIdentity: + if jwtSrc == nil { + return nil, errors.New("spiffe identity requires a SPIFFE provider to be injected") + } + assertionType := id.AssertionType + if assertionType == "" { + assertionType = DefaultAssertion + } + urn, ok := AssertionTypeURN[assertionType] + if !ok { + return nil, errors.New("keycloak: unsupported assertion_type " + assertionType) + } + return &exchange.JWTAssertionAuth{ + ClientID: id.ClientID, + AssertionType: urn, + TokenSource: jwtSrc.FetchToken, + }, nil + case ClientSecretIdentity: + return &exchange.ClientSecretAuth{ + ClientID: id.ClientID, + ClientSecret: id.ClientSecret, + }, nil + default: + return nil, errors.New("keycloak: unsupported identity.type " + id.Type) + } +} + +func init() { RegisterProvider(keycloakProvider{}) } diff --git a/authbridge/authlib/plugins/tokenexchange/provider_test.go b/authbridge/authlib/plugins/tokenexchange/provider_test.go new file mode 100644 index 000000000..dae97a18d --- /dev/null +++ b/authbridge/authlib/plugins/tokenexchange/provider_test.go @@ -0,0 +1,52 @@ +package tokenexchange + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestAllProviderFilesAreRegistered scans for provider_*.go files in +// this package and verifies that each one has a corresponding provider +// registered in the registry. This catches the case where a +// contributor creates a provider file but forgets the init() call. +func TestAllProviderFilesAreRegistered(t *testing.T) { + files, err := filepath.Glob("provider_*.go") + if err != nil { + t.Fatalf("glob: %v", err) + } + + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + + // Derive expected provider name from filename: + // provider_keycloak.go → keycloak + // provider_entra_id.go → entra-id + name := strings.TrimPrefix(file, "provider_") + name = strings.TrimSuffix(name, ".go") + name = strings.ReplaceAll(name, "_", "-") + + // Read file to extract the Name() return value as a sanity + // check — the provider might use a different name than the + // filename convention. If we can't parse it, fall back to + // the filename-derived name. + content, err := os.ReadFile(file) + if err != nil { + t.Fatalf("reading %s: %v", file, err) + } + + // Check that the file contains RegisterProvider + if !strings.Contains(string(content), "RegisterProvider") { + t.Errorf("%s: missing RegisterProvider() call in init() — provider will not be available at runtime", file) + continue + } + + // Verify the provider is actually registered + if p := LookupProvider(name); p == nil { + t.Errorf("%s: expected provider %q to be registered (file exists but LookupProvider returns nil — check init() and Name())", file, name) + } + } +} diff --git a/authbridge/docs/idp-plugin-contract.md b/authbridge/docs/idp-plugin-contract.md new file mode 100644 index 000000000..9adfc63ba --- /dev/null +++ b/authbridge/docs/idp-plugin-contract.md @@ -0,0 +1,181 @@ +# IdP-Agnostic Token Exchange Plugin Contract + +> **Status:** Implemented — RHAIENG-5681 / kagenti-extensions#481 +> +> This document defines the contract that an Identity Provider (IdP) +> plugin must satisfy for the AuthBridge token exchange pipeline. +> Contributors implementing Entra ID, Okta, or other IdP support should +> use this as their specification. + +## Overview + +The token exchange plugin (`token-exchange`) implements RFC 8693 token +exchange for outbound requests. The core pipeline is IdP-agnostic: + +```text +Request → Route resolver → Token cache → RFC 8693 exchange → Inject token +``` + +IdP-specific behavior is owned by the `IdPProvider` interface. Each +provider (Keycloak, Entra ID, Okta, etc.) implements this interface +in its own file and self-registers via `init()`. + +## IdPProvider Interface + +```go +// provider.go +type IdPProvider interface { + Name() string + TokenEndpoint(providerURL, providerRealm string) string + DefaultAssertionType() string + SupportedIdentityTypes() []string + BuildClientAuth(identity IdentityConfig, jwtSrc JWTSource) (ClientAuth, error) +} +``` + +| Method | Purpose | +|--------|---------| +| `Name()` | Provider identifier used in config (e.g. `"keycloak"`) | +| `TokenEndpoint()` | Derives the OAuth token endpoint URL from provider base URL and realm/tenant. Returns `""` if inputs are insufficient. | +| `DefaultAssertionType()` | Default `client_assertion_type` short name for SPIFFE identity (e.g. `"jwt-spiffe"` for Keycloak, `"jwt-bearer"` for Okta). Returns `""` if JWT assertions are not supported. | +| `SupportedIdentityTypes()` | Identity types this provider supports (e.g. `["client-secret", "spiffe"]`). Used at `Configure()` to reject unsupported combinations early. | +| `BuildClientAuth()` | Constructs the provider-appropriate `exchange.ClientAuth` from the identity config. Each provider owns its auth strategy. | + +## Adding a New IdP Provider + +Create a single file — no changes to core plugin code required: + +```go +// provider_okta.go +package tokenexchange + +import ( + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange/exchange" + fwspiffe "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" +) + +type oktaProvider struct{} + +func (oktaProvider) Name() string { return "okta" } + +func (oktaProvider) TokenEndpoint(providerURL, providerRealm string) string { + // Okta URL derivation logic +} + +func (oktaProvider) DefaultAssertionType() string { return JWTBearerAssertion } + +func (oktaProvider) SupportedIdentityTypes() []string { + return []string{ClientSecretIdentity, SpiffeIdentity} +} + +func (oktaProvider) BuildClientAuth(id IdentityConfig, jwtSrc fwspiffe.JWTSource) (exchange.ClientAuth, error) { + // Okta-specific auth construction +} + +func init() { RegisterProvider(oktaProvider{}) } +``` + +The `init()` auto-registration pattern means any provider file compiled +into the binary is automatically available — no central list to maintain. +A CI test (`TestAllProviderFilesAreRegistered`) scans `provider_*.go` +files and verifies each has `RegisterProvider()` in `init()`. + +## Configuration + +```yaml +token-exchange: + # Explicit URL (works for any IdP, always wins) + token_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + + # OR: provider-assisted derivation (convenience for registered IdPs) + provider: "keycloak" # must be a registered provider name + provider_url: "https://keycloak.example.com" + provider_realm: "my-realm" + + identity: + type: "client-secret" # must be in provider's SupportedIdentityTypes() + client_id: "my-agent" + client_secret: "..." + # assertion_type: "jwt-bearer" # optional, defaults to provider's DefaultAssertionType() +``` + +### Resolution chain + +1. **Explicit `token_url`** — always wins (works for any IdP) +2. **Provider derivation** — `LookupProvider(provider).TokenEndpoint(url, realm)` +3. **Per-route `token_url` override** — in `routes.yaml`, per-host + +### Backward compatibility + +`keycloak_url` and `keycloak_realm` continue to work. When present +and `provider` is not set, the plugin infers `provider: "keycloak"`. +A deprecation warning is logged. + +### Validation at Configure() time + +- Unknown provider names → rejected (`"provider X is not registered"`) +- Identity type not in `provider.SupportedIdentityTypes()` → rejected +- Unknown `assertion_type` → rejected (must be in `AssertionTypeURN` map) +- Missing `token_url` after derivation → rejected + +## Available Constants + +```go +// Identity types +ClientSecretIdentity = "client-secret" +SpiffeIdentity = "spiffe" + +// Assertion types (keys into AssertionTypeURN) +JWTSpiffeAssertion = "jwt-spiffe" +JWTBearerAssertion = "jwt-bearer" + +// Policies +PassthroughPolicy = "passthrough" +ExchangePolicy = "exchange" + +// Providers +KeycloakProvider = "keycloak" +GenericProvider = "generic" + +// Defaults +DefaultAssertion = JWTSpiffeAssertion +DefaultProvider = KeycloakProvider +``` + +## Reference Implementation: Keycloak + +See `provider_keycloak.go` for the reference implementation: + +- `TokenEndpoint`: `{url}/realms/{realm}/protocol/openid-connect/token` +- `DefaultAssertionType`: `jwt-spiffe` +- `SupportedIdentityTypes`: `[client-secret, spiffe]` +- `BuildClientAuth`: `ClientSecretAuth` or `JWTAssertionAuth` with `jwt-spiffe` URN + +## What does NOT change + +- **RFC 8693 token exchange parameters** — standard across all IdPs +- **Route resolver** — host-to-audience matching, per-route overrides +- **Token cache** — SHA-256 keyed, IdP-agnostic +- **Plugin registry** — `plugins.RegisterPlugin("token-exchange", ...)` +- **SPIFFE provider injection** — `SetSPIFFEProvider` / `plugins.BuildWithSPIFFE` +- **Credential file handling** — `/shared/client-id.txt`, `/shared/client-secret.txt` +- **Error handling** — standard OAuth error response parsing (RFC 6749) + +## Per-IdP Auth Method Matrix + +| IdP | `client-secret` | `spiffe` (JWT assertion) | `certificate` | Default assertion | +|-----|----------------|------------------------|---------------|-------------------| +| **Keycloak** | ✅ | ✅ (`jwt-spiffe`) | ❌ | `jwt-spiffe` | +| **Okta** (future) | ✅ | ✅ (`jwt-bearer`) | ❌ | `jwt-bearer` | +| **Entra ID** (future) | ✅ | ❌ | ✅ (future) | N/A | + +## Testing strategy + +Each IdP provider should include: +1. **Unit tests** — `TokenEndpoint()` derivation for various inputs +2. **`BuildClientAuth` tests** — verify correct `ClientAuth` construction +3. **Validation tests** — unsupported identity types rejected +4. **Registration guard** — `TestAllProviderFilesAreRegistered` catches missing `init()` +5. **Integration tests** (optional) — against a real or emulated IdP + +See `plugin_test.go` for the Keycloak provider test patterns.