From 4476f96057856ea35211f4bce8f77f866ddab265 Mon Sep 17 00:00:00 2001 From: Nandor Kracser Date: Mon, 28 Sep 2026 11:19:58 +0200 Subject: [PATCH 1/3] feat(conjur): add CyberArk Conjur provider Logs in with Conjur's JWT authenticator and reads the configured variables in one batch request, polling for rotated values. Adds a generic credential.Secret type; VaultSecret is now an alias for it so both providers return the same key/value result. --- README.md | 47 ++++ pkg/conjur/conjur_test.go | 398 +++++++++++++++++++++++++++++++++ pkg/conjur/creds.go | 455 ++++++++++++++++++++++++++++++++++++++ pkg/conjur/option.go | 99 +++++++++ pkg/conjur/telemetry.go | 34 +++ pkg/credential/equal.go | 6 +- pkg/credential/result.go | 8 +- 7 files changed, 1042 insertions(+), 5 deletions(-) create mode 100644 pkg/conjur/conjur_test.go create mode 100644 pkg/conjur/creds.go create mode 100644 pkg/conjur/option.go create mode 100644 pkg/conjur/telemetry.go diff --git a/README.md b/README.md index a2b05c1..f3ae63f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This library provides a unified interface for obtaining and refreshing credentia * [OAuth2 Authorization Code](#oauth2-authorization-code-flow-provider) * [OAuth2 Client Credentials](#oauth2-client-credentials-flow-provider) * [Vault Provider](#vault-credentials-provider) + * [Conjur Provider](#conjur-credentials-provider) * [GitHub App Provider](#github-app-credentials-provider) * [Channel Behavior](#channel-behavior) * [License](#license) @@ -43,6 +44,7 @@ This library provides a unified interface for obtaining and refreshing credentia - **OAuth2AC:** Obtains access tokens through OAuth2 authorization code flow and refreshes them before expiration - **OAuth2CC:** Obtains access tokens through OAuth2 client credentials flow and refreshes them before expiration - **Vault** Exchanges ID tokens for secrets from Vault using Vault's JWT authentication. +- **Conjur:** Exchanges ID tokens for secrets from CyberArk Conjur (Secrets Manager Self-Hosted and SaaS) using Conjur's JWT authenticator, and polls for rotated values. - **GitHub App:** Mints GitHub App installation access tokens by signing a JWT with the App's private key and refreshes them before expiration. ## Installation @@ -746,6 +748,51 @@ go func() { // wg.Wait() ``` +### Conjur Credentials Provider + +Configure a [JWT authenticator](https://docs.cyberark.com/secrets-manager-sh/latest/en/content/operations/services/cjr-authn-jwt.htm) in Conjur that trusts your identity token issuer (`jwks-uri` or `provider-uri`, `issuer`, `audience`), then read variables with it. All variables are read in one batch request and published as a `*credential.Secret` under the keys you choose. + +```go +import ( + "go.riptides.io/tokenex/pkg/conjur" + "go.riptides.io/tokenex/pkg/credential" +) + +// tlsConfig may be nil; set RootCAs when Conjur's certificate is issued by a private CA. +// For Secrets Manager SaaS, use "https://.secretsmgr.cyberark.cloud/api" and account "conjur". +conjurProvider, err := conjur.NewCredentialsProvider(ctx, logger, "https://conjur.example.com", tlsConfig) +if err != nil { + return err +} + +credsChan, err := conjurProvider.GetCredentials( + ctx, + idTokenProvider, + conjur.WithAccount("myorg"), + conjur.WithServiceID("riptides"), // authn-jwt/ + conjur.WithVariables(map[string]string{ + "username": "prod/db/username", + "password": "prod/db/password", + }), + // Only needed when the authenticator has no token-app-property: + // conjur.WithHostID("apps/myapp"), +) +if err != nil { + return err +} + +for creds := range credsChan { + if creds.Err != nil { + return creds.Err + } + + secret := creds.Credential.(*credential.Secret) + logger.Info("Database username", "value", secret.Data["username"]) +} +``` + +Conjur variables have no lease, so the provider reads them again every 15 minutes by default (`conjur.WithPollInterval`). + ### GitHub App Credentials Provider ```go diff --git a/pkg/conjur/conjur_test.go b/pkg/conjur/conjur_test.go new file mode 100644 index 0000000..3f26dd3 --- /dev/null +++ b/pkg/conjur/conjur_test.go @@ -0,0 +1,398 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package conjur //nolint:testpackage // Tests use unexported functions + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.riptides.io/tokenex/pkg/credential" + "go.riptides.io/tokenex/pkg/option" +) + +const ( + testAccount = "myorg" + testServiceID = "riptides" + testJWT = "header.payload.signature" + testAccessToken = `{"protected":"p","payload":"x","signature":"s"}` + + usernameKey = "username" + passwordKey = "password" + usernameID = "db/username" + passwordID = "db/password" + testUsername = "admin" + testPassword = "p@ss,wörd\n\x00" +) + +type testTokenProvider struct { + token string +} + +func (p testTokenProvider) GetToken(_ context.Context, _ ...option.Option) (credential.Token, error) { + return credential.Token{Token: p.token, ExpiresAt: time.Now().Add(time.Hour)}, nil +} + +// fakeConjur serves the JWT authenticator and batch secrets endpoints under prefix. +type fakeConjur struct { + t *testing.T + prefix string + hostPath string // escaped host segment expected in the authenticate URL, if any + base64 bool // whether Accept-Encoding: base64 is honored + + mu sync.Mutex + values map[string]string // variable ID -> value +} + +func (f *fakeConjur) setValue(id, value string) { + f.mu.Lock() + defer f.mu.Unlock() + + f.values[id] = value +} + +func (f *fakeConjur) ServeHTTP(w http.ResponseWriter, r *http.Request) { + authnPath := f.prefix + "/authn-jwt/" + testServiceID + "/" + testAccount + if f.hostPath != "" { + authnPath += "/" + f.hostPath + } + authnPath += "/authenticate" + + switch r.URL.EscapedPath() { + case authnPath: + f.authenticate(w, r) + case f.prefix + "/secrets": + f.secrets(w, r) + default: + http.NotFound(w, r) + } +} + +func (f *fakeConjur) authenticate(w http.ResponseWriter, r *http.Request) { + assert.Equal(f.t, http.MethodPost, r.Method) + assert.Equal(f.t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + + if err := r.ParseForm(); err != nil || r.PostForm.Get("jwt") != testJWT { + w.WriteHeader(http.StatusUnauthorized) + + return + } + + io.WriteString(w, testAccessToken) //nolint:errcheck +} + +func (f *fakeConjur) secrets(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + + wantAuth := `Token token="` + base64.StdEncoding.EncodeToString([]byte(testAccessToken)) + `"` + if r.Header.Get("Authorization") != wantAuth { + w.WriteHeader(http.StatusUnauthorized) + + return + } + + assert.Equal(f.t, "base64", r.Header.Get("Accept-Encoding")) + + fullIDs := strings.Split(r.URL.Query().Get("variable_ids"), ",") + assert.True(f.t, slices.IsSorted(fullIDs), "variable IDs are sorted") + assert.Len(f.t, slices.Compact(slices.Clone(fullIDs)), len(fullIDs), "variable IDs are distinct") + + resp := map[string]string{} + for _, fullID := range fullIDs { + id, _ := strings.CutPrefix(fullID, testAccount+":variable:") + + value, ok := f.values[id] + if !ok { + w.WriteHeader(http.StatusNotFound) + io.WriteString(w, `{"error":{"code":"not_found","message":"CONJ00076E Variable `+fullID+` is empty or not found."}}`) //nolint:errcheck + + return + } + + if f.base64 { + value = base64.StdEncoding.EncodeToString([]byte(value)) + } + resp[fullID] = value + } + + if f.base64 { + w.Header().Set("Content-Encoding", "base64") + } + w.Header().Set("Content-Type", "application/json") + assert.NoError(f.t, json.NewEncoder(w).Encode(resp)) +} + +func newFakeConjur(t *testing.T) *fakeConjur { + t.Helper() + + return &fakeConjur{ + t: t, + base64: true, + values: map[string]string{ + usernameID: testUsername, + passwordID: testPassword, + }, + } +} + +func startProvider(t *testing.T, applianceURL string, tlsConfig *tls.Config, opts ...option.Option) <-chan credential.Result { + t.Helper() + + cp, err := NewCredentialsProvider(t.Context(), logr.Discard(), applianceURL, tlsConfig) + require.NoError(t, err) + + opts = append([]option.Option{ + WithAccount(testAccount), + WithServiceID(testServiceID), + WithIdentityTokenProvider(testTokenProvider{token: testJWT}), + }, opts...) + + ch, err := cp.GetCredentialsWithOptions(t.Context(), opts...) + require.NoError(t, err) + + return ch +} + +func receive(t *testing.T, ch <-chan credential.Result) credential.Result { + t.Helper() + + select { + case res, ok := <-ch: + require.True(t, ok, "channel closed") + + return res + case <-time.After(5 * time.Second): + require.FailNow(t, "timed out waiting for a result") + } + + return credential.Result{} +} + +func requireSecret(t *testing.T, res credential.Result) map[string]any { + t.Helper() + + require.NoError(t, res.Err) + require.Equal(t, credential.UpdateEventType, res.Event) + + secret, ok := res.Credential.(*credential.Secret) + require.True(t, ok, "credential is a *credential.Secret") + + return secret.Data +} + +func TestGetCredentials_Success(t *testing.T) { + t.Parallel() + + // SaaS serves the API under /api. + fake := newFakeConjur(t) + fake.prefix = "/api" + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + + ch := startProvider(t, srv.URL+"/api/", nil, WithVariables(map[string]string{ + usernameKey: usernameID, + "user": usernameID, + passwordKey: passwordID, + })) + + assert.Equal(t, map[string]any{ + usernameKey: testUsername, + "user": testUsername, + passwordKey: testPassword, + }, requireSecret(t, receive(t, ch))) +} + +func TestGetCredentials_PlainValues(t *testing.T) { + t.Parallel() + + fake := newFakeConjur(t) + fake.base64 = false + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + + ch := startProvider(t, srv.URL, nil, WithVariables(map[string]string{usernameKey: usernameID})) + + assert.Equal(t, map[string]any{usernameKey: testUsername}, requireSecret(t, receive(t, ch))) +} + +func TestGetCredentials_HostID(t *testing.T) { + t.Parallel() + + for _, hostID := range []string{"apps/myapp", "host/apps/myapp"} { + t.Run(hostID, func(t *testing.T) { + t.Parallel() + + fake := newFakeConjur(t) + fake.hostPath = "host%2Fapps%2Fmyapp" + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + + ch := startProvider(t, srv.URL, nil, WithHostID(hostID), WithVariables(map[string]string{usernameKey: usernameID})) + + assert.Equal(t, map[string]any{usernameKey: testUsername}, requireSecret(t, receive(t, ch))) + }) + } +} + +func TestGetCredentials_PollsForRotation(t *testing.T) { + t.Parallel() + + fake := newFakeConjur(t) + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + + ch := startProvider(t, srv.URL, nil, WithPollInterval(20*time.Millisecond), WithVariables(map[string]string{passwordKey: passwordID})) + + assert.Equal(t, testPassword, requireSecret(t, receive(t, ch))[passwordKey]) + + fake.setValue(passwordID, "rotated") + + var password any + for password != "rotated" { + password = requireSecret(t, receive(t, ch))[passwordKey] + } +} + +func TestGetCredentials_AuthenticationFailure(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(newFakeConjur(t)) + t.Cleanup(srv.Close) + + ch := startProvider(t, srv.URL, nil, + WithIdentityTokenProvider(testTokenProvider{token: "wrong.jwt.token"}), + WithVariables(map[string]string{usernameKey: usernameID}), + ) + + res := receive(t, ch) + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "failed to authenticate with Conjur") + assert.Contains(t, res.Err.Error(), "401") + assert.Nil(t, res.Credential) + + _, ok := <-ch + assert.False(t, ok, "channel is closed after an error") +} + +func TestGetCredentials_VariableNotFound(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(newFakeConjur(t)) + t.Cleanup(srv.Close) + + ch := startProvider(t, srv.URL, nil, WithVariables(map[string]string{usernameKey: usernameID, "key": "db/missing"})) + + res := receive(t, ch) + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "404") + assert.Contains(t, res.Err.Error(), "CONJ00076E Variable myorg:variable:db/missing is empty or not found.") +} + +func TestGetCredentials_PrivateCA(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(newFakeConjur(t)) + t.Cleanup(srv.Close) + + vars := WithVariables(map[string]string{usernameKey: usernameID}) + + t.Run("untrusted", func(t *testing.T) { + t.Parallel() + + res := receive(t, startProvider(t, srv.URL, nil, vars)) + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "certificate") + }) + + t.Run("trusted", func(t *testing.T) { + t.Parallel() + + pool := x509.NewCertPool() + pool.AddCert(srv.Certificate()) + + res := receive(t, startProvider(t, srv.URL, &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, vars)) + assert.Equal(t, map[string]any{usernameKey: testUsername}, requireSecret(t, res)) + }) +} + +func TestGetCredentialsWithOptions_InvalidConfig(t *testing.T) { + t.Parallel() + + cp, err := NewCredentialsProvider(t.Context(), logr.Discard(), "https://conjur.example.com", nil) + require.NoError(t, err) + + _, err = cp.GetCredentialsWithOptions(t.Context(), WithAccount(testAccount), WithServiceID(testServiceID)) + require.Error(t, err) +} + +func TestValidateConfig(t *testing.T) { + t.Parallel() + + valid := func() *credentialsConfig { + return &credentialsConfig{ + account: testAccount, + serviceID: testServiceID, + variables: map[string]string{passwordKey: passwordID}, + pollInterval: time.Minute, + identityTokenProvider: testTokenProvider{}, + } + } + + tests := []struct { + name string + mutate func(*credentialsConfig) + wantErr string + }{ + {name: "valid", mutate: func(*credentialsConfig) {}}, + {name: "missing account", mutate: func(c *credentialsConfig) { c.account = "" }, wantErr: "account is required"}, + {name: "dot-dot account", mutate: func(c *credentialsConfig) { c.account = ".." }, wantErr: "account must not be"}, + {name: "missing service ID", mutate: func(c *credentialsConfig) { c.serviceID = "" }, wantErr: "service ID is required"}, + {name: "no variables", mutate: func(c *credentialsConfig) { c.variables = nil }, wantErr: "at least one variable"}, + {name: "empty key", mutate: func(c *credentialsConfig) { c.variables = map[string]string{"": passwordID} }, wantErr: "key must not be empty"}, + {name: "empty variable ID", mutate: func(c *credentialsConfig) { c.variables = map[string]string{passwordKey: ""} }, wantErr: "ID must not be empty"}, + {name: "comma in variable ID", mutate: func(c *credentialsConfig) { c.variables = map[string]string{passwordKey: "a,b"} }, wantErr: "comma"}, + {name: "zero poll interval", mutate: func(c *credentialsConfig) { c.pollInterval = 0 }, wantErr: "poll interval"}, + {name: "missing token provider", mutate: func(c *credentialsConfig) { c.identityTokenProvider = nil }, wantErr: "identity token provider"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := valid() + tt.mutate(cfg) + + err := validateConfig(cfg) + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestNewCredentialsProvider_InvalidURL(t *testing.T) { + t.Parallel() + + for _, u := range []string{"", "conjur.example.com", "ftp://conjur.example.com", "https://conjur.example.com?x=1", "https://conjur.example.com#x"} { + _, err := NewCredentialsProvider(t.Context(), logr.Discard(), u, nil) + assert.Error(t, err, u) + } +} diff --git a/pkg/conjur/creds.go b/pkg/conjur/creds.go new file mode 100644 index 0000000..df1bb0a --- /dev/null +++ b/pkg/conjur/creds.go @@ -0,0 +1,455 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +// Package conjur retrieves secrets from CyberArk Conjur (CyberArk Secrets Manager, Self-Hosted and SaaS). +// +// The provider exchanges an identity JWT for a short-lived Conjur access token using the JWT +// authenticator (authn-jwt), then reads the configured variables in one batch request. Conjur +// variables carry no lease, so they are read again every poll interval to pick up rotated values. +package conjur + +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "io" + "maps" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "emperror.dev/errors" + "github.com/go-logr/logr" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "go.riptides.io/tokenex/pkg/credential" + "go.riptides.io/tokenex/pkg/option" + tokenextelemetry "go.riptides.io/tokenex/pkg/telemetry" + "go.riptides.io/tokenex/pkg/token" + "go.riptides.io/tokenex/pkg/util" +) + +const ( + // maxResponseSize bounds how much of a Conjur response body is read. + maxResponseSize = 16 << 20 + + // maxErrorMessageLength bounds how much of a non-JSON error body ends up in an error. + maxErrorMessageLength = 256 +) + +// CredentialsProvider defines the interface for obtaining secrets from Conjur. +type CredentialsProvider interface { + // GetCredentials exchanges an ID token for a Conjur access token and retrieves the configured variables. + // The channel receives an Update event with a *credential.Secret for the first read and each poll. + // In case of errors, the Err field is populated, Credential is nil, and the refresh loop exits. + // When the refresh loop exits, the channel is closed. + GetCredentials(ctx context.Context, tokenProvider token.IdentityTokenProvider, opts ...option.Option) (<-chan credential.Result, error) +} + +var _ CredentialsProvider = &credentialsProvider{} + +type credentialsConfig struct { + account string + serviceID string + hostID string + variables map[string]string + pollInterval time.Duration + identityTokenProvider token.IdentityTokenProvider + + tracerProvider trace.TracerProvider +} + +type credentialsProvider struct { + logger logr.Logger + applianceURL string + httpClient *http.Client +} + +type Provider interface { + isConjur() +} + +func (cp *credentialsProvider) isConjur() {} + +func setDefaults(cfg *credentialsConfig) { + if cfg.pollInterval == 0 { + cfg.pollInterval = 15 * time.Minute + } +} + +func validateConfig(cfg *credentialsConfig) error { + if err := validatePathSegment("account", cfg.account); err != nil { + return err + } + + if err := validatePathSegment("service ID", cfg.serviceID); err != nil { + return err + } + + if len(cfg.variables) == 0 { + return errors.New("at least one variable is required") + } + + for key, id := range cfg.variables { + if key == "" { + return errors.NewWithDetails("variable key must not be empty", "variable_id", id) + } + + if id == "" { + return errors.NewWithDetails("variable ID must not be empty", "key", key) + } + + // The batch retrieval endpoint separates variable IDs with commas. + if strings.Contains(id, ",") { + return errors.NewWithDetails("variable ID must not contain a comma", "variable_id", id) + } + } + + if cfg.pollInterval <= 0 { + return errors.New("poll interval must be greater than zero") + } + + if cfg.identityTokenProvider == nil { + return errors.New("identity token provider must be specified") + } + + return nil +} + +// validatePathSegment rejects values that would not address a single URL path segment. +func validatePathSegment(name string, value string) error { + if value == "" { + return errors.Errorf("%s is required", name) + } + + if value == "." || value == ".." { + return errors.Errorf("%s must not be %q", name, value) + } + + return nil +} + +// hostLogin returns the Conjur login of a host, which carries a "host/" prefix. +func hostLogin(hostID string) string { + if strings.HasPrefix(hostID, "host/") { + return hostID + } + + return "host/" + hostID +} + +// variableIDs returns the distinct variable IDs in sorted order. +func variableIDs(variables map[string]string) []string { + ids := slices.Collect(maps.Values(variables)) + slices.Sort(ids) + + return slices.Compact(ids) +} + +// do sends req and returns the response body and headers. A non-2xx status is returned as an +// error carrying Conjur's error message. +func (cp *credentialsProvider) do(req *http.Request) ([]byte, http.Header, error) { + resp, err := cp.httpClient.Do(req) + if err != nil { + return nil, nil, errors.WrapIf(err, "request failed") + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1)) + if err != nil { + return nil, nil, errors.WrapIf(err, "failed to read response body") + } + + if len(body) > maxResponseSize { + return nil, nil, errors.Errorf("response body exceeds %d bytes", maxResponseSize) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + if msg := errorMessage(body); msg != "" { + return nil, nil, errors.Errorf("unexpected status %s: %s", resp.Status, msg) + } + + return nil, nil, errors.Errorf("unexpected status %s", resp.Status) + } + + return body, resp.Header, nil +} + +// errorMessage extracts the message of a Conjur error response ({"error":{"code":...,"message":...}}), +// falling back to the start of the raw body. +func errorMessage(body []byte) string { + var resp struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + + if json.Unmarshal(body, &resp) == nil && resp.Error.Message != "" { + return resp.Error.Message + } + + msg := strings.TrimSpace(string(body)) + if len(msg) > maxErrorMessageLength { + msg = msg[:maxErrorMessageLength] + } + + return msg +} + +// authenticate exchanges an ID token for a Conjur access token using the JWT authenticator. +// The access token is returned base64 encoded, the form it takes in the Authorization header. +func (cp *credentialsProvider) authenticate(ctx context.Context, cfg *credentialsConfig) (string, error) { + idToken, err := cfg.identityTokenProvider.GetToken(ctx) + if err != nil { + return "", errors.WrapIf(err, "failed to get ID token") + } + + trace.SpanFromContext(ctx).SetAttributes(tokenextelemetry.IdentityTokenAttrs("id_token", idToken.Token, idToken.ExpiresAt)...) + + authnURL := cp.applianceURL + "/authn-jwt/" + url.PathEscape(cfg.serviceID) + "/" + url.PathEscape(cfg.account) + if cfg.hostID != "" { + authnURL += "/" + url.PathEscape(hostLogin(cfg.hostID)) + } + authnURL += "/authenticate" + + body := url.Values{"jwt": {idToken.Token}}.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, authnURL, strings.NewReader(body)) + if err != nil { + return "", errors.WrapIf(err, "failed to create authentication request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + accessToken, _, err := cp.do(req) + if err != nil { + return "", errors.WrapIfWithDetails(err, "failed to authenticate with Conjur", "service_id", cfg.serviceID, "account", cfg.account) + } + + if len(accessToken) == 0 { + return "", errors.New("empty access token returned by Conjur") + } + + return base64.StdEncoding.EncodeToString(accessToken), nil +} + +// retrieveVariables reads all configured variables in one batch request and returns their values +// keyed by variable ID. +func (cp *credentialsProvider) retrieveVariables(ctx context.Context, cfg *credentialsConfig, accessToken string) (map[string]string, error) { + ids := variableIDs(cfg.variables) + + fullIDs := make([]string, len(ids)) + for i, id := range ids { + fullIDs[i] = cfg.account + ":variable:" + id + } + + secretsURL := cp.applianceURL + "/secrets?variable_ids=" + url.QueryEscape(strings.Join(fullIDs, ",")) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, secretsURL, nil) + if err != nil { + return nil, errors.WrapIf(err, "failed to create secrets request") + } + req.Header.Set("Authorization", `Token token="`+accessToken+`"`) + // Ask for base64 encoded values so binary secrets survive the JSON response. + req.Header.Set("Accept-Encoding", "base64") + + body, header, err := cp.do(req) + if err != nil { + return nil, errors.WrapIfWithDetails(err, "failed to retrieve variables", "variable_ids", ids) + } + + var byFullID map[string]string + if err := json.Unmarshal(body, &byFullID); err != nil { + return nil, errors.WrapIf(err, "failed to parse secrets response") + } + + // Conjur versions without base64 support ignore Accept-Encoding and return the values as they are. + decode := header.Get("Content-Encoding") == "base64" + + values := make(map[string]string, len(ids)) + for i, id := range ids { + v, ok := byFullID[fullIDs[i]] + if !ok { + return nil, errors.NewWithDetails("variable missing from secrets response", "variable_id", id) + } + + if decode { + b, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return nil, errors.WrapIfWithDetails(err, "failed to decode variable value", "variable_id", id) + } + v = string(b) + } + + values[id] = v + } + + return values, nil +} + +// retrieveSecret authenticates to Conjur and returns the configured variables under their keys. +func (cp *credentialsProvider) retrieveSecret(ctx context.Context, cfg *credentialsConfig) (*credential.Secret, error) { + accessToken, err := cp.authenticate(ctx, cfg) + if err != nil { + return nil, err + } + + values, err := cp.retrieveVariables(ctx, cfg, accessToken) + if err != nil { + return nil, err + } + + data := make(map[string]any, len(cfg.variables)) + for key, id := range cfg.variables { + data[key] = values[id] + } + + return &credential.Secret{Data: data}, nil +} + +func fetchCredentials(ctx context.Context, tracer trace.Tracer, configAttrs []attribute.KeyValue, cp *credentialsProvider, cfg *credentialsConfig) (*credential.Secret, error) { + ctx, span := tracer.Start(ctx, fetchSpanName, trace.WithAttributes(configAttrs...)) + defer span.End() + + secret, err := cp.retrieveSecret(ctx, cfg) + if err != nil { + tokenextelemetry.RecordResult(span, err) + + return nil, err + } + + span.SetAttributes(fetchSpanResultAttrs()...) + tokenextelemetry.RecordResult(span, nil) + + return secret, nil +} + +// refreshCredentialsLoop reads the variables every poll interval until the context is canceled or a read fails. +func (cp *credentialsProvider) refreshCredentialsLoop(ctx context.Context, cfg *credentialsConfig, credsChan chan credential.Result) { + tracer := tokenextelemetry.Tracer(ctx, cfg.tracerProvider, instrumentationScopeName) + configAttrs := fetchSpanConfigAttrs(cfg, cp.applianceURL, uuid.NewString()) + + logger := cp.logger.WithValues("service_id", cfg.serviceID, "variable_ids", variableIDs(cfg.variables)) + + for { + select { + case <-ctx.Done(): + logger.V(1).Info("Context cancelled, stopping credential refresh") + + return + default: + } + + secret, err := fetchCredentials(ctx, tracer, configAttrs, cp, cfg) + if err != nil { + util.SendErrorToChannel(credsChan, err) + + return + } + + util.SendToChannel(credsChan, credential.Result{ + Credential: secret, + Event: credential.UpdateEventType, + }) + logger.V(2).Info("Published Conjur secret", "nextPollIn", cfg.pollInterval) + + select { + case <-ctx.Done(): + logger.V(1).Info("Context cancelled, stopping credential refresh") + + return + case <-time.After(cfg.pollInterval): + } + } +} + +// NewCredentialsProvider creates a CredentialsProvider for the Conjur instance at applianceURL, +// e.g. "https://conjur.example.com" for Self-Hosted or "https://.secretsmgr.cyberark.cloud/api" +// for SaaS. tlsConfig is optional; set its RootCAs when Conjur's certificate is issued by a private CA. +func NewCredentialsProvider(_ context.Context, logger logr.Logger, applianceURL string, tlsConfig *tls.Config) (*credentialsProvider, error) { + u, err := url.Parse(applianceURL) + if err != nil { + return nil, errors.WrapIfWithDetails(err, "invalid Conjur appliance URL", "url", applianceURL) + } + + if (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" { + return nil, errors.NewWithDetails("Conjur appliance URL must be an absolute http(s) URL without query or fragment", "url", applianceURL) + } + + transport := &http.Transport{Proxy: http.ProxyFromEnvironment} + if t, ok := http.DefaultTransport.(*http.Transport); ok { + transport = t.Clone() + } + + if tlsConfig != nil { + transport.TLSClientConfig = tlsConfig.Clone() + } + + return &credentialsProvider{ + logger: logger.WithName("conjur_credentials"), + applianceURL: strings.TrimSuffix(applianceURL, "/"), + httpClient: &http.Client{Transport: transport, Timeout: 30 * time.Second}, + }, nil +} + +// GetCredentialsWithOptions returns Conjur secrets using the provided options. +// This method implements the credential.Provider interface. +// +// Required options: WithAccount, WithServiceID, WithVariables, WithIdentityTokenProvider. +func (cp *credentialsProvider) GetCredentialsWithOptions(ctx context.Context, opts ...option.Option) (<-chan credential.Result, error) { + cfg := &credentialsConfig{} + setDefaults(cfg) + + for _, opt := range opts { + if opt, ok := isCredentialsOption(opt); ok { + opt.Apply(cfg) + } + } + + if err := validateConfig(cfg); err != nil { + return nil, err + } + + return cp.GetCredentials(ctx, cfg.identityTokenProvider, opts...) +} + +// GetCredentials exchanges an ID token for Conjur secrets and returns a channel to receive them. +func (cp *credentialsProvider) GetCredentials(ctx context.Context, tokenProvider token.IdentityTokenProvider, opts ...option.Option) (<-chan credential.Result, error) { + cfg := &credentialsConfig{} + setDefaults(cfg) + + cfg.identityTokenProvider = tokenProvider + + for _, opt := range opts { + if opt, ok := isCredentialsOption(opt); ok { + opt.Apply(cfg) + } + } + + if err := validateConfig(cfg); err != nil { + return nil, err + } + + // Validate that we can get an initial token + t, err := cfg.identityTokenProvider.GetToken(ctx) + if err != nil { + return nil, errors.WrapIf(err, "failed to get initial ID token") + } + + if t.ExpiresAt.Before(time.Now()) { + return nil, errors.NewWithDetails("initial ID token is already expired", "expiry", t.ExpiresAt) + } + + credsChan := make(chan credential.Result, 1) + + go func() { + defer close(credsChan) + cp.refreshCredentialsLoop(ctx, cfg, credsChan) + }() + + return credsChan, nil +} diff --git a/pkg/conjur/option.go b/pkg/conjur/option.go new file mode 100644 index 0000000..8891630 --- /dev/null +++ b/pkg/conjur/option.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package conjur + +import ( + "maps" + "time" + + "go.opentelemetry.io/otel/trace" + + "go.riptides.io/tokenex/pkg/option" + "go.riptides.io/tokenex/pkg/token" +) + +type ( + CredentialsOption interface { + Apply(*credentialsConfig) + } + credentialsOption struct { + option.Option + + f func(*credentialsConfig) + } +) + +func (o *credentialsOption) Apply(c *credentialsConfig) { + o.f(c) +} + +func withCredentialsOption(f func(*credentialsConfig)) option.Option { + return &credentialsOption{option.OptionImpl{}, f} +} + +func isCredentialsOption(opt any) (CredentialsOption, bool) { + if o, ok := opt.(*credentialsOption); ok { + return o, ok + } + + return nil, false +} + +// WithAccount sets the Conjur account. This option is required. +// It is "conjur" for CyberArk Secrets Manager SaaS. +func WithAccount(account string) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.account = account + }) +} + +// WithServiceID sets the service ID of the JWT authenticator, the in authn-jwt/. +// This option is required. +func WithServiceID(serviceID string) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.serviceID = serviceID + }) +} + +// WithHostID sets the Conjur host to authenticate as. The "host/" prefix is added if missing. +// Leave it unset when the authenticator reads the identity from the JWT through token-app-property. +func WithHostID(hostID string) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.hostID = hostID + }) +} + +// WithVariables sets the Conjur variables to retrieve, keyed by the name each value gets in the +// returned Secret. For example {"username": "prod/db/username", "password": "prod/db/password"}. +// This option is required. All variables are read in one batch request, so a rotated pair is +// never returned half updated. +func WithVariables(variables map[string]string) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.variables = maps.Clone(variables) + }) +} + +// WithIdentityTokenProvider sets the provider of the JWT presented to the JWT authenticator. +// This option is required. +func WithIdentityTokenProvider(idtp token.IdentityTokenProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.identityTokenProvider = idtp + }) +} + +// WithPollInterval sets how often the variables are read again to pick up rotated values. +// If not set, it defaults to 15 minutes. +func WithPollInterval(d time.Duration) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.pollInterval = d + }) +} + +// WithTracerProvider sets the OTel TracerProvider used to emit credential.fetch spans. +// If not set, the tracer falls back to the current span's TracerProvider (if any), then the OTel global TracerProvider. +func WithTracerProvider(tracerProvider trace.TracerProvider) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.tracerProvider = tracerProvider + }) +} diff --git a/pkg/conjur/telemetry.go b/pkg/conjur/telemetry.go new file mode 100644 index 0000000..0d59a06 --- /dev/null +++ b/pkg/conjur/telemetry.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Riptides Labs, Inc. +// SPDX-License-Identifier: MIT + +package conjur + +import ( + "go.opentelemetry.io/otel/attribute" +) + +const fetchSpanName = "credential.fetch" + +const instrumentationScopeName = "go.riptides.io/tokenex/pkg/conjur" + +func fetchSpanConfigAttrs(cfg *credentialsConfig, applianceURL string, correlationID string) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.String("cfg.appliance_url", applianceURL), + attribute.String("cfg.account", cfg.account), + attribute.String("cfg.service_id", cfg.serviceID), + attribute.StringSlice("cfg.variable_ids", variableIDs(cfg.variables)), + attribute.String("correlation_id", correlationID), + } + + if cfg.hostID != "" { + attrs = append(attrs, attribute.String("cfg.host_id", hostLogin(cfg.hostID))) + } + + return attrs +} + +func fetchSpanResultAttrs() []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.Bool("credential.expires", false), + } +} diff --git a/pkg/credential/equal.go b/pkg/credential/equal.go index 529b69b..f028722 100644 --- a/pkg/credential/equal.go +++ b/pkg/credential/equal.go @@ -19,8 +19,8 @@ func Equal(a, b Credential) bool { b, ok := b.(*Token) return ok && a.IsEqual(b) - case *VaultSecret: - b, ok := b.(*VaultSecret) + case *Secret: + b, ok := b.(*Secret) return ok && a.IsEqual(b) default: @@ -88,6 +88,6 @@ func (a *AWSCreds) IsEqual(b *AWSCreds) bool { return true } -func (a *VaultSecret) IsEqual(b *VaultSecret) bool { +func (a *Secret) IsEqual(b *Secret) bool { return reflect.DeepEqual(a.Data, b.Data) } diff --git a/pkg/credential/result.go b/pkg/credential/result.go index 4412fc7..1c45622 100644 --- a/pkg/credential/result.go +++ b/pkg/credential/result.go @@ -70,8 +70,12 @@ type Token struct { func (*Token) isResultType() {} -type VaultSecret struct { +// Secret holds named secret values read from a secrets manager such as Vault or Conjur. +type Secret struct { Data map[string]any } -func (*VaultSecret) isResultType() {} +func (*Secret) isResultType() {} + +// VaultSecret is the Secret returned by the Vault provider. +type VaultSecret = Secret From b2a6b6c44c5199e9a66cbf9665068644add7b301 Mon Sep 17 00:00:00 2001 From: Nandor Kracser Date: Mon, 28 Sep 2026 11:44:31 +0200 Subject: [PATCH 2/3] refactor(conjur): inline HTTP handling like rfc8693 --- pkg/conjur/conjur_test.go | 1 - pkg/conjur/creds.go | 111 +++++++++++--------------------------- 2 files changed, 31 insertions(+), 81 deletions(-) diff --git a/pkg/conjur/conjur_test.go b/pkg/conjur/conjur_test.go index 3f26dd3..50b23a0 100644 --- a/pkg/conjur/conjur_test.go +++ b/pkg/conjur/conjur_test.go @@ -361,7 +361,6 @@ func TestValidateConfig(t *testing.T) { }{ {name: "valid", mutate: func(*credentialsConfig) {}}, {name: "missing account", mutate: func(c *credentialsConfig) { c.account = "" }, wantErr: "account is required"}, - {name: "dot-dot account", mutate: func(c *credentialsConfig) { c.account = ".." }, wantErr: "account must not be"}, {name: "missing service ID", mutate: func(c *credentialsConfig) { c.serviceID = "" }, wantErr: "service ID is required"}, {name: "no variables", mutate: func(c *credentialsConfig) { c.variables = nil }, wantErr: "at least one variable"}, {name: "empty key", mutate: func(c *credentialsConfig) { c.variables = map[string]string{"": passwordID} }, wantErr: "key must not be empty"}, diff --git a/pkg/conjur/creds.go b/pkg/conjur/creds.go index df1bb0a..aa1e8af 100644 --- a/pkg/conjur/creds.go +++ b/pkg/conjur/creds.go @@ -34,14 +34,6 @@ import ( "go.riptides.io/tokenex/pkg/util" ) -const ( - // maxResponseSize bounds how much of a Conjur response body is read. - maxResponseSize = 16 << 20 - - // maxErrorMessageLength bounds how much of a non-JSON error body ends up in an error. - maxErrorMessageLength = 256 -) - // CredentialsProvider defines the interface for obtaining secrets from Conjur. type CredentialsProvider interface { // GetCredentials exchanges an ID token for a Conjur access token and retrieves the configured variables. @@ -83,12 +75,12 @@ func setDefaults(cfg *credentialsConfig) { } func validateConfig(cfg *credentialsConfig) error { - if err := validatePathSegment("account", cfg.account); err != nil { - return err + if cfg.account == "" { + return errors.New("account is required") } - if err := validatePathSegment("service ID", cfg.serviceID); err != nil { - return err + if cfg.serviceID == "" { + return errors.New("service ID is required") } if len(cfg.variables) == 0 { @@ -121,19 +113,6 @@ func validateConfig(cfg *credentialsConfig) error { return nil } -// validatePathSegment rejects values that would not address a single URL path segment. -func validatePathSegment(name string, value string) error { - if value == "" { - return errors.Errorf("%s is required", name) - } - - if value == "." || value == ".." { - return errors.Errorf("%s must not be %q", name, value) - } - - return nil -} - // hostLogin returns the Conjur login of a host, which carries a "host/" prefix. func hostLogin(hostID string) string { if strings.HasPrefix(hostID, "host/") { @@ -151,56 +130,6 @@ func variableIDs(variables map[string]string) []string { return slices.Compact(ids) } -// do sends req and returns the response body and headers. A non-2xx status is returned as an -// error carrying Conjur's error message. -func (cp *credentialsProvider) do(req *http.Request) ([]byte, http.Header, error) { - resp, err := cp.httpClient.Do(req) - if err != nil { - return nil, nil, errors.WrapIf(err, "request failed") - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1)) - if err != nil { - return nil, nil, errors.WrapIf(err, "failed to read response body") - } - - if len(body) > maxResponseSize { - return nil, nil, errors.Errorf("response body exceeds %d bytes", maxResponseSize) - } - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - if msg := errorMessage(body); msg != "" { - return nil, nil, errors.Errorf("unexpected status %s: %s", resp.Status, msg) - } - - return nil, nil, errors.Errorf("unexpected status %s", resp.Status) - } - - return body, resp.Header, nil -} - -// errorMessage extracts the message of a Conjur error response ({"error":{"code":...,"message":...}}), -// falling back to the start of the raw body. -func errorMessage(body []byte) string { - var resp struct { - Error struct { - Message string `json:"message"` - } `json:"error"` - } - - if json.Unmarshal(body, &resp) == nil && resp.Error.Message != "" { - return resp.Error.Message - } - - msg := strings.TrimSpace(string(body)) - if len(msg) > maxErrorMessageLength { - msg = msg[:maxErrorMessageLength] - } - - return msg -} - // authenticate exchanges an ID token for a Conjur access token using the JWT authenticator. // The access token is returned base64 encoded, the form it takes in the Authorization header. func (cp *credentialsProvider) authenticate(ctx context.Context, cfg *credentialsConfig) (string, error) { @@ -225,9 +154,20 @@ func (cp *credentialsProvider) authenticate(ctx context.Context, cfg *credential } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - accessToken, _, err := cp.do(req) + resp, err := cp.httpClient.Do(req) if err != nil { - return "", errors.WrapIfWithDetails(err, "failed to authenticate with Conjur", "service_id", cfg.serviceID, "account", cfg.account) + return "", errors.WrapIf(err, "authentication request failed") + } + + defer resp.Body.Close() + + accessToken, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.WrapIf(err, "could not read authentication response body") + } + + if resp.StatusCode != http.StatusOK { + return "", errors.Errorf("failed to authenticate with Conjur, status %d: %s", resp.StatusCode, accessToken) } if len(accessToken) == 0 { @@ -257,9 +197,20 @@ func (cp *credentialsProvider) retrieveVariables(ctx context.Context, cfg *crede // Ask for base64 encoded values so binary secrets survive the JSON response. req.Header.Set("Accept-Encoding", "base64") - body, header, err := cp.do(req) + resp, err := cp.httpClient.Do(req) + if err != nil { + return nil, errors.WrapIf(err, "secrets request failed") + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.WrapIfWithDetails(err, "failed to retrieve variables", "variable_ids", ids) + return nil, errors.WrapIf(err, "could not read secrets response body") + } + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("failed to retrieve variables, status %d: %s", resp.StatusCode, body) } var byFullID map[string]string @@ -268,7 +219,7 @@ func (cp *credentialsProvider) retrieveVariables(ctx context.Context, cfg *crede } // Conjur versions without base64 support ignore Accept-Encoding and return the values as they are. - decode := header.Get("Content-Encoding") == "base64" + decode := resp.Header.Get("Content-Encoding") == "base64" values := make(map[string]string, len(ids)) for i, id := range ids { From 36fa50f8245061a440331677f3d22940290813ea Mon Sep 17 00:00:00 2001 From: Nandor Kracser Date: Mon, 28 Sep 2026 12:52:19 +0200 Subject: [PATCH 3/3] docs(conjur): clarify variable IDs and the initial token expiry error --- pkg/conjur/creds.go | 2 +- pkg/conjur/option.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/conjur/creds.go b/pkg/conjur/creds.go index aa1e8af..f71f337 100644 --- a/pkg/conjur/creds.go +++ b/pkg/conjur/creds.go @@ -392,7 +392,7 @@ func (cp *credentialsProvider) GetCredentials(ctx context.Context, tokenProvider } if t.ExpiresAt.Before(time.Now()) { - return nil, errors.NewWithDetails("initial ID token is already expired", "expiry", t.ExpiresAt) + return nil, errors.NewWithDetails("initial ID token is expired or has no expiry", "expiry", t.ExpiresAt) } credsChan := make(chan credential.Result, 1) diff --git a/pkg/conjur/option.go b/pkg/conjur/option.go index 8891630..d1985c7 100644 --- a/pkg/conjur/option.go +++ b/pkg/conjur/option.go @@ -66,6 +66,7 @@ func WithHostID(hostID string) option.Option { // WithVariables sets the Conjur variables to retrieve, keyed by the name each value gets in the // returned Secret. For example {"username": "prod/db/username", "password": "prod/db/password"}. +// Use plain variable IDs, not the fully qualified :variable: form. // This option is required. All variables are read in one batch request, so a rotated pair is // never returned half updated. func WithVariables(variables map[string]string) option.Option {