From 65f70ba6d6d6af3b956b94dd2df822b1bd6ee8c5 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Mon, 16 Mar 2026 13:19:38 -0400 Subject: [PATCH 1/5] fix: reuse oauth2 token across requests Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 4 +++ auth_providers/auth_oauth.go | 16 ++++++++-- auth_providers/auth_oauth_test.go | 49 ++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a8d32..0ac00e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# v1.3.1 +## Fixes +- Reuse OAuth2 token source to prevent unnecessary token fetches for each request. + # v1.3.0 ## Features diff --git a/auth_providers/auth_oauth.go b/auth_providers/auth_oauth.go index d402b75..7fe8912 100644 --- a/auth_providers/auth_oauth.go +++ b/auth_providers/auth_oauth.go @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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. @@ -22,6 +22,7 @@ import ( "net/http" "os" "strings" + "sync" "time" "golang.org/x/oauth2" @@ -115,6 +116,10 @@ type CommandConfigOauth struct { // TokenURL is the token URL for OAuth authentication TokenURL string `json:"token_url,omitempty" yaml:"token_url,omitempty"` + + // unexported: lazily initialized, shared across GetHttpClient() calls + tokenSource oauth2.TokenSource + tsMu sync.Mutex } // NewOAuthAuthenticatorBuilder creates a new CommandConfigOauth instance. @@ -222,7 +227,14 @@ func (b *CommandConfigOauth) GetHttpClient() (*http.Client, error) { } ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Transport: baseTransport}) - tokenSource := config.TokenSource(ctx) + + // Lazily initialize the token source and cache it + b.tsMu.Lock() + if b.tokenSource == nil { + b.tokenSource = config.TokenSource(ctx) + } + tokenSource := b.tokenSource + b.tsMu.Unlock() client = http.Client{ Transport: &oauth2Transport{ diff --git a/auth_providers/auth_oauth_test.go b/auth_providers/auth_oauth_test.go index 720a9ae..cff30be 100644 --- a/auth_providers/auth_oauth_test.go +++ b/auth_providers/auth_oauth_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// 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. @@ -16,9 +16,11 @@ package auth_providers_test import ( "crypto/tls" + "encoding/json" "encoding/pem" "fmt" "net/http" + "net/http/httptest" "net/url" "os" "path/filepath" @@ -568,3 +570,48 @@ func DownloadCertificate(input string, outputPath string) error { fmt.Printf("Certificate chain saved to: %s\n", outputFile) return nil } + +func TestCommandConfigOauth_TokenSourceIsReused(t *testing.T) { + tokenRequestCount := 0 + + // Fake IdP token endpoint + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenRequestCount++ + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "shared-test-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + defer tokenServer.Close() + + // Fake API endpoint (just needs to accept requests) + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer apiServer.Close() + + config := &auth_providers.CommandConfigOauth{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + TokenURL: tokenServer.URL + "/token", + } + + // Get multiple clients from the same config + const numClients = 3 + for i := 0; i < numClients; i++ { + client, err := config.GetHttpClient() + if err != nil { + t.Fatalf("GetHttpClient() call %d failed: %v", i+1, err) + } + _, err = client.Get(apiServer.URL) + if err != nil { + t.Fatalf("request %d failed: %v", i+1, err) + } + } + + if tokenRequestCount != 1 { + t.Errorf("expected token endpoint to be called once, got %d โ€” token source is not being reused across GetHttpClient() calls", tokenRequestCount) + } +} From a56d1fd83468ec632721463ee2fc8bba094f72ea Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Mon, 16 Mar 2026 13:44:11 -0400 Subject: [PATCH 2/5] chore: address PR feedback Signed-off-by: Matthew H. Irby --- auth_providers/auth_oauth_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/auth_providers/auth_oauth_test.go b/auth_providers/auth_oauth_test.go index cff30be..397ec12 100644 --- a/auth_providers/auth_oauth_test.go +++ b/auth_providers/auth_oauth_test.go @@ -25,6 +25,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" @@ -572,11 +573,11 @@ func DownloadCertificate(input string, outputPath string) error { } func TestCommandConfigOauth_TokenSourceIsReused(t *testing.T) { - tokenRequestCount := 0 + var tokenRequestCount atomic.Int32 // Fake IdP token endpoint tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tokenRequestCount++ + tokenRequestCount.Add(1) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "access_token": "shared-test-token", @@ -605,13 +606,14 @@ func TestCommandConfigOauth_TokenSourceIsReused(t *testing.T) { if err != nil { t.Fatalf("GetHttpClient() call %d failed: %v", i+1, err) } - _, err = client.Get(apiServer.URL) + resp, err := client.Get(apiServer.URL) if err != nil { t.Fatalf("request %d failed: %v", i+1, err) } + resp.Body.Close() } - if tokenRequestCount != 1 { - t.Errorf("expected token endpoint to be called once, got %d โ€” token source is not being reused across GetHttpClient() calls", tokenRequestCount) + if tokenRequestCount.Load() != 1 { + t.Errorf("expected token endpoint to be called once, got %d โ€” token source is not being reused across GetHttpClient() calls", tokenRequestCount.Load()) } } From 4d3657dee055f69fc5c9c480355dd1dd76b82301 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 17 Mar 2026 18:23:02 -0400 Subject: [PATCH 3/5] feat: add logging to capture when oauth token source is instantiated Signed-off-by: Matthew H. Irby --- auth_providers/auth_oauth.go | 1 + 1 file changed, 1 insertion(+) diff --git a/auth_providers/auth_oauth.go b/auth_providers/auth_oauth.go index 7fe8912..225c3fc 100644 --- a/auth_providers/auth_oauth.go +++ b/auth_providers/auth_oauth.go @@ -231,6 +231,7 @@ func (b *CommandConfigOauth) GetHttpClient() (*http.Client, error) { // Lazily initialize the token source and cache it b.tsMu.Lock() if b.tokenSource == nil { + log.Printf("[DEBUG] Initializing OAuth2 token source for client ID: %s", b.ClientID) b.tokenSource = config.TokenSource(ctx) } tokenSource := b.tokenSource From 813238a5692fd3fcd8e21a45c979af93fb2052e3 Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 8 Sep 2026 10:45:24 -0400 Subject: [PATCH 4/5] feat(oauth): allow external token source to be provided to oauth config Signed-off-by: Matthew H. Irby --- README.md | 26 ++ auth_providers/auth_oauth.go | 121 +++++---- .../auth_oauth_external_token_source_test.go | 243 ++++++++++++++++++ auth_providers/auth_oauth_test.go | 21 +- auth_providers/command_config.go | 40 +-- 5 files changed, 375 insertions(+), 76 deletions(-) create mode 100644 auth_providers/auth_oauth_external_token_source_test.go diff --git a/README.md b/README.md index 1e783aa..699a05e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,32 @@ Currently `Basic Authentication` via `Active Directory` is the *ONLY* supported | KEYFACTOR_AUTH_ACCESS_TOKEN | Access token to use to authenticate to Keyfactor Command API. This can be supplied directly or generated via client credentials | | | KEYFACTOR_AUTH_CA_CERT | Either a file path or PEM encoded string to a CA certificate to use when connecting to Keyfactor Auth | | +#### Credential Precedence + +`CommandConfigOauth` supports three ways to obtain a token. If more than one is configured at the same time, the SDK does **not** raise an error -- it silently picks the highest-precedence one, in this order: + +1. **Static access token** -- `WithAccessToken(accessToken string)`, or `KEYFACTOR_AUTH_ACCESS_TOKEN` / a config file's `access_token`. Used exactly as given, with no refresh logic; the caller is responsible for the token remaining valid for as long as it's needed. +2. **External token source** (Go API only -- see below) -- `WithExternalTokenSource(src oauth2.TokenSource)`. Takes precedence over client credentials, but not over a static access token. +3. **Client credentials** -- `ClientID` + `ClientSecret` + `TokenURL` (directly, via environment variables, or via a config file profile). The standard OAuth2 `client_credentials` grant, used only when neither of the above is configured. + +If none of the three is configured, `ValidateAuthConfig`/`Authenticate`/`GetHttpClient`/`GetAccessToken` return an error. Because there is no conflict error between them, a leftover `ClientSecret` (e.g. from an old config file or an ambient environment variable) alongside an explicitly configured `WithAccessToken` or `WithExternalTokenSource` will be silently ignored rather than flagged -- keep that in mind when debugging an unexpected credential being used. + +#### External Token Source (Go API only) + +`WithExternalTokenSource` lets a caller supply their own [`oauth2.TokenSource`](https://pkg.go.dev/golang.org/x/oauth2#TokenSource) instead of configuring `ClientID`/`ClientSecret`/`TokenURL`. There is no environment variable or config file equivalent -- it's set programmatically. This is intended for ambient/workload-identity credential providers (cloud platform managed identity, workload identity federation, etc.) where the caller already has a mechanism for obtaining a Command access token and no static secret should exist at all: + +```go +config := auth_providers.NewOAuthAuthenticatorBuilder() +config.WithExternalTokenSource(myTokenSource) // any golang.org/x/oauth2.TokenSource +config.WithCommandHostName("command.example.com") +``` + +Behavior to be aware of: + +- The SDK wraps the supplied source in `oauth2.ReuseTokenSourceWithExpiry` (30-second buffer) when building an HTTP client via `GetHttpClient`/`Authenticate`, so `Token()` is not called on every outgoing request -- only when there is no cached token or the cached one is within 30 seconds of expiring. **The supplied source must set an accurate `Expiry` on every token it returns**; a token with a zero `Expiry` is treated as never expiring by `oauth2.Token` and will be cached indefinitely. +- `GetAccessToken()` calls the supplied source directly on every call instead (no caching), matching how that method already treats the client-credentials flow as a one-shot fetch. +- This SDK does not enforce a timeout on the supplied source's `Token()` call the way it does for its own client-credentials token fetches -- `oauth2.TokenSource` has no `context.Context` parameter, so the source implementation is responsible for its own reasonable timeout/cancellation behavior. + ### Kerberos/SPNEGO Authentication Kerberos authentication supports three methods: credential cache (ccache), keytab file, or username/password. The authentication method is determined automatically based on which credentials are provided, with the following priority: ccache > keytab > password. diff --git a/auth_providers/auth_oauth.go b/auth_providers/auth_oauth.go index ec999e0..75bc198 100644 --- a/auth_providers/auth_oauth.go +++ b/auth_providers/auth_oauth.go @@ -64,6 +64,10 @@ var ( DefaultScopes []string ) +var ( + ErrMissingClientCredentials = fmt.Errorf("client ID, client secret, and token URL are required if access token or static token source is not provided. Please provide these values directly or populate environment variables %s, %s, and %s", EnvKeyfactorClientID, EnvKeyfactorClientSecret, EnvKeyfactorAuthTokenURL) +) + // OAuth Authenticator var _ Authenticator = &OAuthAuthenticator{} @@ -215,6 +219,12 @@ type CommandConfigOauth struct { // TokenURL is the token URL for OAuth authentication TokenURL string `json:"token_url,omitempty" yaml:"token_url,omitempty"` + // ExternalTokenSource, when set, supplies access token from a caller-provided + // oauth2.TokenSource instead of any credential-based flow this SDK manages itself. + // Intended for ambient / workload identity credential providers where the caller + // already has a token-producing mechanism + ExternalTokenSource oauth2.TokenSource `json:"-" yaml:"-"` + // unexported: lazily initialized, shared across GetHttpClient() calls tokenSource oauth2.TokenSource tsMu sync.Mutex @@ -276,6 +286,11 @@ func (b *CommandConfigOauth) WithAccessToken(accessToken string) *CommandConfigO return b } +func (b *CommandConfigOauth) WithExternalTokenSource(src oauth2.TokenSource) *CommandConfigOauth { + b.ExternalTokenSource = src + return b +} + func (b *CommandConfigOauth) WithHttpClient(httpClient *http.Client) *CommandConfigOauth { b.HttpClient = httpClient return b @@ -294,6 +309,7 @@ func (b *CommandConfigOauth) GetHttpClient() (*http.Client, error) { return nil, tErr } + // If an access token is provided directly, use it for the HTTP client instead of fetching a new one. if b.AccessToken != "" { client.Transport = &oauth2.Transport{ Base: baseTransport, @@ -339,12 +355,19 @@ func (b *CommandConfigOauth) GetHttpClient() (*http.Client, error) { // is actually required (initial fetch, or refresh after expiry). b.tsMu.Lock() if b.tokenSource == nil { - log.Printf("[DEBUG] Initializing OAuth2 token source for client ID: %s", b.ClientID) - b.tokenSource = oauth2.ReuseTokenSource(nil, &boundedClientCredentialsTokenSource{ - config: config, - baseTransport: baseTransport, - httpClientTimeout: b.HttpClientTimeout, - }) + if b.ExternalTokenSource != nil { + // Use the caller-supplied external token source, wrapping it with a buffer to ensure tokens are refreshed slightly before they expire. + buffer := time.Second * 30 + log.Printf("[DEBUG] Initializing OAuth2 token source from external token source with a %.0f second expiration buffer", buffer.Seconds()) + b.tokenSource = oauth2.ReuseTokenSourceWithExpiry(nil, b.ExternalTokenSource, buffer) + } else { + log.Printf("[DEBUG] Initializing OAuth2 token source for client ID: %s", b.ClientID) + b.tokenSource = oauth2.ReuseTokenSource(nil, &boundedClientCredentialsTokenSource{ + config: config, + baseTransport: baseTransport, + httpClientTimeout: b.HttpClientTimeout, + }) + } } tokenSource := b.tokenSource b.tsMu.Unlock() @@ -444,52 +467,46 @@ func (b *CommandConfigOauth) ValidateAuthConfig() error { // check if access token is set in the environment if accessToken, ok := os.LookupEnv(EnvKeyfactorAccessToken); ok { b.AccessToken = accessToken + } + } + + if b.ClientID == "" { + if clientId, idOk := os.LookupEnv(EnvKeyfactorClientID); idOk { + b.ClientID = clientId } else { - // check if client ID, client secret, and token URL are provided - if b.ClientID == "" { - if clientId, idOk := os.LookupEnv(EnvKeyfactorClientID); idOk { - b.ClientID = clientId - } else { - if serverConfig != nil && serverConfig.ClientID != "" { - b.ClientID = serverConfig.ClientID - } else { - return fmt.Errorf("client ID or environment variable %s is required", EnvKeyfactorClientID) - } - } + if serverConfig != nil && serverConfig.ClientID != "" { + b.ClientID = serverConfig.ClientID } + } + } - if b.ClientSecret == "" { - if clientSecret, sOk := os.LookupEnv(EnvKeyfactorClientSecret); sOk { - b.ClientSecret = clientSecret - } else { - if serverConfig != nil && serverConfig.ClientSecret != "" { - b.ClientSecret = serverConfig.ClientSecret - } else { - return fmt.Errorf( - "client secret or environment variable %s is required", - EnvKeyfactorClientSecret, - ) - } - } + if b.ClientSecret == "" { + if clientSecret, sOk := os.LookupEnv(EnvKeyfactorClientSecret); sOk { + b.ClientSecret = clientSecret + } else { + if serverConfig != nil && serverConfig.ClientSecret != "" { + b.ClientSecret = serverConfig.ClientSecret } + } + } - if b.TokenURL == "" { - if tokenUrl, uOk := os.LookupEnv(EnvKeyfactorAuthTokenURL); uOk { - b.TokenURL = tokenUrl - } else { - if serverConfig != nil && serverConfig.OAuthTokenUrl != "" { - b.TokenURL = serverConfig.OAuthTokenUrl - } else { - return fmt.Errorf( - "token URL or environment variable %s is required", - EnvKeyfactorAuthTokenURL, - ) - } - } + if b.TokenURL == "" { + if tokenUrl, uOk := os.LookupEnv(EnvKeyfactorAuthTokenURL); uOk { + b.TokenURL = tokenUrl + } else { + if serverConfig != nil && serverConfig.OAuthTokenUrl != "" { + b.TokenURL = serverConfig.OAuthTokenUrl } } } + allClientCredentialsProvided := b.ClientID != "" && b.ClientSecret != "" && b.TokenURL != "" + + // Ensure that either all client credentials are provided or an access token/external token source is available. + if !allClientCredentialsProvided && (b.AccessToken == "" && b.ExternalTokenSource == nil) { + return ErrMissingClientCredentials + } + if b.Audience == "" { if audience, ok := os.LookupEnv(EnvKeyfactorAuthAudience); ok { b.Audience = audience @@ -557,6 +574,7 @@ func (b *CommandConfigOauth) GetServerConfig() *Server { server.ClientID = b.ClientID server.ClientSecret = b.ClientSecret server.AccessToken = b.AccessToken + server.ExternalTokenSource = b.ExternalTokenSource server.OAuthTokenUrl = b.TokenURL server.Scopes = b.Scopes server.Audience = b.Audience @@ -582,6 +600,23 @@ func (b *CommandConfigOauth) GetAccessToken() (*oauth2.Token, error) { }, nil } + if b.ExternalTokenSource != nil { + // Unlike GetHttpClient(), this is a single one-shot fetch with no shared cache to + // consult or populate (the client_credentials branch below is the same: it builds a + // fresh clientcredentials.Config and fetches every call), so this calls straight + // through to the caller-supplied source rather than going through the + // oauth2.ReuseTokenSourceWithExpiry wrapper GetHttpClient() builds into b.tokenSource. + log.Printf("[DEBUG] Fetching OAuth2 token from external token source") + token, tErr := b.ExternalTokenSource.Token() + if tErr != nil { + return nil, fmt.Errorf("failed to retrieve token from external token source: %w", tErr) + } + if token == nil || token.AccessToken == "" { + return nil, fmt.Errorf("received empty OAuth token from external token source") + } + return token, nil + } + log.Printf("[DEBUG] Getting OAuth2 token source for client ID: %s", b.ClientID) if b.ClientID == "" || b.ClientSecret == "" || b.TokenURL == "" { return nil, fmt.Errorf("client ID, client secret, and token URL must be provided") diff --git a/auth_providers/auth_oauth_external_token_source_test.go b/auth_providers/auth_oauth_external_token_source_test.go new file mode 100644 index 0000000..0445a0e --- /dev/null +++ b/auth_providers/auth_oauth_external_token_source_test.go @@ -0,0 +1,243 @@ +// 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. + +// This file scaffolds the test surface for caller-supplied external oauth2.TokenSource +// support, per docs/design/external-token-source.md. It is written against +// CommandConfigOauth.WithExternalTokenSource, which does not exist yet, and is expected to +// fail to compile until that implementation lands. Once it compiles, the conflict test and +// the "no ClientID required" happy path are also expected to keep failing until +// ValidateAuthConfig's credential-source handling is generalized per ยง4 of the design doc +// (today it unconditionally requires a ClientID whenever AccessToken is unset). +package auth_providers_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "golang.org/x/oauth2" + + "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers" +) + +// clearOAuthEnv snapshots and clears the ClientID/ClientSecret/TokenURL-related env vars +// (via the exportOAuthEnvVariables/unsetOAuthEnvVariables/setOAuthEnvVariables helpers +// already defined in auth_oauth_test.go) so ambient CI/dev-environment configuration can't +// leak into these external-token-source tests, restoring them on cleanup. +func clearOAuthEnv(t *testing.T) { + t.Helper() + + clientID, clientSecret, tokenURL := exportOAuthEnvVariables() + unsetOAuthEnvVariables() + t.Cleanup( + func() { + setOAuthEnvVariables(clientID, clientSecret, tokenURL) + }, + ) +} + +// mockTokenSource is a minimal oauth2.TokenSource test double, standing in for whatever +// ambient-credential mechanism a real caller (e.g. command-cert-manager-issuer under +// workload identity) would supply. +type mockTokenSource struct { + token *oauth2.Token + err error + calls atomic.Int32 +} + +func (m *mockTokenSource) Token() (*oauth2.Token, error) { + m.calls.Add(1) + if m.err != nil { + return nil, m.err + } + return m.token, nil +} + +// newStatusEndpointsServer returns an httptest.Server standing in for Command's +// Status/Endpoints health-check endpoint (what CommandAuthConfig.Authenticate() calls), +// capturing the Authorization header of each request it receives. +func newStatusEndpointsServer(t *testing.T, captured *[]string) *httptest.Server { + t.Helper() + + return httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + *captured = append(*captured, r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]string{}) + }, + ), + ) +} + +// TestCommandConfigOauth_ExternalTokenSource_ValidateAndAuthenticate covers the primary +// external-token-source path from docs/design/external-token-source.md: a caller-supplied +// oauth2.TokenSource (standing in for an ambient/workload-identity credential) should be +// enough on its own for ValidateAuthConfig to succeed and for Authenticate to complete a real +// request carrying whatever token the source produced -- no ClientID/ClientSecret/TokenURL +// required. +func TestCommandConfigOauth_ExternalTokenSource_ValidateAndAuthenticate(t *testing.T) { + clearOAuthEnv(t) + + const wantToken = "external-token-abc123" + mockSource := &mockTokenSource{ + token: &oauth2.Token{ + AccessToken: wantToken, + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + }, + } + + var capturedAuthHeaders []string + server := newStatusEndpointsServer(t, &capturedAuthHeaders) + defer server.Close() + + config := &auth_providers.CommandConfigOauth{} + config.WithExternalTokenSource(mockSource) + config.WithCommandHostName(server.URL) + + if err := config.ValidateAuthConfig(); err != nil { + t.Fatalf("expected ValidateAuthConfig to succeed with only an ExternalTokenSource configured, got %v", err) + } + + if err := config.Authenticate(); err != nil { + t.Fatalf("expected Authenticate to succeed, got %v", err) + } + + if len(capturedAuthHeaders) == 0 { + t.Fatalf("expected the fake Command server to receive at least one request") + } + want := "Bearer " + wantToken + for _, got := range capturedAuthHeaders { + if got != want { + t.Errorf("expected Authorization header %q, got %q", want, got) + } + } + + if mockSource.calls.Load() == 0 { + t.Errorf("expected the external token source to have been called at least once") + } +} + +// The SDK must wrap the caller-supplied source (e.g. via oauth2.ReuseTokenSourceWithExpiry) rather +// than calling it on every outgoing request, exactly like the existing ClientSecret flow's +// token reuse (see TestCommandConfigOauth_TokenSourceIsReused in auth_oauth_test.go). +func TestCommandConfigOauth_ExternalTokenSource_Reused(t *testing.T) { + clearOAuthEnv(t) + + mockSource := &mockTokenSource{ + token: &oauth2.Token{ + AccessToken: "reused-token", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + }, + } + + var capturedAuthHeaders []string + server := newStatusEndpointsServer(t, &capturedAuthHeaders) + defer server.Close() + + config := &auth_providers.CommandConfigOauth{} + config.WithExternalTokenSource(mockSource) + config.WithCommandHostName(server.URL) + + const numRequests = 3 + for i := 0; i < numRequests; i++ { + client, err := config.GetHttpClient() + if err != nil { + t.Fatalf("GetHttpClient() call %d failed: %v", i+1, err) + } + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("request %d failed: %v", i+1, err) + } + resp.Body.Close() + } + + if got := mockSource.calls.Load(); got != 1 { + t.Errorf( + "expected the external token source to be called exactly once across %d requests, got %d calls -- it is not being reused/cached", + numRequests, got, + ) + } +} + +// TestCommandConfigOauth_GetAccessToken_ExternalTokenSource covers GetAccessToken() directly +// (as opposed to GetHttpClient()/Authenticate()) with only an ExternalTokenSource configured +// -- no ClientID/ClientSecret/TokenURL at all -- and asserts the token it returns is exactly +// the one the mock source produced. +func TestCommandConfigOauth_GetAccessToken_ExternalTokenSource(t *testing.T) { + clearOAuthEnv(t) + + wantExpiry := time.Now().Add(time.Hour) + mockSource := &mockTokenSource{ + token: &oauth2.Token{ + AccessToken: "external-token-abc123", + TokenType: "Bearer", + Expiry: wantExpiry, + }, + } + + config := &auth_providers.CommandConfigOauth{} + config.WithExternalTokenSource(mockSource) + config.WithCommandHostName("idp.example.com") + + token, err := config.GetAccessToken() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if token == nil { + t.Fatalf("expected a non-nil token") + } + if token.AccessToken != "external-token-abc123" { + t.Errorf("expected AccessToken %q, got %q", "external-token-abc123", token.AccessToken) + } + if !token.Expiry.Equal(wantExpiry) { + t.Errorf("expected Expiry %v, got %v", wantExpiry, token.Expiry) + } + if mockSource.calls.Load() != 1 { + t.Errorf("expected the external token source to be called exactly once, got %d calls", mockSource.calls.Load()) + } +} + +// Validates error propagation when the caller-supplied source itself fails -- it must surface that +// failure rather than falling through to the "client ID, client secret, and token URL must +// be provided" error meant for the client_credentials path. +func TestCommandConfigOauth_GetAccessToken_ExternalTokenSourceError(t *testing.T) { + clearOAuthEnv(t) + + wantErr := fmt.Errorf("ambient credential provider unavailable") + mockSource := &mockTokenSource{err: wantErr} + + config := &auth_providers.CommandConfigOauth{} + config.WithExternalTokenSource(mockSource) + config.WithCommandHostName("idp.example.com") + + token, err := config.GetAccessToken() + if err == nil { + t.Fatalf("expected an error, got a token: %+v", token) + } + if !strings.Contains(err.Error(), wantErr.Error()) { + t.Errorf("expected error to wrap %q, got: %v", wantErr.Error(), err) + } + if strings.Contains(err.Error(), "client ID, client secret, and token URL") { + t.Errorf("got the client_credentials-path error instead of the external-token-source error: %v", err) + } +} diff --git a/auth_providers/auth_oauth_test.go b/auth_providers/auth_oauth_test.go index 4e1bde1..0d61151 100644 --- a/auth_providers/auth_oauth_test.go +++ b/auth_providers/auth_oauth_test.go @@ -352,27 +352,21 @@ func TestCommandConfigOauth_Authenticate(t *testing.T) { t.Log("Testing oAuth with no Environmental variables") incompleteEnvConfig := &auth_providers.CommandConfigOauth{} - incompleteEnvConfigExpectedError := fmt.Sprintf( - "client ID or environment variable %s is required", - auth_providers.EnvKeyfactorClientID, - ) + incompleteEnvConfigExpectedError := auth_providers.ErrMissingClientCredentials authOauthTest( t, "with incomplete Environmental variables", true, incompleteEnvConfig, - incompleteEnvConfigExpectedError, + incompleteEnvConfigExpectedError.Error(), ) t.Log("Testing auth with only clientID") clientIDOnlyConfig := &auth_providers.CommandConfigOauth{ ClientID: "test-client-id", } - clientIDOnlyConfigExceptedError := fmt.Sprintf( - "client secret or environment variable %s is required", - auth_providers.EnvKeyfactorClientSecret, - ) - authOauthTest(t, "clientID only", true, clientIDOnlyConfig, clientIDOnlyConfigExceptedError) + clientIDOnlyConfigExceptedError := auth_providers.ErrMissingClientCredentials + authOauthTest(t, "clientID only", true, clientIDOnlyConfig, clientIDOnlyConfigExceptedError.Error()) t.Log("Testing auth with w/ full params variables") fullParamsConfig := &auth_providers.CommandConfigOauth{ @@ -400,11 +394,8 @@ func TestCommandConfigOauth_Authenticate(t *testing.T) { ClientID: clientID, ClientSecret: clientSecret, } - noTokenURLExpectedError := fmt.Sprintf( - "token URL or environment variable %s is required", - auth_providers.EnvKeyfactorAuthTokenURL, - ) - authOauthTest(t, "w/ no tokenURL", true, noTokenURLConfig, noTokenURLExpectedError) + noTokenURLExpectedError := auth_providers.ErrMissingClientCredentials + authOauthTest(t, "w/ no tokenURL", true, noTokenURLConfig, noTokenURLExpectedError.Error()) // Write the config file back t.Logf("Writing config file: %s", configFilePath) diff --git a/auth_providers/command_config.go b/auth_providers/command_config.go index f5ebc05..1dc84f2 100644 --- a/auth_providers/command_config.go +++ b/auth_providers/command_config.go @@ -19,28 +19,30 @@ import ( "fmt" "os" + "golang.org/x/oauth2" "gopkg.in/yaml.v2" ) // Server represents the server configuration for authentication. type Server struct { - Host string `json:"host,omitempty" yaml:"host,omitempty"` // Host is the Command server DNS name or IP address. - Port int `json:"port,omitempty" yaml:"port,omitempty"` // Port is the Command server port. - Username string `json:"username,omitempty" yaml:"username,omitempty"` // Username is the username for authentication. - Password string `json:"password,omitempty" yaml:"password,omitempty"` // Password is the password for authentication. - Domain string `json:"domain,omitempty" yaml:"domain,omitempty"` // Domain is the domain for authentication. - ClientID string `json:"client_id,omitempty" yaml:"client_id,omitempty"` // ClientID is the client ID for OAuth. - ClientSecret string `json:"client_secret,omitempty" yaml:"client_secret,omitempty"` // ClientSecret is the client secret for OAuth. - AccessToken string `json:"access_token,omitempty" yaml:"access_token,omitempty"` // AccessToken is the OAuth access token. - Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` // Scopes is the OAuth scopes. - Audience string `json:"audience,omitempty" yaml:"audience,omitempty"` // Audience is the OAuth audience. - OAuthTokenUrl string `json:"token_url,omitempty" yaml:"token_url,omitempty"` // OAuthTokenUrl is full URL for OAuth token request endpoint. - APIPath string `json:"api_path,omitempty" yaml:"api_path,omitempty"` // APIPath is the API path. - AuthProvider AuthProvider `json:"auth_provider,omitempty" yaml:"auth_provider,omitempty"` // AuthProvider contains the authentication provider details. - SkipTLSVerify bool `json:"skip_tls_verify,omitempty" yaml:"skip_tls_verify,omitempty"` // TLSVerify determines whether to verify the TLS certificate. - CACertPath string `json:"ca_cert_path,omitempty" yaml:"ca_cert_path,omitempty"` // CACertPath is the path to the CA certificate to trust. - AuthType string `json:"auth_type,omitempty" yaml:"auth_type,omitempty"` // AuthType is the type of authentication to use. - ClientTimeout int `json:"client_timeout,omitempty" yaml:"client_timeout,omitempty"` // ClientTimeout is the http Client timeout, in seconds, mirrored from CommandAuthConfig.HttpClientTimeout. + Host string `json:"host,omitempty" yaml:"host,omitempty"` // Host is the Command server DNS name or IP address. + Port int `json:"port,omitempty" yaml:"port,omitempty"` // Port is the Command server port. + Username string `json:"username,omitempty" yaml:"username,omitempty"` // Username is the username for authentication. + Password string `json:"password,omitempty" yaml:"password,omitempty"` // Password is the password for authentication. + Domain string `json:"domain,omitempty" yaml:"domain,omitempty"` // Domain is the domain for authentication. + ClientID string `json:"client_id,omitempty" yaml:"client_id,omitempty"` // ClientID is the client ID for OAuth. + ClientSecret string `json:"client_secret,omitempty" yaml:"client_secret,omitempty"` // ClientSecret is the client secret for OAuth. + AccessToken string `json:"access_token,omitempty" yaml:"access_token,omitempty"` // AccessToken is the OAuth access token. + Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` // Scopes is the OAuth scopes. + Audience string `json:"audience,omitempty" yaml:"audience,omitempty"` // Audience is the OAuth audience. + OAuthTokenUrl string `json:"token_url,omitempty" yaml:"token_url,omitempty"` // OAuthTokenUrl is full URL for OAuth token request endpoint. + ExternalTokenSource oauth2.TokenSource `json:"-" yaml:"-"` // ExternalTokenSource is the external token source for OAuth. + APIPath string `json:"api_path,omitempty" yaml:"api_path,omitempty"` // APIPath is the API path. + AuthProvider AuthProvider `json:"auth_provider,omitempty" yaml:"auth_provider,omitempty"` // AuthProvider contains the authentication provider details. + SkipTLSVerify bool `json:"skip_tls_verify,omitempty" yaml:"skip_tls_verify,omitempty"` // TLSVerify determines whether to verify the TLS certificate. + CACertPath string `json:"ca_cert_path,omitempty" yaml:"ca_cert_path,omitempty"` // CACertPath is the path to the CA certificate to trust. + AuthType string `json:"auth_type,omitempty" yaml:"auth_type,omitempty"` // AuthType is the type of authentication to use. + ClientTimeout int `json:"client_timeout,omitempty" yaml:"client_timeout,omitempty"` // ClientTimeout is the http Client timeout, in seconds, mirrored from CommandAuthConfig.HttpClientTimeout. // Kerberos authentication fields KerberosRealm string `json:"kerberos_realm,omitempty" yaml:"kerberos_realm,omitempty"` // KerberosRealm is the Kerberos realm (uppercase). @@ -219,6 +221,7 @@ func (s *Server) Compare(other *Server) bool { s.ClientSecret == other.ClientSecret && s.AccessToken == other.AccessToken && s.OAuthTokenUrl == other.OAuthTokenUrl && + s.ExternalTokenSource == other.ExternalTokenSource && s.APIPath == other.APIPath && s.SkipTLSVerify == other.SkipTLSVerify && s.CACertPath == other.CACertPath && @@ -264,7 +267,7 @@ func MergeConfigFromFile(filePath string, config *Config) (*Config, error) { // GetAuthType returns the type of authentication to use based on the configuration params. func (s *Server) GetAuthType() string { - if (s.ClientID != "" && s.ClientSecret != "") || s.AccessToken != "" { + if (s.ClientID != "" && s.ClientSecret != "") || s.AccessToken != "" || s.ExternalTokenSource != nil { s.AuthType = "oauth" } else if s.KerberosRealm != "" || s.KerberosKeytab != "" || s.KerberosCCache != "" { s.AuthType = "kerberos" @@ -333,6 +336,7 @@ func (s *Server) GetOAuthClientConfig() (*CommandConfigOauth, error) { WithTokenUrl(s.OAuthTokenUrl). WithScopes(s.Scopes). WithAudience(s.Audience). + WithExternalTokenSource(s.ExternalTokenSource). Build() vErr := oauthConfig.ValidateAuthConfig() From 3581dbe97d22f0c1073a4cde92ea103b38ebad4b Mon Sep 17 00:00:00 2001 From: "Matthew H. Irby" Date: Tue, 8 Sep 2026 11:03:33 -0400 Subject: [PATCH 5/5] chore(docs): update CHANGELOG Signed-off-by: Matthew H. Irby --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac00e7..b84bf5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# v1.5.1 +## Features +- Add support for external token source in OAuth2 client config. + +# v1.5.0 +## Features +- Add support for Kerberos authentication + # v1.3.1 ## Fixes - Reuse OAuth2 token source to prevent unnecessary token fetches for each request.