From bcc93830f8cf7728bd9dd03fb4772062784e98f1 Mon Sep 17 00:00:00 2001 From: Akram Date: Wed, 17 Jun 2026 15:01:39 +0200 Subject: [PATCH 1/2] Feat: IdP-agnostic token exchange plugin interface Introduce an IdPProvider interface and provider registry so new IdP backends (Entra ID, Okta, etc.) can be added without modifying core plugin code. Each provider implements TokenEndpoint() and JWKSEndpoint() for its URL conventions and registers via init(). Changes: - IdPProvider interface with TokenEndpoint/JWKSEndpoint contract - Provider registry (RegisterProvider/LookupProvider) in provider.go - Keycloak provider as built-in reference implementation - Config fields: provider, provider_url, provider_realm for IdP selection - Configurable assertion_type on spiffe identity (jwt-spiffe/jwt-bearer) - Backward compat: keycloak_url/keycloak_realm auto-migrate with warning - Contract documented in docs/idp-plugin-contract.md To add a new IdP (e.g. Entra ID, Okta): 1. Implement IdPProvider (Name, TokenEndpoint, JWKSEndpoint) 2. Call RegisterProvider() in an init() function 3. No changes to plugin.go needed Ref: RHAIENG-5681 Upstream: kagenti-extensions#481 Assisted-By: Claude (Anthropic AI) Signed-off-by: Akram --- .../authlib/plugins/tokenexchange/plugin.go | 108 +++++-- .../plugins/tokenexchange/plugin_test.go | 152 ++++++++++ .../authlib/plugins/tokenexchange/provider.go | 59 ++++ .../tokenexchange/provider_keycloak.go | 32 +++ .../plugins/tokenexchange/provider_test.go | 52 ++++ authbridge/docs/idp-plugin-contract.md | 270 ++++++++++++++++++ 6 files changed, 642 insertions(+), 31 deletions(-) create mode 100644 authbridge/authlib/plugins/tokenexchange/provider.go create mode 100644 authbridge/authlib/plugins/tokenexchange/provider_keycloak.go create mode 100644 authbridge/authlib/plugins/tokenexchange/provider_test.go create mode 100644 authbridge/docs/idp-plugin-contract.md diff --git a/authbridge/authlib/plugins/tokenexchange/plugin.go b/authbridge/authlib/plugins/tokenexchange/plugin.go index 2fa315cb1..d217ad94f 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: keycloak, entra-id, okta, or generic." enum:"keycloak,entra-id,okta,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,9 +138,27 @@ 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. + if c.KeycloakURL != "" && c.ProviderURL == "" { + c.ProviderURL = c.KeycloakURL + if c.Provider == "" { + c.Provider = "keycloak" + } + 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 from the registered IdP provider when not set + // explicitly. LookupProvider returns nil for unknown/empty names, + // which leaves TokenURL empty — validate() will catch it. + if c.TokenURL == "" && c.Provider != "" { + if p := LookupProvider(c.Provider); p != nil { + c.TokenURL = p.TokenEndpoint(c.ProviderURL, c.ProviderRealm) + } } if c.DefaultPolicy == "" { c.DefaultPolicy = "passthrough" @@ -281,7 +317,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.", } } @@ -351,7 +387,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) + c.Identity.ClientID, c.Identity.ClientSecret, c.Identity.AssertionType, jwtSrc) if err != nil { return fmt.Errorf("token-exchange: %w", err) } @@ -420,15 +456,25 @@ func credentialsAreReady(id tokenExchangeIdentity, jwtSrc fwspiffe.JWTSource) bo // 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) { +// assertionTypeURN maps short assertion type names to their full URN. +var assertionTypeURN = map[string]string{ + "jwt-spiffe": "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + "jwt-bearer": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", +} + +func buildClientAuthFrom(identityType, clientID, clientSecret, assertionType string, jwtSrc fwspiffe.JWTSource) (exchange.ClientAuth, error) { switch identityType { case "spiffe": if jwtSrc == nil { return nil, errors.New("spiffe identity requires a SPIFFE provider to be injected") } + urn := assertionTypeURN[assertionType] + if urn == "" { + urn = assertionTypeURN["jwt-spiffe"] // default + } return &exchange.JWTAssertionAuth{ ClientID: clientID, - AssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + AssertionType: urn, TokenSource: jwtSrc.FetchToken, }, nil case "client-secret": @@ -535,7 +581,7 @@ 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)) + clientAuth, err := buildClientAuthFrom(p.cfg.Identity.Type, clientID, clientSecret, p.cfg.Identity.AssertionType, 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..61092311b 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin_test.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin_test.go @@ -379,3 +379,155 @@ 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 TestKeycloakProvider_TokenEndpoint(t *testing.T) { + p := LookupProvider("keycloak") + if p == nil { + t.Fatal("keycloak provider not registered") + } + 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 := LookupProvider("keycloak") + 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_JWKSEndpoint(t *testing.T) { + p := LookupProvider("keycloak") + got := p.JWKSEndpoint("https://keycloak.example.com", "my-realm") + want := "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +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"} + auth, err := buildClientAuthFrom("spiffe", "client-1", "", "", 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"} + auth, err := buildClientAuthFrom("spiffe", "client-1", "", "jwt-bearer", 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) + } +} diff --git a/authbridge/authlib/plugins/tokenexchange/provider.go b/authbridge/authlib/plugins/tokenexchange/provider.go new file mode 100644 index 000000000..05797c9c9 --- /dev/null +++ b/authbridge/authlib/plugins/tokenexchange/provider.go @@ -0,0 +1,59 @@ +package tokenexchange + +import ( + "fmt" + "sync" +) + +// IdPProvider defines the contract for an Identity Provider backend. +// Each IdP (Keycloak, Entra ID, Okta, etc.) implements this interface +// to provide endpoint derivation from its configuration 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 + + // JWKSEndpoint derives the JWKS endpoint URL from the provider + // base URL and realm/tenant. Returns "" if the inputs are + // insufficient (caller must supply explicit jwks_url). + JWKSEndpoint(providerURL, providerRealm string) 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] +} diff --git a/authbridge/authlib/plugins/tokenexchange/provider_keycloak.go b/authbridge/authlib/plugins/tokenexchange/provider_keycloak.go new file mode 100644 index 000000000..8b52eaded --- /dev/null +++ b/authbridge/authlib/plugins/tokenexchange/provider_keycloak.go @@ -0,0 +1,32 @@ +package tokenexchange + +import "strings" + +// keycloakProvider derives endpoints from Keycloak's URL conventions. +// +// Config example: +// +// provider: keycloak +// provider_url: https://keycloak.example.com +// provider_realm: my-realm +type keycloakProvider struct{} + +func (keycloakProvider) Name() string { return "keycloak" } + +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) JWKSEndpoint(providerURL, providerRealm string) string { + base := strings.TrimRight(providerURL, "/") + if base == "" || providerRealm == "" { + return "" + } + return base + "/realms/" + providerRealm + "/protocol/openid-connect/certs" +} + +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..0e69fa69c --- /dev/null +++ b/authbridge/docs/idp-plugin-contract.md @@ -0,0 +1,270 @@ +# IdP-Agnostic Token Exchange Plugin Contract + +> **Status:** Draft — Phase 1 of 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: + +``` +Request → Route resolver → Token cache → RFC 8693 exchange → Inject token +``` + +IdP-specific behavior is confined to two extension points: + +1. **Token endpoint resolution** — how to derive the OAuth token endpoint URL +2. **Client authentication** — how the workload authenticates to the IdP + +Everything else (route matching, caching, token injection, error +handling) is generic and shared. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ token-exchange plugin │ +│ │ +│ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ Route │ │ exchange.Client │ │ +│ │ Resolver │ │ (RFC 8693, IdP-agnostic) │ │ +│ │ │ │ │ │ +│ │ host → aud │ │ ┌───────────────────┐ │ │ +│ │ host → scope │ │ │ ClientAuth │ │ │ +│ │ host → url │──│ │ (IdP-specific) │ │ │ +│ │ │ │ │ │ │ │ +│ └──────────────┘ │ │ • ClientSecretAuth│ │ │ +│ │ │ • JWTAssertionAuth│ │ │ +│ │ │ • CertificateAuth │ │ │ +│ │ │ (future) │ │ │ +│ │ └───────────────────┘ │ │ +│ └─────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## Extension Point 1: Token Endpoint Resolution + +### Current state (Keycloak-specific) + +```go +// plugin.go:122-126 +if c.TokenURL == "" && c.KeycloakURL != "" && c.KeycloakRealm != "" { + base := strings.TrimRight(c.KeycloakURL, "/") + "/realms/" + c.KeycloakRealm + c.TokenURL = base + "/protocol/openid-connect/token" +} +``` + +### Proposed contract + +The plugin resolves the token endpoint URL via a **resolution chain**: + +1. **Explicit `token_url`** — always wins (works for any IdP) +2. **Provider-specific derivation** — when `provider` is set and `token_url` is empty +3. **Per-route `token_url` override** — in `routes.yaml`, per-host + +#### Configuration + +```yaml +token-exchange: + # Explicit URL (works for any IdP, recommended for production) + token_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + + # OR: provider-assisted derivation (convenience for supported IdPs) + provider: "keycloak" # keycloak | entra-id | okta | generic + provider_url: "https://keycloak.example.com" + provider_realm: "my-realm" # keycloak-specific, ignored by other providers + + identity: + type: "client-secret" # client-secret | spiffe | certificate (future) + client_id: "my-agent" + client_secret: "..." +``` + +#### Provider URL derivation patterns + +| Provider | `token_url` derivation | `jwks_url` derivation | +|----------|----------------------|---------------------| +| `keycloak` | `{provider_url}/realms/{provider_realm}/protocol/openid-connect/token` | `{provider_url}/realms/{provider_realm}/protocol/openid-connect/certs` | +| `entra-id` | `https://login.microsoftonline.com/{provider_realm}/oauth2/v2.0/token` | `https://login.microsoftonline.com/{provider_realm}/discovery/v2.0/keys` | +| `okta` | `{provider_url}/oauth2/v1/token` | `{provider_url}/oauth2/v1/keys` | +| `generic` | **must** supply explicit `token_url` | **must** supply explicit `jwks_url` | + +`provider_realm` is overloaded per IdP: +- **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) + +#### 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 suggesting migration to `provider_url` +/ `provider_realm`. + +``` +WARN token-exchange: keycloak_url/keycloak_realm are deprecated; + use provider=keycloak + provider_url + provider_realm instead +``` + +### Interface (Go) + +No new Go interface is needed for URL derivation — it is a pure +function of (`provider`, `provider_url`, `provider_realm`) → +(`token_url`, `jwks_url`). Implemented as a switch in +`applyDefaults()`. + +```go +// resolveEndpoints derives token_url and jwks_url from provider config. +// Returns ("", "") when explicit URLs should be required. +func resolveEndpoints(provider, providerURL, providerRealm string) (tokenURL, jwksURL string) { + base := strings.TrimRight(providerURL, "/") + switch provider { + case "keycloak": + realmBase := base + "/realms/" + providerRealm + return realmBase + "/protocol/openid-connect/token", + realmBase + "/protocol/openid-connect/certs" + case "entra-id": + tenant := providerRealm // tenant ID + return "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", + "https://login.microsoftonline.com/" + tenant + "/discovery/v2.0/keys" + case "okta": + if providerRealm != "" { + return base + "/oauth2/" + providerRealm + "/v1/token", + base + "/oauth2/" + providerRealm + "/v1/keys" + } + return base + "/oauth2/v1/token", + base + "/oauth2/v1/keys" + case "generic", "": + return "", "" // must supply explicit URLs + default: + return "", "" // unknown provider + } +} +``` + +## Extension Point 2: Client Authentication + +### Current state + +The `exchange.ClientAuth` interface is already IdP-agnostic: + +```go +// exchange/auth.go +type ClientAuth interface { + Apply(req *http.Request) error +} +``` + +Two implementations exist: +- `ClientSecretAuth` — `client_id` + `client_secret` in request body +- `JWTAssertionAuth` — JWT client assertion (`client_assertion_type` + `client_assertion`) + +### Proposed additions for IdP coverage + +| IdP | Supported auth methods | Implementation | +|-----|----------------------|----------------| +| **Keycloak** | `client-secret` ✅, `spiffe` (JWT assertion) ✅ | Already implemented | +| **Entra ID** | `client-secret` ✅, `certificate` ❌ (new) | Needs `CertificateAuth` | +| **Okta** | `client-secret` ✅, `jwt-bearer` ❌ (new assertion type) | Needs configurable assertion type | + +#### New identity type: `certificate` (future, for Entra ID) + +```yaml +identity: + type: "certificate" + client_id: "app-client-id" + certificate_file: "/certs/client.pem" + private_key_file: "/certs/client.key" +``` + +Implements `ClientAuth` by constructing a self-signed JWT assertion +using the X.509 certificate thumbprint (`x5t` header claim), signed +with the private key. This is the standard Entra ID confidential +client authentication flow. + +#### Configurable assertion type (for Okta) + +The `spiffe` identity type hardcodes the assertion type URN: + +```go +// Current (Keycloak-specific) +AssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" +``` + +This should be configurable: + +```yaml +identity: + type: "spiffe" + jwt_audience: "https://okta.example.com" + # Override the default assertion type (default: jwt-spiffe) + assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +``` + +| Assertion type | IdP support | +|---------------|-------------| +| `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe` | Keycloak ✅, Okta ❌, Entra ID ❌ | +| `urn:ietf:params:oauth:client-assertion-type:jwt-bearer` | Keycloak ✅, Okta ✅, Entra ID ❌ | + +## Extension Point 3: SchemaProvider (config introspection) + +The plugin already implements `pipeline.SchemaProvider`: + +```go +func (p *TokenExchange) ConfigSchema() []pipeline.FieldSchema { + return pipeline.SchemaOf(tokenExchangeConfig{}) +} +``` + +New fields (`provider`, `provider_url`, `provider_realm`, +`assertion_type`) are automatically surfaced via struct tags. No +framework changes needed. + +## What does NOT change + +The following are IdP-agnostic and require no modifications: + +- **RFC 8693 token exchange parameters** (`grant_type`, `subject_token`, + `requested_token_type`, `audience`, `scope`) — standard across all IdPs +- **Route resolver** — host-to-audience matching, per-route `token_url` override +- **Token cache** — SHA-256 keyed, IdP-agnostic +- **Plugin registry** — `plugins.RegisterPlugin("token-exchange", ...)` unchanged +- **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) + +## Implementation phases + +### Phase 1: Config generalization (this PR) +- Add `provider`, `provider_url`, `provider_realm` fields +- Deprecate `keycloak_url`, `keycloak_realm` (backward compat) +- Implement `resolveEndpoints()` for keycloak, entra-id, okta, generic +- Make `assertion_type` configurable on spiffe identity +- Update Capabilities description +- Document the contract (this file) + +### Phase 2: Entra ID plugin (separate PR) +- Implement `CertificateAuth` (`exchange.ClientAuth`) +- Add `identity.type: "certificate"` support +- Test with Entra ID token endpoint + +### Phase 3: Okta plugin (separate PR) +- Test `jwt-bearer` assertion type with Okta +- Add Okta-specific integration test +- Document Okta-specific configuration + +## Testing strategy + +Each IdP integration should include: +1. **Unit tests** — mock token endpoint, verify correct parameters +2. **Config validation tests** — ensure required fields are enforced +3. **URL derivation tests** — verify per-provider endpoint patterns +4. **Integration tests** (optional) — against a real or emulated IdP + +The existing `exchange/client_test.go` provides the pattern for mock +server tests. From f904c2f9a5f1795cbb396c1e8777eac68a4f734a Mon Sep 17 00:00:00 2001 From: Akram Date: Thu, 18 Jun 2026 12:47:41 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Fix:=20Address=20review=20=E2=80=94=20widen?= =?UTF-8?q?=20IdPProvider=20to=20own=20client=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on PR #521, the IdPProvider interface now covers the full IdP surface (endpoint derivation + client authentication), not just URL templating. Changes: - IdPProvider interface: add DefaultAssertionType(), SupportedIdentityTypes(), BuildClientAuth() — remove JWKSEndpoint() (no consumer on the exchange path, belongs to jwt-validation) - Keycloak provider: implements full interface including auth building - Remove buildClientAuthFrom() from core — delegated to provider - Validate provider↔identity compatibility at Configure() time (e.g. provider:keycloak + identity.type:certificate → rejected) - Reject unknown assertion_type values (was silently defaulting) - Reject unknown provider names at validation - Trim provider enum to implemented providers only (keycloak, generic) - Fix mixed back-compat path (keycloak_realm without keycloak_url) - IdentityConfig exported struct for provider contract Tests: - 5 new Keycloak provider tests (DefaultAssertionType, SupportedIdentityTypes, BuildClientAuth for client-secret/spiffe, unsupported type rejection) - 3 new validation tests (invalid assertion_type, provider↔identity incompatibility, unknown provider rejection) - All 40 plugin tests pass, full suite zero regression Ref: RHAIENG-5681 Assisted-By: Claude (Anthropic AI) Signed-off-by: Akram --- .../authlib/plugins/tokenexchange/plugin.go | 141 +++++--- .../plugins/tokenexchange/plugin_test.go | 120 ++++++- .../authlib/plugins/tokenexchange/provider.go | 78 ++++- .../tokenexchange/provider_keycloak.go | 52 ++- authbridge/docs/idp-plugin-contract.md | 309 +++++++----------- 5 files changed, 431 insertions(+), 269 deletions(-) diff --git a/authbridge/authlib/plugins/tokenexchange/plugin.go b/authbridge/authlib/plugins/tokenexchange/plugin.go index d217ad94f..2e429acd4 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin.go @@ -38,7 +38,7 @@ type tokenExchangeConfig struct { // 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: keycloak, entra-id, okta, or generic." enum:"keycloak,entra-id,okta,generic"` + 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: @@ -140,11 +140,15 @@ type tokenExchangeRoute struct { func (c *tokenExchangeConfig) applyDefaults() { // Backward compatibility: migrate keycloak_url/keycloak_realm to // provider_url/provider_realm when the new fields are empty. - if c.KeycloakURL != "" && c.ProviderURL == "" { - c.ProviderURL = c.KeycloakURL + // 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 = "keycloak" + 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 == "" { @@ -152,16 +156,20 @@ func (c *tokenExchangeConfig) applyDefaults() { slog.Warn("token-exchange: keycloak_realm is deprecated; use provider_realm + provider=keycloak instead") } - // Derive token_url from the registered IdP provider when not set - // explicitly. LookupProvider returns nil for unknown/empty names, - // which leaves TokenURL empty — validate() will catch it. - if c.TokenURL == "" && c.Provider != "" { + // 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 { - c.TokenURL = p.TokenEndpoint(c.ProviderURL, c.ProviderRealm) + 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 @@ -179,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" } @@ -196,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: @@ -210,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 @@ -225,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 } @@ -386,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, c.Identity.AssertionType, jwtSrc) + clientAuth, err := buildClientAuth(c.Provider, c.Identity, jwtSrc) if err != nil { return fmt.Errorf("token-exchange: %w", err) } @@ -435,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. @@ -446,44 +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. -// assertionTypeURN maps short assertion type names to their full URN. -var assertionTypeURN = map[string]string{ - "jwt-spiffe": "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", - "jwt-bearer": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", -} - -func buildClientAuthFrom(identityType, clientID, clientSecret, assertionType 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") } - urn := assertionTypeURN[assertionType] - if urn == "" { - urn = assertionTypeURN["jwt-spiffe"] // default + 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, + 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) } } @@ -502,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, @@ -530,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 @@ -581,7 +625,10 @@ func (p *TokenExchange) pollCredentials(ctx context.Context, needID, needSecret } clientSecret = v } - clientAuth, err := buildClientAuthFrom(p.cfg.Identity.Type, clientID, clientSecret, p.cfg.Identity.AssertionType, 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 61092311b..632a0a021 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin_test.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin_test.go @@ -384,11 +384,17 @@ func TestTokenExchange_SPIFFE_Identity_ErrorsWhenNoJWTSource(t *testing.T) { // IdP provider registry and endpoint resolution tests // ======================================== -func TestKeycloakProvider_TokenEndpoint(t *testing.T) { +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 { @@ -397,7 +403,7 @@ func TestKeycloakProvider_TokenEndpoint(t *testing.T) { } func TestKeycloakProvider_TokenEndpoint_TrailingSlash(t *testing.T) { - p := LookupProvider("keycloak") + 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 { @@ -405,12 +411,58 @@ func TestKeycloakProvider_TokenEndpoint_TrailingSlash(t *testing.T) { } } -func TestKeycloakProvider_JWKSEndpoint(t *testing.T) { - p := LookupProvider("keycloak") - got := p.JWKSEndpoint("https://keycloak.example.com", "my-realm") - want := "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs" - 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") } } @@ -494,7 +546,8 @@ func TestConfigure_ProviderURL_Preferred(t *testing.T) { func TestBuildClientAuth_DefaultAssertionType(t *testing.T) { jwt := &fakeJWTSource{token: "test-jwt"} - auth, err := buildClientAuthFrom("spiffe", "client-1", "", "", jwt) + id := tokenExchangeIdentity{Type: "spiffe", ClientID: "client-1"} + auth, err := buildClientAuth("keycloak", id, jwt) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -514,7 +567,9 @@ func TestBuildClientAuth_DefaultAssertionType(t *testing.T) { func TestBuildClientAuth_JWTBearerAssertionType(t *testing.T) { jwt := &fakeJWTSource{token: "test-jwt"} - auth, err := buildClientAuthFrom("spiffe", "client-1", "", "jwt-bearer", 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) } @@ -531,3 +586,48 @@ func TestBuildClientAuth_JWTBearerAssertionType(t *testing.T) { 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 index 05797c9c9..807c1b25f 100644 --- a/authbridge/authlib/plugins/tokenexchange/provider.go +++ b/authbridge/authlib/plugins/tokenexchange/provider.go @@ -3,11 +3,45 @@ 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 from its configuration conventions. +// to provide endpoint derivation and client authentication from its +// conventions. // // Adding a new IdP: // 1. Create a new file (e.g. provider_okta.go) @@ -27,10 +61,35 @@ type IdPProvider interface { // are insufficient (caller must supply explicit token_url). TokenEndpoint(providerURL, providerRealm string) string - // JWKSEndpoint derives the JWKS endpoint URL from the provider - // base URL and realm/tenant. Returns "" if the inputs are - // insufficient (caller must supply explicit jwks_url). - JWKSEndpoint(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 ( @@ -57,3 +116,12 @@ func LookupProvider(name string) IdPProvider { 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 index 8b52eaded..b864ce279 100644 --- a/authbridge/authlib/plugins/tokenexchange/provider_keycloak.go +++ b/authbridge/authlib/plugins/tokenexchange/provider_keycloak.go @@ -1,17 +1,26 @@ package tokenexchange -import "strings" +import ( + "errors" + "strings" -// keycloakProvider derives endpoints from Keycloak's URL conventions. + "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 "keycloak" } +func (keycloakProvider) Name() string { return KeycloakProvider } func (keycloakProvider) TokenEndpoint(providerURL, providerRealm string) string { base := strings.TrimRight(providerURL, "/") @@ -21,12 +30,39 @@ func (keycloakProvider) TokenEndpoint(providerURL, providerRealm string) string return base + "/realms/" + providerRealm + "/protocol/openid-connect/token" } -func (keycloakProvider) JWKSEndpoint(providerURL, providerRealm string) string { - base := strings.TrimRight(providerURL, "/") - if base == "" || providerRealm == "" { - return "" +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) } - return base + "/realms/" + providerRealm + "/protocol/openid-connect/certs" } func init() { RegisterProvider(keycloakProvider{}) } diff --git a/authbridge/docs/idp-plugin-contract.md b/authbridge/docs/idp-plugin-contract.md index 0e69fa69c..9adfc63ba 100644 --- a/authbridge/docs/idp-plugin-contract.md +++ b/authbridge/docs/idp-plugin-contract.md @@ -1,6 +1,6 @@ # IdP-Agnostic Token Exchange Plugin Contract -> **Status:** Draft — Phase 1 of RHAIENG-5681 / kagenti-extensions#481 +> **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. @@ -12,259 +12,170 @@ 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 confined to two extension points: - -1. **Token endpoint resolution** — how to derive the OAuth token endpoint URL -2. **Client authentication** — how the workload authenticates to the IdP +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()`. -Everything else (route matching, caching, token injection, error -handling) is generic and shared. +## IdPProvider Interface -## Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ token-exchange plugin │ -│ │ -│ ┌──────────────┐ ┌─────────────────────────┐ │ -│ │ Route │ │ exchange.Client │ │ -│ │ Resolver │ │ (RFC 8693, IdP-agnostic) │ │ -│ │ │ │ │ │ -│ │ host → aud │ │ ┌───────────────────┐ │ │ -│ │ host → scope │ │ │ ClientAuth │ │ │ -│ │ host → url │──│ │ (IdP-specific) │ │ │ -│ │ │ │ │ │ │ │ -│ └──────────────┘ │ │ • ClientSecretAuth│ │ │ -│ │ │ • JWTAssertionAuth│ │ │ -│ │ │ • CertificateAuth │ │ │ -│ │ │ (future) │ │ │ -│ │ └───────────────────┘ │ │ -│ └─────────────────────────┘ │ -└─────────────────────────────────────────────────┘ +```go +// provider.go +type IdPProvider interface { + Name() string + TokenEndpoint(providerURL, providerRealm string) string + DefaultAssertionType() string + SupportedIdentityTypes() []string + BuildClientAuth(identity IdentityConfig, jwtSrc JWTSource) (ClientAuth, error) +} ``` -## Extension Point 1: Token Endpoint Resolution +| 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 -### Current state (Keycloak-specific) +Create a single file — no changes to core plugin code required: ```go -// plugin.go:122-126 -if c.TokenURL == "" && c.KeycloakURL != "" && c.KeycloakRealm != "" { - base := strings.TrimRight(c.KeycloakURL, "/") + "/realms/" + c.KeycloakRealm - c.TokenURL = base + "/protocol/openid-connect/token" +// 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 } -``` -### Proposed contract +func (oktaProvider) DefaultAssertionType() string { return JWTBearerAssertion } -The plugin resolves the token endpoint URL via a **resolution chain**: +func (oktaProvider) SupportedIdentityTypes() []string { + return []string{ClientSecretIdentity, SpiffeIdentity} +} -1. **Explicit `token_url`** — always wins (works for any IdP) -2. **Provider-specific derivation** — when `provider` is set and `token_url` is empty -3. **Per-route `token_url` override** — in `routes.yaml`, per-host +func (oktaProvider) BuildClientAuth(id IdentityConfig, jwtSrc fwspiffe.JWTSource) (exchange.ClientAuth, error) { + // Okta-specific auth construction +} + +func init() { RegisterProvider(oktaProvider{}) } +``` -#### Configuration +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, recommended for production) + # 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 supported IdPs) - provider: "keycloak" # keycloak | entra-id | okta | generic + # 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" # keycloak-specific, ignored by other providers + provider_realm: "my-realm" identity: - type: "client-secret" # client-secret | spiffe | certificate (future) + 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() ``` -#### Provider URL derivation patterns - -| Provider | `token_url` derivation | `jwks_url` derivation | -|----------|----------------------|---------------------| -| `keycloak` | `{provider_url}/realms/{provider_realm}/protocol/openid-connect/token` | `{provider_url}/realms/{provider_realm}/protocol/openid-connect/certs` | -| `entra-id` | `https://login.microsoftonline.com/{provider_realm}/oauth2/v2.0/token` | `https://login.microsoftonline.com/{provider_realm}/discovery/v2.0/keys` | -| `okta` | `{provider_url}/oauth2/v1/token` | `{provider_url}/oauth2/v1/keys` | -| `generic` | **must** supply explicit `token_url` | **must** supply explicit `jwks_url` | +### Resolution chain -`provider_realm` is overloaded per IdP: -- **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) +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 +### 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 suggesting migration to `provider_url` -/ `provider_realm`. +A deprecation warning is logged. -``` -WARN token-exchange: keycloak_url/keycloak_realm are deprecated; - use provider=keycloak + provider_url + provider_realm instead -``` +### Validation at Configure() time -### Interface (Go) +- 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 -No new Go interface is needed for URL derivation — it is a pure -function of (`provider`, `provider_url`, `provider_realm`) → -(`token_url`, `jwks_url`). Implemented as a switch in -`applyDefaults()`. +## Available Constants ```go -// resolveEndpoints derives token_url and jwks_url from provider config. -// Returns ("", "") when explicit URLs should be required. -func resolveEndpoints(provider, providerURL, providerRealm string) (tokenURL, jwksURL string) { - base := strings.TrimRight(providerURL, "/") - switch provider { - case "keycloak": - realmBase := base + "/realms/" + providerRealm - return realmBase + "/protocol/openid-connect/token", - realmBase + "/protocol/openid-connect/certs" - case "entra-id": - tenant := providerRealm // tenant ID - return "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", - "https://login.microsoftonline.com/" + tenant + "/discovery/v2.0/keys" - case "okta": - if providerRealm != "" { - return base + "/oauth2/" + providerRealm + "/v1/token", - base + "/oauth2/" + providerRealm + "/v1/keys" - } - return base + "/oauth2/v1/token", - base + "/oauth2/v1/keys" - case "generic", "": - return "", "" // must supply explicit URLs - default: - return "", "" // unknown provider - } -} -``` +// Identity types +ClientSecretIdentity = "client-secret" +SpiffeIdentity = "spiffe" -## Extension Point 2: Client Authentication +// Assertion types (keys into AssertionTypeURN) +JWTSpiffeAssertion = "jwt-spiffe" +JWTBearerAssertion = "jwt-bearer" -### Current state +// Policies +PassthroughPolicy = "passthrough" +ExchangePolicy = "exchange" -The `exchange.ClientAuth` interface is already IdP-agnostic: +// Providers +KeycloakProvider = "keycloak" +GenericProvider = "generic" -```go -// exchange/auth.go -type ClientAuth interface { - Apply(req *http.Request) error -} -``` - -Two implementations exist: -- `ClientSecretAuth` — `client_id` + `client_secret` in request body -- `JWTAssertionAuth` — JWT client assertion (`client_assertion_type` + `client_assertion`) - -### Proposed additions for IdP coverage - -| IdP | Supported auth methods | Implementation | -|-----|----------------------|----------------| -| **Keycloak** | `client-secret` ✅, `spiffe` (JWT assertion) ✅ | Already implemented | -| **Entra ID** | `client-secret` ✅, `certificate` ❌ (new) | Needs `CertificateAuth` | -| **Okta** | `client-secret` ✅, `jwt-bearer` ❌ (new assertion type) | Needs configurable assertion type | - -#### New identity type: `certificate` (future, for Entra ID) - -```yaml -identity: - type: "certificate" - client_id: "app-client-id" - certificate_file: "/certs/client.pem" - private_key_file: "/certs/client.key" -``` - -Implements `ClientAuth` by constructing a self-signed JWT assertion -using the X.509 certificate thumbprint (`x5t` header claim), signed -with the private key. This is the standard Entra ID confidential -client authentication flow. - -#### Configurable assertion type (for Okta) - -The `spiffe` identity type hardcodes the assertion type URN: - -```go -// Current (Keycloak-specific) -AssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" +// Defaults +DefaultAssertion = JWTSpiffeAssertion +DefaultProvider = KeycloakProvider ``` -This should be configurable: - -```yaml -identity: - type: "spiffe" - jwt_audience: "https://okta.example.com" - # Override the default assertion type (default: jwt-spiffe) - assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" -``` +## Reference Implementation: Keycloak -| Assertion type | IdP support | -|---------------|-------------| -| `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe` | Keycloak ✅, Okta ❌, Entra ID ❌ | -| `urn:ietf:params:oauth:client-assertion-type:jwt-bearer` | Keycloak ✅, Okta ✅, Entra ID ❌ | +See `provider_keycloak.go` for the reference implementation: -## Extension Point 3: SchemaProvider (config introspection) - -The plugin already implements `pipeline.SchemaProvider`: - -```go -func (p *TokenExchange) ConfigSchema() []pipeline.FieldSchema { - return pipeline.SchemaOf(tokenExchangeConfig{}) -} -``` - -New fields (`provider`, `provider_url`, `provider_realm`, -`assertion_type`) are automatically surfaced via struct tags. No -framework changes needed. +- `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 -The following are IdP-agnostic and require no modifications: - -- **RFC 8693 token exchange parameters** (`grant_type`, `subject_token`, - `requested_token_type`, `audience`, `scope`) — standard across all IdPs -- **Route resolver** — host-to-audience matching, per-route `token_url` override +- **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", ...)` unchanged +- **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) -## Implementation phases - -### Phase 1: Config generalization (this PR) -- Add `provider`, `provider_url`, `provider_realm` fields -- Deprecate `keycloak_url`, `keycloak_realm` (backward compat) -- Implement `resolveEndpoints()` for keycloak, entra-id, okta, generic -- Make `assertion_type` configurable on spiffe identity -- Update Capabilities description -- Document the contract (this file) - -### Phase 2: Entra ID plugin (separate PR) -- Implement `CertificateAuth` (`exchange.ClientAuth`) -- Add `identity.type: "certificate"` support -- Test with Entra ID token endpoint +## Per-IdP Auth Method Matrix -### Phase 3: Okta plugin (separate PR) -- Test `jwt-bearer` assertion type with Okta -- Add Okta-specific integration test -- Document Okta-specific configuration +| 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 integration should include: -1. **Unit tests** — mock token endpoint, verify correct parameters -2. **Config validation tests** — ensure required fields are enforced -3. **URL derivation tests** — verify per-provider endpoint patterns -4. **Integration tests** (optional) — against a real or emulated IdP +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 -The existing `exchange/client_test.go` provides the pattern for mock -server tests. +See `plugin_test.go` for the Keycloak provider test patterns.