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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 78 additions & 43 deletions auth_providers/auth_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
Loading
Loading