diff --git a/auth_providers/auth_credentials.go b/auth_providers/auth_credentials.go new file mode 100644 index 0000000..a2882cb --- /dev/null +++ b/auth_providers/auth_credentials.go @@ -0,0 +1,225 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth_providers + +import ( + "fmt" + "strings" +) + +// AuthMethod is the credential family that satisfies a Keyfactor auth +// request. Returned by AuthCreds.Validate. +type AuthMethod string + +const ( + // AuthMethodUnset means no complete credential tuple was found. + AuthMethodUnset AuthMethod = "" + // AuthMethodBasic requires Username + Password (and optionally Domain). + AuthMethodBasic AuthMethod = "basic" + // AuthMethodOAuth2 requires ClientID + ClientSecret + TokenURL. + AuthMethodOAuth2 AuthMethod = "oauth2" + // AuthMethodToken requires AccessToken (a static bearer). + AuthMethodToken AuthMethod = "token" + // AuthMethodKerberos requires at least one of: + // KerberosKeytab, + // KerberosCCache, + // (Username + Password + KerberosRealm). + AuthMethodKerberos AuthMethod = "kerberos" +) + +// AuthCreds is the auth-only view of a Server, decoupled from Command +// target fields (Host/Port/APIPath). It lets callers validate +// credentials independently of whether they're targeting Command, +// ACME, or anything else. +// +// AuthCreds is intentionally a flat struct so it round-trips cleanly +// through Viper / mapstructure. The loader subpackage uses it for +// sub-block override merging. +type AuthCreds struct { + // AuthType is an optional explicit method selector ("basic", + // "oauth2", "token", "kerberos"). When set it disambiguates + // otherwise-overlapping inputs (e.g. an OAuth2 client_id and a + // static AccessToken set on the same profile). When unset, + // Validate infers the method from which fields are populated. + AuthType string `mapstructure:"auth_type" json:"auth_type,omitempty" yaml:"auth_type,omitempty"` + + // Basic + Username string `mapstructure:"username" json:"username,omitempty" yaml:"username,omitempty"` + Password string `mapstructure:"password" json:"password,omitempty" yaml:"password,omitempty"` + Domain string `mapstructure:"domain" json:"domain,omitempty" yaml:"domain,omitempty"` + + // OAuth2 client credentials + ClientID string `mapstructure:"client_id" json:"client_id,omitempty" yaml:"client_id,omitempty"` + ClientSecret string `mapstructure:"client_secret" json:"client_secret,omitempty" yaml:"client_secret,omitempty"` + TokenURL string `mapstructure:"token_url" json:"token_url,omitempty" yaml:"token_url,omitempty"` + Scopes []string `mapstructure:"scopes" json:"scopes,omitempty" yaml:"scopes,omitempty"` + Audience string `mapstructure:"audience" json:"audience,omitempty" yaml:"audience,omitempty"` + + // Static bearer + AccessToken string `mapstructure:"access_token" json:"access_token,omitempty" yaml:"access_token,omitempty"` + + // Kerberos + KerberosRealm string `mapstructure:"kerberos_realm" json:"kerberos_realm,omitempty" yaml:"kerberos_realm,omitempty"` + KerberosKeytab string `mapstructure:"kerberos_keytab" json:"kerberos_keytab,omitempty" yaml:"kerberos_keytab,omitempty"` + KerberosConfig string `mapstructure:"kerberos_config" json:"kerberos_config,omitempty" yaml:"kerberos_config,omitempty"` + KerberosCCache string `mapstructure:"kerberos_ccache" json:"kerberos_ccache,omitempty" yaml:"kerberos_ccache,omitempty"` + KerberosSPN string `mapstructure:"kerberos_spn" json:"kerberos_spn,omitempty" yaml:"kerberos_spn,omitempty"` +} + +// AuthCredsFromServer extracts the credential fields from a Server into +// a standalone AuthCreds. Used by the loader to build the server-level +// view before applying per-tool sub-block overrides. +func AuthCredsFromServer(s *Server) *AuthCreds { + if s == nil { + return &AuthCreds{} + } + return &AuthCreds{ + AuthType: s.AuthType, + Username: s.Username, + Password: s.Password, + Domain: s.Domain, + ClientID: s.ClientID, + ClientSecret: s.ClientSecret, + TokenURL: s.OAuthTokenUrl, + Scopes: append([]string(nil), s.Scopes...), + Audience: s.Audience, + AccessToken: s.AccessToken, + KerberosRealm: s.KerberosRealm, + KerberosKeytab: s.KerberosKeytab, + KerberosConfig: s.KerberosConfig, + KerberosCCache: s.KerberosCCache, + KerberosSPN: s.KerberosSPN, + } +} + +// Validate reports whether the credentials form a complete tuple for +// some auth method, and returns the resolved method. +// +// Method selection rules (first match wins): +// 1. If AuthType is set, it forces the chosen method; all required +// fields for that method must be present. +// 2. Otherwise, the first method whose required fields are fully +// populated is selected. +// +// Validation is strict: a partially-populated method (e.g. ClientID +// without ClientSecret) returns an error naming the missing fields +// even when another method would have been complete. This catches +// configuration mistakes early. +func (a *AuthCreds) Validate() (AuthMethod, error) { + if a == nil { + return AuthMethodUnset, fmt.Errorf("auth credentials are nil") + } + + // What's populated, by method. + hasBasic := a.Username != "" || a.Password != "" + hasOAuth2 := a.ClientID != "" || a.ClientSecret != "" || a.TokenURL != "" + hasToken := a.AccessToken != "" + hasKerberos := a.KerberosRealm != "" || a.KerberosKeytab != "" || a.KerberosCCache != "" || a.KerberosSPN != "" + + // Forced by AuthType. + if a.AuthType != "" { + switch strings.ToLower(a.AuthType) { + case string(AuthMethodBasic): + return AuthMethodBasic, validateBasic(a) + case string(AuthMethodOAuth2): + return AuthMethodOAuth2, validateOAuth2(a) + case string(AuthMethodToken): + return AuthMethodToken, validateToken(a) + case string(AuthMethodKerberos): + return AuthMethodKerberos, validateKerberos(a) + default: + return AuthMethodUnset, fmt.Errorf("unknown auth_type %q (expected basic, oauth2, token, or kerberos)", a.AuthType) + } + } + + // Strict-mode partial detection: if a method's first field is set + // but the rest are not, it's an error even if another method would + // have validated. This makes "I forgot client_secret" produce a + // clear message instead of silently falling through. + if hasOAuth2 { + if err := validateOAuth2(a); err != nil { + return AuthMethodUnset, err + } + return AuthMethodOAuth2, nil + } + if hasBasic { + if err := validateBasic(a); err != nil { + return AuthMethodUnset, err + } + return AuthMethodBasic, nil + } + if hasToken { + // Token only needs AccessToken; validateToken is just a + // non-empty check. + return AuthMethodToken, validateToken(a) + } + if hasKerberos { + if err := validateKerberos(a); err != nil { + return AuthMethodUnset, err + } + return AuthMethodKerberos, nil + } + + return AuthMethodUnset, fmt.Errorf("no auth credentials configured (set username/password, client_id/client_secret/token_url, access_token, or a Kerberos tuple)") +} + +func validateBasic(a *AuthCreds) error { + var missing []string + if a.Username == "" { + missing = append(missing, "username") + } + if a.Password == "" { + missing = append(missing, "password") + } + if len(missing) > 0 { + return fmt.Errorf("basic auth missing required field(s): %s", strings.Join(missing, ", ")) + } + return nil +} + +func validateOAuth2(a *AuthCreds) error { + var missing []string + if a.ClientID == "" { + missing = append(missing, "client_id") + } + if a.ClientSecret == "" { + missing = append(missing, "client_secret") + } + if a.TokenURL == "" { + missing = append(missing, "token_url") + } + if len(missing) > 0 { + return fmt.Errorf("oauth2 auth missing required field(s): %s", strings.Join(missing, ", ")) + } + return nil +} + +func validateToken(a *AuthCreds) error { + if a.AccessToken == "" { + return fmt.Errorf("token auth missing required field: access_token") + } + return nil +} + +func validateKerberos(a *AuthCreds) error { + // Kerberos accepts any of: keytab, ccache, or username+password+realm. + if a.KerberosKeytab != "" || a.KerberosCCache != "" { + return nil + } + if a.Username != "" && a.Password != "" && a.KerberosRealm != "" { + return nil + } + return fmt.Errorf("kerberos auth requires one of: kerberos_keytab, kerberos_ccache, or (username + password + kerberos_realm)") +} diff --git a/auth_providers/command_config.go b/auth_providers/command_config.go index e8a14a3..1ac5bcd 100644 --- a/auth_providers/command_config.go +++ b/auth_providers/command_config.go @@ -47,6 +47,19 @@ type Server struct { KerberosConfig string `json:"kerberos_config,omitempty" yaml:"kerberos_config,omitempty"` // KerberosConfig is the path to krb5.conf. KerberosCCache string `json:"kerberos_ccache,omitempty" yaml:"kerberos_ccache,omitempty"` // KerberosCCache is the path to the credential cache. KerberosSPN string `json:"kerberos_spn,omitempty" yaml:"kerberos_spn,omitempty"` // KerberosSPN is the Service Principal Name. + + // Extras holds per-tool sub-blocks decoded alongside the canonical + // fields. Populated by the loader subpackage when consumers register + // their tool namespaces. Direct access is uncommon — prefer + // loader.DecodeExtras(namespace, target). + // + // The `mapstructure:",remain"` tag tells mapstructure (used by Viper + // inside the loader) to put any keys it doesn't recognize from the + // parent struct into this map, preserving them across read/write + // cycles. The json/yaml `-` tags keep Extras out of the canonical + // wire format for the existing ReadConfig*/WriteConfig* paths; the + // loader handles sub-block serialization separately. + Extras map[string]any `json:"-" yaml:"-" mapstructure:",remain"` } // AuthProvider represents the authentication provider configuration. diff --git a/go.mod b/go.mod index be4c892..7a185db 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,8 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 github.com/jcmturner/gokrb5/v8 v8.4.4 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 gopkg.in/yaml.v2 v2.4.0 @@ -31,6 +33,8 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect @@ -40,8 +44,15 @@ require ( github.com/jcmturner/goidentity/v6 v6.0.1 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index 9ac55df..2b7940b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= @@ -19,8 +17,16 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= @@ -50,12 +56,26 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -65,14 +85,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -82,9 +102,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= @@ -98,9 +115,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -110,9 +124,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/loader/auth.go b/loader/auth.go new file mode 100644 index 0000000..5f4eafd --- /dev/null +++ b/loader/auth.go @@ -0,0 +1,118 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader + +import ( + "fmt" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" +) + +// ResolvedAuth computes the effective authentication credentials for +// the given tool namespace by overlaying sub-block fields on top of +// the server-level credentials, then validating the resulting tuple +// under strict-mode rules. +// +// Pass an empty namespace ("") to get the server-level view, used by +// callers that target Keyfactor Command directly (kfutil, +// terraform-provider-keyfactor). Tools with their own auth section +// pass their namespace (e.g. "acme"). +// +// Strict-mode rules: +// - Any auth-method-specific field set in the sub-block (e.g. +// `client_id` under `acme:`) means the sub-block is asserting that +// method. The entire required tuple must be present in the +// sub-block; missing fields are NOT silently inherited. +// - Conversely, an empty sub-block (or one that sets only non-auth +// fields like base_url) inherits the server-level credentials in +// full. +// +// This catches "I forgot client_secret" with a clean error instead of +// silently picking up the wrong secret from the parent level. +func (l *Loader) ResolvedAuth(namespace string) (*auth_providers.AuthCreds, error) { + if !l.loaded { + return nil, fmt.Errorf("loader.ResolvedAuth called before Load") + } + + // Server-level view comes from Load's cached, overlaid Server so + // env-var and flag overrides aren't lost. + serverCreds := auth_providers.AuthCredsFromServer(l.cachedServer) + + if namespace == "" { + if _, err := serverCreds.Validate(); err != nil { + return nil, fmt.Errorf("server-level auth: %w", err) + } + return serverCreds, nil + } + + // Sub-block view. + var subCreds auth_providers.AuthCreds + subKey := "servers." + l.resolvedProfile + "." + namespace + if l.v.IsSet(subKey) { + if err := l.v.UnmarshalKey(subKey, &subCreds, decoderOpts()); err != nil { + return nil, fmt.Errorf("unmarshal %q sub-block auth fields: %w", namespace, err) + } + } + + merged, err := overlayAuth(serverCreds, &subCreds) + if err != nil { + return nil, fmt.Errorf("%s auth: %w", namespace, err) + } + if _, err := merged.Validate(); err != nil { + return nil, fmt.Errorf("%s auth: %w", namespace, err) + } + return merged, nil +} + +// overlayAuth applies sub-block fields on top of server-level fields +// per the strict-mode rules documented on ResolvedAuth. Returns the +// merged AuthCreds or an error when the sub-block declares a partial +// method override. +func overlayAuth(server, sub *auth_providers.AuthCreds) (*auth_providers.AuthCreds, error) { + if subDeclaresMethod(sub) { + // Sub-block is asserting its own method — return a fresh tuple + // taking ONLY from the sub-block. Inheritance is disabled to + // avoid the half-and-half case where (say) client_id is from + // the sub-block but client_secret leaks in from the parent. + out := *sub + return &out, nil + } + // No method assertion in the sub-block; inherit everything. + out := *server + return &out, nil +} + +// subDeclaresMethod reports whether the sub-block sets ANY field +// indicative of an auth method assertion. Fields that don't belong to +// a specific method (audience, scopes, domain alone) don't trigger it. +func subDeclaresMethod(sub *auth_providers.AuthCreds) bool { + if sub == nil { + return false + } + if sub.AuthType != "" { + return true + } + switch { + case sub.Username != "" || sub.Password != "": + return true + case sub.ClientID != "" || sub.ClientSecret != "" || sub.TokenURL != "": + return true + case sub.AccessToken != "": + return true + case sub.KerberosKeytab != "" || sub.KerberosCCache != "" || sub.KerberosRealm != "": + return true + } + return false +} diff --git a/loader/discovery.go b/loader/discovery.go new file mode 100644 index 0000000..db9e48c --- /dev/null +++ b/loader/discovery.go @@ -0,0 +1,87 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" +) + +// configFileExtensions is the discovery order applied to +// ~/.keyfactor/command_config.* when no explicit path is provided. +// JSON-first preserves the existing kfc-auth convention; YAML variants +// are checked next. +var configFileExtensions = []string{".json", ".yaml", ".yml"} + +// discoverConfigFile resolves the file path the loader should read. +// +// Returns ("", nil) when no config file is found anywhere; that's not +// an error — callers can still produce a complete Server from env vars +// and flags alone. +func (l *Loader) discoverConfigFile() (string, error) { + // 0. CLI flag override (bound by bindFlags into "active_config_file"). + if l.opts.flagSet != nil { + if f := l.opts.flagSet.Lookup("config-file"); f != nil && f.Changed { + path := f.Value.String() + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("config file %q (--config-file) not readable: %w", path, err) + } + return path, nil + } + if f := l.opts.flagSet.Lookup("config"); f != nil && f.Changed { + path := f.Value.String() + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("config file %q (--config) not readable: %w", path, err) + } + return path, nil + } + } + + // 1. Explicit option. + if l.opts.configFile != "" { + if _, err := os.Stat(l.opts.configFile); err != nil { + return "", fmt.Errorf("config file %q not readable: %w", l.opts.configFile, err) + } + return l.opts.configFile, nil + } + + // 2. Env override (uses kfc-auth's existing constant). + if envPath := os.Getenv(auth_providers.EnvKeyfactorConfigFile); envPath != "" { + if _, err := os.Stat(envPath); err != nil { + return "", fmt.Errorf("config file %q (from %s) not readable: %w", + envPath, auth_providers.EnvKeyfactorConfigFile, err) + } + return envPath, nil + } + + // 3. Default search path: ~/.keyfactor/command_config.. + home, err := os.UserHomeDir() + if err != nil { + // No home dir is not fatal — caller may be running in a + // minimal container where everything comes from env vars. + return "", nil + } + dir := filepath.Join(home, ".keyfactor") + for _, ext := range configFileExtensions { + candidate := filepath.Join(dir, "command_config"+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + return "", nil +} diff --git a/loader/env.go b/loader/env.go new file mode 100644 index 0000000..848e9b6 --- /dev/null +++ b/loader/env.go @@ -0,0 +1,172 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader + +import ( + "os" + "reflect" + "strings" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" +) + +// canonicalEnvBindings maps the existing kfc-auth env var constants to +// their dotted Viper keys relative to a Server. Bound under +// servers..* during Load. +// +// Order intentionally mirrors auth_core.go / auth_basic.go / +// auth_oauth.go / auth_kerberos.go so a reader can cross-reference. +func canonicalEnvBindings() []envBinding { + return []envBinding{ + // auth_core.go + {key: "host", env: auth_providers.EnvKeyfactorHostName}, + {key: "port", env: auth_providers.EnvKeyfactorPort}, + {key: "api_path", env: auth_providers.EnvKeyfactorAPIPath}, + {key: "skip_tls_verify", env: auth_providers.EnvKeyfactorSkipVerify}, + {key: "ca_cert_path", env: auth_providers.EnvKeyfactorCACert}, + + // auth_basic.go + {key: "username", env: auth_providers.EnvKeyfactorUsername}, + {key: "password", env: auth_providers.EnvKeyfactorPassword}, + {key: "domain", env: auth_providers.EnvKeyfactorDomain}, + + // auth_oauth.go + {key: "client_id", env: auth_providers.EnvKeyfactorClientID}, + {key: "client_secret", env: auth_providers.EnvKeyfactorClientSecret}, + {key: "token_url", env: auth_providers.EnvKeyfactorAuthTokenURL}, + {key: "access_token", env: auth_providers.EnvKeyfactorAccessToken}, + {key: "audience", env: auth_providers.EnvKeyfactorAuthAudience}, + {key: "scopes", env: auth_providers.EnvKeyfactorAuthScopes}, + + // auth_kerberos.go + {key: "kerberos_realm", env: auth_providers.EnvKeyfactorKrbRealm}, + {key: "kerberos_keytab", env: auth_providers.EnvKeyfactorKrbKeytab}, + {key: "kerberos_config", env: auth_providers.EnvKeyfactorKrbConfig}, + {key: "kerberos_ccache", env: auth_providers.EnvKeyfactorKrbCCache}, + {key: "kerberos_spn", env: auth_providers.EnvKeyfactorKrbSPN}, + } +} + +type envBinding struct { + key string // Viper sub-key under servers., e.g. "host" + env string // environment variable name +} + +// bindCanonicalEnv wires the kfc-auth standard env vars to their +// server-level keys for the active profile. Called from Load. +// +// We bind via explicit BindEnv (one call per var) rather than +// AutomaticEnv because the profile name is part of the Viper key path +// and we need to compose it deterministically. +func (l *Loader) bindCanonicalEnv() { + // Profile selector env var is bound to a synthetic top-level key + // used only by resolveProfile. + _ = l.v.BindEnv("active_profile", auth_providers.EnvKeyfactorAuthProfile) + + // Canonical bindings need a profile path; defer the actual binding + // until resolveProfile has run by re-binding from there. For now, + // stash them so resolveProfile can apply them once it knows the + // active profile. + l.opts.pendingCanonicalEnv = canonicalEnvBindings() +} + +// applyCanonicalEnvForProfile binds the canonical env vars under the +// concrete profile path now that l.resolvedProfile is set. +func (l *Loader) applyCanonicalEnvForProfile() { + for _, b := range l.opts.pendingCanonicalEnv { + full := "servers." + l.resolvedProfile + "." + b.key + _ = l.v.BindEnv(full, b.env) + } +} + +// bindToolEnvs walks each registered tool namespace and, if the +// caller supplied an envPrefix and a schema, binds +// _ for every mapstructure-tagged field in the +// schema. Field names with embedded dots are NOT supported; flat +// structs work cleanly. +func (l *Loader) bindToolEnvs() { + for _, t := range l.opts.tools { + if t.envPrefix == "" || t.schema == nil { + continue + } + fields := schemaFields(t.schema) + for _, field := range fields { + envName := t.envPrefix + "_" + strings.ToUpper(field) + viperKey := "servers." + l.resolvedProfile + "." + t.name + "." + field + _ = l.v.BindEnv(viperKey, envName) + } + } +} + +// schemaFields introspects a struct (or pointer to struct) and returns +// the lowercase mapstructure tag (or field name) for every top-level +// field. Embedded fields and nested structs are not recursed — tool +// sub-blocks should be flat. Fields with `mapstructure:"-"` are +// skipped. +func schemaFields(schema any) []string { + t := reflect.TypeOf(schema) + if t == nil { + return nil + } + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + out := make([]string, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + tag := f.Tag.Get("mapstructure") + if tag == "-" { + continue + } + name := strings.Split(tag, ",")[0] + if name == "" { + name = strings.ToLower(f.Name) + } + out = append(out, name) + } + return out +} + +// resolveProfile picks the active profile name. Precedence: +// +// WithProfile option > KEYFACTOR_AUTH_CONFIG_PROFILE > "default" +func (l *Loader) resolveProfile() string { + if l.opts.profile != "" { + l.applyCanonicalEnvForProfile_setProfile(l.opts.profile) + return l.opts.profile + } + if envVal := os.Getenv(auth_providers.EnvKeyfactorAuthProfile); envVal != "" { + l.applyCanonicalEnvForProfile_setProfile(envVal) + return envVal + } + l.applyCanonicalEnvForProfile_setProfile(auth_providers.DefaultConfigProfile) + return auth_providers.DefaultConfigProfile +} + +// applyCanonicalEnvForProfile_setProfile is a tiny helper that stashes +// the resolved profile and applies all deferred bindings — canonical +// env vars and pflag-bound flags. Split out so resolveProfile reads +// cleanly. +func (l *Loader) applyCanonicalEnvForProfile_setProfile(profile string) { + l.resolvedProfile = profile + l.applyCanonicalEnvForProfile() + l.applyFlagBindingsForProfile() +} diff --git a/loader/extras.go b/loader/extras.go new file mode 100644 index 0000000..8ef8f1a --- /dev/null +++ b/loader/extras.go @@ -0,0 +1,49 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader + +import ( + "fmt" +) + +// DecodeExtras unmarshals the per-tool sub-block named `namespace` +// (e.g. "acme") into target. target must be a non-nil pointer to a +// struct whose fields have `mapstructure` tags matching the YAML keys +// under the sub-block. +// +// When the sub-block is absent, DecodeExtras returns nil and leaves +// target untouched. Callers that require the sub-block should check +// for zero values afterwards. +// +// DecodeExtras can only be called after Load. +func (l *Loader) DecodeExtras(namespace string, target any) error { + if !l.loaded { + return fmt.Errorf("loader.DecodeExtras called before Load") + } + if namespace == "" { + return fmt.Errorf("loader.DecodeExtras requires a non-empty namespace") + } + if target == nil { + return fmt.Errorf("loader.DecodeExtras requires a non-nil target") + } + key := "servers." + l.resolvedProfile + "." + namespace + if !l.v.IsSet(key) { + return nil + } + if err := l.v.UnmarshalKey(key, target, decoderOpts()); err != nil { + return fmt.Errorf("decode sub-block %q: %w", namespace, err) + } + return nil +} diff --git a/loader/flags.go b/loader/flags.go new file mode 100644 index 0000000..7760d54 --- /dev/null +++ b/loader/flags.go @@ -0,0 +1,116 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader + +// canonicalFlagBindings maps the standard Keyfactor CLI flag names to +// their dotted Viper keys relative to a Server. Flags absent from the +// caller-supplied FlagSet are silently ignored so consumers can opt +// into whichever subset they expose. +// +// Flag names follow the existing Keyfactor convention (hyphenated) for +// the CLI surface, while the underlying Viper keys use underscores to +// match the canonical file format. +func canonicalFlagBindings() []flagBinding { + return []flagBinding{ + // Connection target. + {flag: "hostname", key: "host"}, + {flag: "base-url", key: "host"}, // kfacme-cli compatibility alias + {flag: "port", key: "port"}, + {flag: "api-path", key: "api_path"}, + {flag: "skip-verify", key: "skip_tls_verify"}, + {flag: "ca-cert", key: "ca_cert_path"}, + + // Basic auth. + {flag: "username", key: "username"}, + {flag: "password", key: "password"}, + {flag: "domain", key: "domain"}, + + // OAuth2. + {flag: "client-id", key: "client_id"}, + {flag: "client-secret", key: "client_secret"}, + {flag: "token-url", key: "token_url"}, + {flag: "access-token", key: "access_token"}, + {flag: "scopes", key: "scopes"}, + {flag: "audience", key: "audience"}, + + // Kerberos. + {flag: "kerberos-realm", key: "kerberos_realm"}, + {flag: "kerberos-keytab", key: "kerberos_keytab"}, + {flag: "kerberos-config", key: "kerberos_config"}, + {flag: "kerberos-ccache", key: "kerberos_ccache"}, + {flag: "kerberos-spn", key: "kerberos_spn"}, + } +} + +type flagBinding struct { + flag string // pflag name (hyphenated) + key string // Viper sub-key under servers. +} + +// bindFlags wires every recognized flag in the registered FlagSet to +// its corresponding server-level Viper key. Profile-selection and +// config-file flags are bound to synthetic top-level keys; the +// resolveProfile and discoverConfigFile steps consume them. +// +// Returns nil when no FlagSet was registered. Errors only on a viper +// binding failure (which in practice doesn't happen for non-nil flag +// objects). +func (l *Loader) bindFlags() error { + fs := l.opts.flagSet + if fs == nil { + return nil + } + + // Profile and config-file flags target synthetic top-level keys + // (no profile prefix). resolveProfile / discoverConfigFile consume + // them. + if f := fs.Lookup("profile"); f != nil { + if err := l.v.BindPFlag("active_profile", f); err != nil { + return err + } + } + if f := fs.Lookup("config-file"); f != nil { + if err := l.v.BindPFlag("active_config_file", f); err != nil { + return err + } + } else if f := fs.Lookup("config"); f != nil { // common short form + if err := l.v.BindPFlag("active_config_file", f); err != nil { + return err + } + } + + // Canonical server-level flags bind under the active profile path. + // At this point the profile may not be resolved yet, so we defer + // the binding until after resolveProfile runs, just like canonical + // env vars do. Stash the pairs that have matching flags now and + // apply them in applyFlagBindingsForProfile. + for _, b := range canonicalFlagBindings() { + if f := fs.Lookup(b.flag); f != nil { + l.opts.pendingFlagBindings = append(l.opts.pendingFlagBindings, + pendingFlagBinding{flag: f, key: b.key}) + } + } + return nil +} + +// applyFlagBindingsForProfile finalizes the server-level flag bindings +// now that the active profile name is known. Called from the body of +// resolveProfile via the same mechanism as canonical env vars. +func (l *Loader) applyFlagBindingsForProfile() { + for _, b := range l.opts.pendingFlagBindings { + full := "servers." + l.resolvedProfile + "." + b.key + _ = l.v.BindPFlag(full, b.flag) + } +} diff --git a/loader/loader.go b/loader/loader.go new file mode 100644 index 0000000..a6ff81f --- /dev/null +++ b/loader/loader.go @@ -0,0 +1,198 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package loader provides a Viper-backed configuration loader for tools +// that authenticate against Keyfactor Command (and optionally other +// Keyfactor APIs). +// +// It reads the canonical Keyfactor config file layout +// (~/.keyfactor/command_config.{json,yml,yaml} with a Servers map of +// profile name → Server), merges environment variables and (optionally) +// CLI flags on top, and returns a populated auth_providers.Server. +// +// Tools that need fields beyond the canonical Server struct register a +// per-tool "namespace" — a YAML key under each Server (e.g. `acme:`, +// `kfutil:`) whose contents are decoded into a caller-supplied struct. +// Sub-block fields inherit from the server-level fields by default; +// per-field overrides are honored under strict-mode rules +// (see ResolvedAuth). +// +// The loader subpackage is the only place Viper enters the +// keyfactor-auth-client-go dependency graph. Consumers that don't +// import loader keep their slim dep tree. +package loader + +import ( + "fmt" + "strings" + + "github.com/go-viper/mapstructure/v2" + "github.com/spf13/viper" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" +) + +// decoderOpts configures viper.UnmarshalKey to use the existing JSON +// tags on Server (and the mapstructure tags on AuthCreds), and to +// match keys case-insensitively after stripping underscores. This +// lets `client_id` in YAML resolve to `ClientID` on Server without +// having to retag the entire struct. +func decoderOpts() viper.DecoderConfigOption { + return func(c *mapstructure.DecoderConfig) { + // When multiple tag names are present mapstructure prefers + // the configured one; we use "json" because Server already + // has full json tags. AuthCreds has explicit mapstructure + // tags so this name will be used for it too without conflict. + c.TagName = "json" + // Match "client_id" to "ClientID" by lowercasing and dropping + // underscores on both sides. The json tag handles most cases; + // MatchName is a belt-and-suspenders for fields where mapstructure + // reaches for the field name (e.g. `Audience` with json:"audience"). + c.MatchName = func(mapKey, fieldName string) bool { + return normalize(mapKey) == normalize(fieldName) + } + } +} + +func normalize(s string) string { + return strings.ToLower(strings.ReplaceAll(s, "_", "")) +} + +// Loader resolves a Keyfactor config from layered sources. The standard +// precedence is: CLI flag > environment variable > config file > caller +// defaults > zero value. +// +// A Loader is single-use: call Load once, then use the returned Server +// (and DecodeExtras / ResolvedAuth helpers) to fetch data. Constructing +// a fresh Loader for each top-level invocation keeps state simple. +type Loader struct { + v *viper.Viper + opts options + + // loaded tracks whether Load has been called; subsequent calls to + // DecodeExtras / ResolvedAuth rely on the Viper instance being + // populated. + loaded bool + + // resolvedProfile is the profile name picked during Load, after + // all overrides (flag → env → option → default). + resolvedProfile string + + // cachedServer is the fully overlaid Server produced by Load. + // ResolvedAuth uses this rather than re-unmarshalling the Viper + // view so that env-var and flag overrides applied in + // overlayCanonicalFields aren't lost. + cachedServer *auth_providers.Server +} + +// New constructs a Loader. Options can be supplied in any order. +// +// With no options, the loader reads the default config file +// (~/.keyfactor/command_config.{json,yml,yaml}, with the discovery +// order matching kfc-auth's existing convention), selects the +// "default" profile or whatever KEYFACTOR_AUTH_CONFIG_PROFILE names, +// and binds the canonical KEYFACTOR_* / KEYFACTOR_AUTH_* env vars to +// their server-level fields. +func New(opts ...Option) *Loader { + o := defaultOptions() + for _, opt := range opts { + opt(&o) + } + v := viper.New() + v.SetConfigType("yaml") // overridden by ConfigType inference in Load + return &Loader{v: v, opts: o} +} + +// Load resolves the active profile from all layers and returns the +// populated Server. The returned Server's Extras map contains every +// registered tool namespace, ready for DecodeExtras. +// +// Load performs no authentication and no URL/host validation; the +// caller validates whatever it needs (typically AuthCreds.Validate() +// from auth_providers plus its own URL checks). +func (l *Loader) Load() (*auth_providers.Server, error) { + if l.loaded { + return nil, fmt.Errorf("loader.Load already called; construct a fresh Loader for a new resolution") + } + + // 1. Resolve the config file path. Search order: + // explicit --config flag (caller-bound) > + // WithConfigFile option > + // KEYFACTOR_AUTH_CONFIG_FILE env > + // ~/.keyfactor/command_config.{json,yml,yaml}. + path, err := l.discoverConfigFile() + if err != nil { + return nil, err + } + if path != "" { + l.v.SetConfigFile(path) + if err := l.v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("read config file %q: %w", path, err) + } + } + + // 2. Bind canonical environment variables. Tool-namespace env vars + // are bound after we know the active profile so we can target + // the right Viper key path. + l.bindCanonicalEnv() + + // 3. Bind CLI flags from any registered pflag.FlagSet. + if err := l.bindFlags(); err != nil { + return nil, err + } + + // 4. Apply caller-provided defaults at the lowest precedence layer. + for k, val := range l.opts.defaults { + l.v.SetDefault(k, val) + } + + // 5. Resolve the active profile name. + l.resolvedProfile = l.resolveProfile() + + // 6. Bind tool-namespace env vars now that the profile is known. + l.bindToolEnvs() + + // 7. Unmarshal the active profile into a Server. We allow the + // profile to be absent so callers can construct a Server from + // pure env/flag input (common in CI). + key := "servers." + l.resolvedProfile + var srv auth_providers.Server + if err := l.v.UnmarshalKey(key, &srv, decoderOpts()); err != nil { + return nil, fmt.Errorf("unmarshal profile %q: %w", l.resolvedProfile, err) + } + + // 8. Overlay env vars and flags onto the Server. Viper's + // UnmarshalKey does NOT traverse BindEnv / BindPFlag into + // nested struct fields, so we apply the overrides explicitly + // using v.IsSet checks (which DO honor env/flag bindings). + // Precedence within Viper itself stays flag > env > file, so + // a single v.GetString per leaf gives the right value. + l.overlayCanonicalFields(&srv, key) + + l.cachedServer = &srv + l.loaded = true + return &srv, nil +} + +// Profile reports the active profile name. Valid only after Load. +func (l *Loader) Profile() string { + return l.resolvedProfile +} + +// Viper returns the underlying *viper.Viper instance. Exposed for +// advanced callers that need to query keys directly; most consumers +// should prefer DecodeExtras and ResolvedAuth. +func (l *Loader) Viper() *viper.Viper { + return l.v +} diff --git a/loader/loader_test.go b/loader/loader_test.go new file mode 100644 index 0000000..e747952 --- /dev/null +++ b/loader/loader_test.go @@ -0,0 +1,317 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" + "github.com/Keyfactor/keyfactor-auth-client-go/loader" +) + +// acmeSchema is the kfacme-cli sub-block shape. Defined inside the +// test package because the loader itself stays schema-agnostic. +type acmeSchema struct { + BaseURL string `mapstructure:"base_url"` + Output string `mapstructure:"output"` +} + +func fixture(t *testing.T, name string) string { + t.Helper() + p, err := filepath.Abs(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("abs: %v", err) + } + return p +} + +// TestLoad_SharedCreds covers the common case: one OAuth2 tuple at the +// server level, used by both Command-targeting tools and the ACME +// sub-block via inheritance. +func TestLoad_SharedCreds(t *testing.T) { + l := loader.New(loader.WithConfigFile(fixture(t, "shared_creds.yaml"))) + srv, err := l.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got, want := srv.Host, "command.example.com"; got != want { + t.Errorf("Host: got %q, want %q", got, want) + } + if got, want := srv.ClientID, "shared-svc"; got != want { + t.Errorf("ClientID: got %q, want %q", got, want) + } + + // Server-level auth view validates as oauth2. + got, err := l.ResolvedAuth("") + if err != nil { + t.Fatalf("ResolvedAuth(\"\"): %v", err) + } + if got.ClientID != "shared-svc" || got.ClientSecret != "shared-rotate" { + t.Errorf("server-level creds not populated: %+v", got) + } + + // ACME view inherits the same auth tuple (sub-block has no auth + // fields, so strict-mode falls through to inheritance). + gotAcme, err := l.ResolvedAuth("acme") + if err != nil { + t.Fatalf("ResolvedAuth(\"acme\"): %v", err) + } + if gotAcme.ClientID != "shared-svc" { + t.Errorf("acme creds did not inherit: %+v", gotAcme) + } + + // Sub-block decodes into the consumer's struct. + var acme acmeSchema + if err := l.DecodeExtras("acme", &acme); err != nil { + t.Fatalf("DecodeExtras: %v", err) + } + if acme.BaseURL != "https://acme.example.com/acme-admin" { + t.Errorf("acme.BaseURL: got %q", acme.BaseURL) + } +} + +// TestLoad_SeparateCreds covers the case where the sub-block declares +// its own full OAuth2 tuple. The sub-block creds must win for the +// "acme" namespace; the server-level creds must remain intact for the +// empty namespace. +func TestLoad_SeparateCreds(t *testing.T) { + l := loader.New(loader.WithConfigFile(fixture(t, "separate_creds.yaml"))) + if _, err := l.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + gotCommand, err := l.ResolvedAuth("") + if err != nil { + t.Fatalf("ResolvedAuth(\"\"): %v", err) + } + if gotCommand.ClientID != "command-svc" { + t.Errorf("command creds: %+v", gotCommand) + } + + gotAcme, err := l.ResolvedAuth("acme") + if err != nil { + t.Fatalf("ResolvedAuth(\"acme\"): %v", err) + } + if gotAcme.ClientID != "acme-svc" || gotAcme.ClientSecret != "acme-rotate" { + t.Errorf("acme creds did not override: %+v", gotAcme) + } + if gotAcme.TokenURL != "https://acme-auth.customer.com/oauth/token" { + t.Errorf("acme token_url did not override: %+v", gotAcme) + } +} + +// TestLoad_ACMEOnly verifies that profiles without Command target +// fields still resolve cleanly. The auth tuple is complete; the +// caller (kfacme-cli) is responsible for checking its own URL field +// later. +func TestLoad_ACMEOnly(t *testing.T) { + l := loader.New(loader.WithConfigFile(fixture(t, "acme_only.yaml"))) + srv, err := l.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if srv.Host != "" { + t.Errorf("expected empty Host, got %q", srv.Host) + } + + // Server-level auth is the inherited tuple; should validate as + // oauth2 even though Host is empty (loader doesn't enforce hosts). + if _, err := l.ResolvedAuth(""); err != nil { + t.Fatalf("server-level auth should validate on an ACME-only profile: %v", err) + } + + var acme acmeSchema + if err := l.DecodeExtras("acme", &acme); err != nil { + t.Fatalf("DecodeExtras: %v", err) + } + if acme.BaseURL == "" { + t.Error("acme.base_url should be populated") + } +} + +// TestLoad_MixedMethods covers the case where the sub-block uses a +// different auth method (static token) than the server level (OAuth2). +func TestLoad_MixedMethods(t *testing.T) { + l := loader.New(loader.WithConfigFile(fixture(t, "mixed_methods.yaml"))) + if _, err := l.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + // Command auth is oauth2. + gotCmd, err := l.ResolvedAuth("") + if err != nil { + t.Fatalf("ResolvedAuth(\"\"): %v", err) + } + if gotCmd.AccessToken != "" { + t.Errorf("command should not see acme's static token: %+v", gotCmd) + } + + // ACME auth is the static bearer ONLY — strict mode means the + // OAuth2 fields don't leak in. + gotAcme, err := l.ResolvedAuth("acme") + if err != nil { + t.Fatalf("ResolvedAuth(\"acme\"): %v", err) + } + if gotAcme.AccessToken == "" { + t.Errorf("acme should see its static token: %+v", gotAcme) + } + if gotAcme.ClientID != "" { + t.Errorf("strict mode: acme should NOT inherit command's client_id: %+v", gotAcme) + } + + method, err := gotAcme.Validate() + if err != nil { + t.Fatalf("acme creds should validate as token: %v", err) + } + if method != auth_providers.AuthMethodToken { + t.Errorf("acme method: got %q, want token", method) + } +} + +// TestLoad_PartialOverrideRejected covers strict mode: a sub-block +// that declares an OAuth2 tuple but omits client_secret must error +// with a clear message. +func TestLoad_PartialOverrideRejected(t *testing.T) { + l := loader.New(loader.WithConfigFile(fixture(t, "partial_override.yaml"))) + if _, err := l.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + _, err := l.ResolvedAuth("acme") + if err == nil { + t.Fatal("expected an error for partial override; got nil") + } + msg := err.Error() + if !strings.Contains(msg, "client_secret") { + t.Errorf("expected error to mention client_secret; got: %s", msg) + } + if !strings.Contains(msg, "acme") { + t.Errorf("expected error to mention the acme namespace; got: %s", msg) + } +} + +// TestLoad_ProfileSelection verifies the WithProfile option targets a +// non-default profile correctly. +func TestLoad_ProfileSelection(t *testing.T) { + l := loader.New( + loader.WithConfigFile(fixture(t, "shared_creds.yaml")), + loader.WithProfile("staging"), + ) + srv, err := l.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if srv.Host != "command-staging.example.com" { + t.Errorf("Host: got %q, want command-staging.example.com", srv.Host) + } + if l.Profile() != "staging" { + t.Errorf("Profile: got %q, want staging", l.Profile()) + } +} + +// TestLoad_EnvOverride verifies that env vars override file values +// for canonical kfc-auth fields. +func TestLoad_EnvOverride(t *testing.T) { + t.Setenv(auth_providers.EnvKeyfactorClientSecret, "from-env") + + l := loader.New(loader.WithConfigFile(fixture(t, "shared_creds.yaml"))) + if _, err := l.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + got, err := l.ResolvedAuth("") + if err != nil { + t.Fatalf("ResolvedAuth: %v", err) + } + if got.ClientSecret != "from-env" { + t.Errorf("env should override file: got %q, want from-env", got.ClientSecret) + } +} + +// TestLoad_ToolEnvBinding verifies that registering a tool namespace +// with an env prefix binds the tool's sub-block fields. +func TestLoad_ToolEnvBinding(t *testing.T) { + t.Setenv("KEYFACTOR_ACME_BASE_URL", "https://from-env.example.com/acme") + + l := loader.New( + loader.WithConfigFile(fixture(t, "shared_creds.yaml")), + loader.WithToolNamespace("acme", "KEYFACTOR_ACME", &acmeSchema{}), + ) + if _, err := l.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + var acme acmeSchema + if err := l.DecodeExtras("acme", &acme); err != nil { + t.Fatalf("DecodeExtras: %v", err) + } + if acme.BaseURL != "https://from-env.example.com/acme" { + t.Errorf("tool env should override file: got %q", acme.BaseURL) + } +} + +// TestAuthCreds_Validate sanity-checks the validator covers every +// auth method and rejects an empty tuple. +func TestAuthCreds_Validate(t *testing.T) { + cases := []struct { + name string + creds auth_providers.AuthCreds + want auth_providers.AuthMethod + wantErr bool + }{ + {"empty", auth_providers.AuthCreds{}, auth_providers.AuthMethodUnset, true}, + { + "basic complete", + auth_providers.AuthCreds{Username: "u", Password: "p"}, + auth_providers.AuthMethodBasic, false, + }, + { + "basic missing password", + auth_providers.AuthCreds{Username: "u"}, + auth_providers.AuthMethodUnset, true, + }, + { + "oauth2 complete", + auth_providers.AuthCreds{ClientID: "c", ClientSecret: "s", TokenURL: "u"}, + auth_providers.AuthMethodOAuth2, false, + }, + { + "oauth2 missing token_url", + auth_providers.AuthCreds{ClientID: "c", ClientSecret: "s"}, + auth_providers.AuthMethodUnset, true, + }, + { + "token", + auth_providers.AuthCreds{AccessToken: "t"}, + auth_providers.AuthMethodToken, false, + }, + { + "kerberos keytab", + auth_providers.AuthCreds{KerberosKeytab: "/etc/krb5.keytab"}, + auth_providers.AuthMethodKerberos, false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.creds.Validate() + if (err != nil) != tc.wantErr { + t.Fatalf("err: got %v, wantErr %v", err, tc.wantErr) + } + if got != tc.want { + t.Errorf("method: got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/loader/options.go b/loader/options.go new file mode 100644 index 0000000..cf06323 --- /dev/null +++ b/loader/options.go @@ -0,0 +1,136 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader + +import ( + "github.com/spf13/pflag" +) + +// Option configures a Loader at construction time. Options are +// composable and order-independent. +type Option func(*options) + +// toolNamespace is a registration record produced by WithToolNamespace. +// The schema parameter is optional; when supplied, its mapstructure +// tags are introspected to bind _ env vars to the +// corresponding sub-block fields under the active profile. +type toolNamespace struct { + name string // YAML key (e.g. "acme") + envPrefix string // env-var prefix (e.g. "KEYFACTOR_ACME"); empty disables env binding + schema any // optional reflection target for env-suffix derivation +} + +type options struct { + configFile string + profile string + flagSet *pflag.FlagSet + defaults map[string]any + tools []toolNamespace + + // pendingCanonicalEnv is the list of canonical kfc-auth env vars to + // bind once the active profile is known (the profile name is part + // of the Viper key path). Populated by bindCanonicalEnv in env.go. + pendingCanonicalEnv []envBinding + + // pendingFlagBindings is the analogous deferred-binding list for + // pflag-bound flags from the caller's FlagSet. + pendingFlagBindings []pendingFlagBinding +} + +// pendingFlagBinding records a server-level flag that should be bound +// under servers.. once resolveProfile runs. +type pendingFlagBinding struct { + flag *pflag.Flag + key string +} + +func defaultOptions() options { + return options{ + defaults: make(map[string]any), + } +} + +// WithConfigFile pins a specific config file path, bypassing +// auto-discovery. Equivalent to setting the KEYFACTOR_AUTH_CONFIG_FILE +// env var or passing --config-file at the CLI layer (when a flag set +// is registered). +func WithConfigFile(path string) Option { + return func(o *options) { o.configFile = path } +} + +// WithProfile selects which profile under `servers:` to read. When +// unset, the loader falls back to KEYFACTOR_AUTH_CONFIG_PROFILE and +// then to "default". +func WithProfile(name string) Option { + return func(o *options) { o.profile = name } +} + +// WithFlagSet binds a pflag.FlagSet so that registered flag values +// participate in precedence at the highest layer. Cobra-based tools +// typically pass cmd.PersistentFlags() or cmd.Flags(). +// +// The loader binds the canonical Keyfactor flag names (--base-url, +// --hostname, --username, --password, --client-id, --client-secret, +// --token-url, --access-token, --scopes, --audience, --skip-verify, +// --api-path, --profile, --config-file). Flags absent from the set +// are silently ignored. +func WithFlagSet(fs *pflag.FlagSet) Option { + return func(o *options) { o.flagSet = fs } +} + +// WithDefaults seeds defaults for known fields. Defaults occupy the +// lowest precedence layer; any value from the file, env, or flag wins. +// +// Keys are dotted Viper paths relative to the active profile. To set +// a default for every profile use the top-level form +// "servers.default.skip_tls_verify"; to set just one profile use +// "servers..". +func WithDefaults(defaults map[string]any) Option { + return func(o *options) { + for k, v := range defaults { + o.defaults[k] = v + } + } +} + +// WithToolNamespace registers a per-tool sub-block schema. The +// namespace is the YAML key under each Server (e.g. "acme"). When +// envPrefix is non-empty, env vars matching _ +// (uppercased) are bound to sub-block fields under the active profile. +// +// If schema is non-nil, its mapstructure tags are walked to derive +// the env-suffix → field-name map. When schema is nil, env binding +// for the namespace is disabled — callers can still read sub-block +// fields from the config file via DecodeExtras. +// +// Tools typically register their namespace at startup: +// +// type ACMEConfig struct { +// BaseURL string `mapstructure:"base_url"` +// Output string `mapstructure:"output"` +// } +// +// l := loader.New( +// loader.WithToolNamespace("acme", "KEYFACTOR_ACME", &ACMEConfig{}), +// ) +func WithToolNamespace(name, envPrefix string, schema any) Option { + return func(o *options) { + o.tools = append(o.tools, toolNamespace{ + name: name, + envPrefix: envPrefix, + schema: schema, + }) + } +} diff --git a/loader/overlay.go b/loader/overlay.go new file mode 100644 index 0000000..74af21a --- /dev/null +++ b/loader/overlay.go @@ -0,0 +1,159 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loader + +import ( + "os" + "strconv" + "strings" + + "github.com/spf13/pflag" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" +) + +// overlayCanonicalFields applies env-var and flag overrides on top of +// the Server unmarshalled from the config file. +// +// Why this exists: viper's UnmarshalKey doesn't traverse BindEnv / +// BindPFlag bindings into nested struct fields. Rather than fight +// viper for the nested-binding case, we read os.LookupEnv directly +// for each canonical env var and check pflag.FlagSet for explicit +// flag changes. This is deterministic and matches the precedence +// documented in the README (flag > env > file > default) by virtue +// of overlay ordering: env overlays the file first, then flags +// overlay env. +// +// Tool-namespace env vars are overlaid back into the Viper instance +// via v.Set (highest layer) so DecodeExtras sees them later. +func (l *Loader) overlayCanonicalFields(srv *auth_providers.Server, profileKey string) { + // 1. Env overlay onto the unmarshalled Server. + for _, b := range canonicalEnvBindings() { + v, ok := os.LookupEnv(b.env) + if !ok { + continue + } + applyServerField(srv, b.key, v) + } + + // 2. Flag overlay (highest precedence). Only applied for flags the + // user actually set on the command line. + if l.opts.flagSet != nil { + for _, b := range canonicalFlagBindings() { + f := l.opts.flagSet.Lookup(b.flag) + if f == nil || !f.Changed { + continue + } + applyServerField(srv, b.key, flagValueString(f)) + } + } + + // 3. Tool-namespace env vars are pushed back into Viper at the + // override layer so subsequent DecodeExtras calls see them. + for _, t := range l.opts.tools { + if t.envPrefix == "" || t.schema == nil { + continue + } + for _, field := range schemaFields(t.schema) { + envName := t.envPrefix + "_" + strings.ToUpper(field) + if v, ok := os.LookupEnv(envName); ok { + key := profileKey + "." + t.name + "." + field + l.v.Set(key, v) + } + } + } +} + +// applyServerField writes the string value of a single canonical +// Server field. Conversions (int, bool) are local to this function so +// the field-name dispatch stays a single readable switch. +func applyServerField(srv *auth_providers.Server, key, raw string) { + switch key { + case "host": + srv.Host = raw + case "port": + if i, err := strconv.Atoi(raw); err == nil { + srv.Port = i + } + case "api_path": + srv.APIPath = raw + case "skip_tls_verify": + srv.SkipTLSVerify = parseBool(raw) + case "ca_cert_path": + srv.CACertPath = raw + case "username": + srv.Username = raw + case "password": + srv.Password = raw + case "domain": + srv.Domain = raw + case "client_id": + srv.ClientID = raw + case "client_secret": + srv.ClientSecret = raw + case "token_url": + srv.OAuthTokenUrl = raw + case "access_token": + srv.AccessToken = raw + case "audience": + srv.Audience = raw + case "scopes": + // CSV-encoded by convention (KEYFACTOR_AUTH_SCOPES). + srv.Scopes = splitCSV(raw) + case "kerberos_realm": + srv.KerberosRealm = raw + case "kerberos_keytab": + srv.KerberosKeytab = raw + case "kerberos_config": + srv.KerberosConfig = raw + case "kerberos_ccache": + srv.KerberosCCache = raw + case "kerberos_spn": + srv.KerberosSPN = raw + } +} + +func parseBool(s string) bool { + b, err := strconv.ParseBool(s) + if err != nil { + return false + } + return b +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// flagValueString reads the current value of a pflag.Flag as a string, +// regardless of the underlying flag type. Bool flags return "true" or +// "false"; string flags return the literal value. +func flagValueString(f *pflag.Flag) string { + if f == nil { + return "" + } + return f.Value.String() +} diff --git a/loader/testdata/acme_only.yaml b/loader/testdata/acme_only.yaml new file mode 100644 index 0000000..c06727f --- /dev/null +++ b/loader/testdata/acme_only.yaml @@ -0,0 +1,10 @@ +# ACME-only profile: no Command host, no api_path. Loader must accept +# this and validate ACME auth without complaining about the missing +# Command target fields — that's each tool's job to enforce. +servers: + default: + client_id: acme-svc + client_secret: acme-rotate + token_url: https://auth.example.com/oauth/token + acme: + base_url: https://acme.example.com/acme-admin diff --git a/loader/testdata/mixed_methods.yaml b/loader/testdata/mixed_methods.yaml new file mode 100644 index 0000000..bd71770 --- /dev/null +++ b/loader/testdata/mixed_methods.yaml @@ -0,0 +1,11 @@ +# Command authenticated via OAuth2, ACME via a static bearer issued +# out-of-band. Different auth methods per tool sub-block. +servers: + default: + host: command.example.com + client_id: command-svc + client_secret: command-rotate + token_url: https://command-auth.internal/oauth/token + acme: + base_url: https://acme.example.com/acme-admin + access_token: eyJhbGc-acme-static-token diff --git a/loader/testdata/partial_override.yaml b/loader/testdata/partial_override.yaml new file mode 100644 index 0000000..8fcf9af --- /dev/null +++ b/loader/testdata/partial_override.yaml @@ -0,0 +1,15 @@ +# Strict-mode rejection case: the acme sub-block sets client_id and +# token_url but is missing client_secret. The loader must reject this +# with a clear error instead of silently inheriting client_secret +# from the server level. +servers: + default: + host: command.example.com + client_id: command-svc + client_secret: command-rotate + token_url: https://command-auth.internal/oauth/token + acme: + base_url: https://acme.example.com/acme-admin + client_id: acme-svc + # client_secret intentionally omitted + token_url: https://acme-auth.customer.com/oauth/token diff --git a/loader/testdata/separate_creds.yaml b/loader/testdata/separate_creds.yaml new file mode 100644 index 0000000..3bf9fc2 --- /dev/null +++ b/loader/testdata/separate_creds.yaml @@ -0,0 +1,14 @@ +servers: + default: + host: command.example.com + api_path: KeyfactorAPI + client_id: command-svc + client_secret: command-rotate + token_url: https://command-auth.internal/oauth/token + acme: + base_url: https://acme.example.com/acme-admin + # Full OAuth2 tuple in the sub-block; strict mode requires all + # three when any one is set. + client_id: acme-svc + client_secret: acme-rotate + token_url: https://acme-auth.customer.com/oauth/token diff --git a/loader/testdata/shared_creds.yaml b/loader/testdata/shared_creds.yaml new file mode 100644 index 0000000..d6c9937 --- /dev/null +++ b/loader/testdata/shared_creds.yaml @@ -0,0 +1,17 @@ +servers: + default: + host: command.example.com + api_path: KeyfactorAPI + client_id: shared-svc + client_secret: shared-rotate + token_url: https://auth.example.com/oauth/token + acme: + base_url: https://acme.example.com/acme-admin + output: cli + staging: + host: command-staging.example.com + client_id: staging-svc + client_secret: staging-rotate + token_url: https://auth-staging.example.com/oauth/token + acme: + base_url: https://acme-staging.example.com/acme-admin