diff --git a/.sugarjar.yaml b/.sugarjar.yaml new file mode 100644 index 0000000..aaa3fff --- /dev/null +++ b/.sugarjar.yaml @@ -0,0 +1,13 @@ +on_push: [lint] +lint: + - name: yamllint + command: yamllint . + - name: golangci-lint + command: golangci-lint + - name: vet + command: go vet + - name: style + command: make style +unit: + - name: unit + command: make test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a0d903e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Prometheus uses GitHub to manage reviews of pull requests. + +For trivial fixes or improvements, open a pull request. For more involved +changes, first discuss the idea on the +[prometheus-developers mailing list](https://groups.google.com/g/prometheus-developers). + +Relevant coding style guidance includes the +[Go Code Review Comments](https://go.dev/wiki/CodeReviewComments). diff --git a/Makefile b/Makefile index fee95a7..2b2dca6 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +.DEFAULT_GOAL := test + include Makefile.common .PHONY: test diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..9c790c1 --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ +Cloudflare Access authentication for Prometheus Go components. +Copyright 2026 The Prometheus Authors diff --git a/README.md b/README.md new file mode 100644 index 0000000..df4b506 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# prometheus cfaccess roundtripper + +[![Go Reference](https://pkg.go.dev/badge/github.com/prometheus/cfaccess.svg)](https://pkg.go.dev/github.com/prometheus/cfaccess) + +`cfaccess` provides an `http.RoundTripper` that authenticates requests to +applications protected by [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/). +It uses cloudflared's browser-based login flow and stores tokens in cloudflared's +normal on-disk cache. + +This is a separate module from `github.com/prometheus/common` so that projects +which do not use Cloudflare Access do not inherit cloudflared's dependency tree. + +This module is considered internal to Prometheus, without any stability +guarantees for external usage. + +## Usage + +The target application is discovered lazily from the first request to each +host, because its Cloudflare Access audience is not known when the transport is +constructed. + +```go +transport := cfaccess.NewRoundTripper(http.DefaultTransport) +client := &http.Client{Transport: transport} +``` + +When using `prometheus/common/config`, the existing HTTP configuration format +can select Cloudflare Access authentication: + +```yaml +authorization: + type: cf-access +``` + +The consumer must prepare the configuration before passing it to common, then +conditionally install the Cloudflare Access transport: + +```go +cfg, enabled, err := cfaccess.PrepareHTTPClientConfig(cfg) +if err != nil { + return err +} + +transport, err := commonconfig.NewRoundTripperFromConfig(cfg, "example") +if err != nil { + return err +} +if enabled { + transport = cfaccess.NewRoundTripper(transport) +} +``` + +For a client created with `commonconfig.NewClientFromConfig`, wrap +`client.Transport` in the same way. + +## Interactive authentication + +If no valid token is cached, the first request opens the user's browser and +blocks until login completes. This behavior is intended for interactive tools, +not unattended servers. + +Cloudflared's token APIs do not currently accept a `context.Context`, so +cancelling the original request cannot interrupt an authentication operation +already in progress. The authenticated request is not sent if its context has +expired by the time authentication completes. diff --git a/cfaccess.go b/cfaccess.go new file mode 100644 index 0000000..abf67e9 --- /dev/null +++ b/cfaccess.go @@ -0,0 +1,181 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package cfaccess provides HTTP authentication for applications protected by +// Cloudflare Access. +package cfaccess + +import ( + "fmt" + "net/http" + "net/url" + "os" + "sync" + "time" + + "github.com/cloudflare/cloudflared/token" + "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog" +) + +const ( + // TokenHeader is the header Cloudflare Access checks for a JWT obtained + // through its browser-based login flow. + TokenHeader = "Cf-Access-Token" + + tokenExpiryMargin = 30 * time.Second +) + +var initializeCloudflared sync.Once + +type dependencies struct { + getAppInfo func(*url.URL) (*token.AppInfo, error) + fetchToken func(*url.URL, *token.AppInfo, *zerolog.Logger) (string, error) + now func() time.Time + logger *zerolog.Logger +} + +type app struct { + mtx sync.Mutex + info *token.AppInfo + token string + expires time.Time +} + +type roundTripper struct { + next http.RoundTripper + deps dependencies + + mtx sync.Mutex + apps map[string]*app +} + +// NewRoundTripper returns a RoundTripper that obtains a Cloudflare Access +// token for each target application and adds it to requests before passing +// them to next. If next is nil, http.DefaultTransport is used. +func NewRoundTripper(next http.RoundTripper) http.RoundTripper { + initializeCloudflared.Do(func() { + // cloudflared stores its User-Agent globally, so use one stable identity + // rather than allowing constructors to race with per-consumer values. + token.Init("prometheus-cfaccess") + }) + if next == nil { + next = http.DefaultTransport + } + + logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).With().Timestamp().Logger() + return newRoundTripper(next, dependencies{ + getAppInfo: token.GetAppInfo, + fetchToken: func(appURL *url.URL, info *token.AppInfo, logger *zerolog.Logger) (string, error) { + return token.FetchToken(appURL, info, false, false, logger) + }, + now: time.Now, + logger: &logger, + }) +} + +func newRoundTripper(next http.RoundTripper, deps dependencies) http.RoundTripper { + return &roundTripper{ + next: next, + deps: deps, + apps: make(map[string]*app), + } +} + +func (rt *roundTripper) appFor(key string) *app { + rt.mtx.Lock() + defer rt.mtx.Unlock() + + a, ok := rt.apps[key] + if !ok { + a = &app{} + rt.apps[key] = a + } + return a +} + +// RoundTrip implements http.RoundTripper. +func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if err := req.Context().Err(); err != nil { + return nil, err + } + + key := req.URL.Scheme + "://" + req.URL.Host + tok, err := rt.appFor(key).fetch(req.URL, rt.deps) + if err != nil { + return nil, fmt.Errorf("cloudflare access: %w", err) + } + if err := req.Context().Err(); err != nil { + return nil, err + } + + outgoing := req.Clone(req.Context()) + if outgoing.Header == nil { + outgoing.Header = make(http.Header) + } + outgoing.Header.Set(TokenHeader, tok) + return rt.next.RoundTrip(outgoing) +} + +func (rt *roundTripper) CloseIdleConnections() { + if ci, ok := rt.next.(interface{ CloseIdleConnections() }); ok { + ci.CloseIdleConnections() + } +} + +func (a *app) fetch(requestURL *url.URL, deps dependencies) (string, error) { + a.mtx.Lock() + defer a.mtx.Unlock() + + if a.token != "" && deps.now().Add(tokenExpiryMargin).Before(a.expires) { + return a.token, nil + } + + if a.info == nil { + // Cloudflared may retain or modify URLs passed to its APIs. Give each + // operation its own copy so neither it nor the request can affect the + // other. + discoveryURL := *requestURL + info, err := deps.getAppInfo(&discoveryURL) + if err != nil { + return "", fmt.Errorf("failed to detect Cloudflare Access application for %s://%s: %w", requestURL.Scheme, requestURL.Host, err) + } + a.info = info + } + + loginURL := *requestURL + tok, err := deps.fetchToken(&loginURL, a.info, deps.logger) + if err != nil { + return "", fmt.Errorf("failed to fetch Cloudflare Access token: %w", err) + } + + a.token = tok + a.expires = tokenExpiry(tok) + return tok, nil +} + +// tokenExpiry returns the expiry time encoded in the token's exp claim. The +// token was obtained directly from cloudflared, so its signature is not +// verified here. A token whose expiry cannot be read is refreshed on the next +// request. +func tokenExpiry(tok string) time.Time { + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(tok, claims); err != nil { + return time.Time{} + } + exp, err := claims.GetExpirationTime() + if err != nil || exp == nil { + return time.Time{} + } + return exp.Time +} diff --git a/cfaccess_test.go b/cfaccess_test.go new file mode 100644 index 0000000..4ec447b --- /dev/null +++ b/cfaccess_test.go @@ -0,0 +1,314 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cfaccess + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cloudflare/cloudflared/token" + "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func signedTestToken(t *testing.T, expiry time.Time) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "exp": jwt.NewNumericDate(expiry), + }) + signed, err := tok.SignedString([]byte("test-signing-key")) + require.NoError(t, err) + return signed +} + +func testDependencies( + t *testing.T, + now func() time.Time, + getAppInfo func(*url.URL) (*token.AppInfo, error), + fetchToken func(*url.URL, *token.AppInfo) (string, error), +) dependencies { + t.Helper() + logger := zerolog.Nop() + return dependencies{ + now: now, + getAppInfo: getAppInfo, + fetchToken: func(appURL *url.URL, info *token.AppInfo, _ *zerolog.Logger) (string, error) { + return fetchToken(appURL, info) + }, + logger: &logger, + } +} + +func TestTokenExpiry(t *testing.T) { + t.Parallel() + + t.Run("valid token", func(t *testing.T) { + t.Parallel() + expiry := time.Now().Add(time.Hour).Truncate(time.Second) + require.WithinDuration(t, expiry, tokenExpiry(signedTestToken(t, expiry)), time.Second) + }) + + t.Run("malformed token", func(t *testing.T) { + t.Parallel() + require.True(t, tokenExpiry("not-a-jwt").IsZero()) + }) + + t.Run("token without exp claim", func(t *testing.T) { + t.Parallel() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{}) + signed, err := tok.SignedString([]byte("test-signing-key")) + require.NoError(t, err) + require.True(t, tokenExpiry(signed).IsZero()) + }) +} + +func TestRoundTripper(t *testing.T) { + t.Parallel() + + fakeNow := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + var getAppInfoCalls atomic.Int32 + var fetchTokenCalls atomic.Int32 + shortLivedToken := signedTestToken(t, fakeNow.Add(time.Minute)) + longLivedToken := signedTestToken(t, fakeNow.Add(time.Hour)) + + deps := testDependencies(t, + func() time.Time { return fakeNow }, + func(requestURL *url.URL) (*token.AppInfo, error) { + getAppInfoCalls.Add(1) + info := &token.AppInfo{AuthDomain: "auth." + requestURL.Host, AppAUD: "aud", AppDomain: requestURL.Host} + requestURL.Path = "/mutated-by-discovery" + return info, nil + }, + func(appURL *url.URL, _ *token.AppInfo) (string, error) { + require.Equal(t, "/query", appURL.Path) + // cloudflared constructs the login endpoint by mutating this URL. + appURL.Path = "/cdn-cgi/access/cli" + appURL.RawQuery = "token=secret" + if fetchTokenCalls.Add(1) == 1 { + return shortLivedToken, nil + } + return longLivedToken, nil + }, + ) + + var gotRequest *http.Request + next := roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotRequest = req + return &http.Response{StatusCode: http.StatusOK}, nil + }) + rt := newRoundTripper(next, deps) + + req1, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + req1.Header.Set("X-Test", "preserved") + _, err = rt.RoundTrip(req1) + require.NoError(t, err) + require.Equal(t, "https://app.example.com/query", gotRequest.URL.String()) + require.Equal(t, shortLivedToken, gotRequest.Header.Get(TokenHeader)) + require.Equal(t, "preserved", gotRequest.Header.Get("X-Test")) + require.Empty(t, req1.Header.Get(TokenHeader)) + require.Equal(t, "https://app.example.com/query", req1.URL.String()) + require.EqualValues(t, 1, getAppInfoCalls.Load()) + require.EqualValues(t, 1, fetchTokenCalls.Load()) + + req2, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + _, err = rt.RoundTrip(req2) + require.NoError(t, err) + require.EqualValues(t, 1, getAppInfoCalls.Load()) + require.EqualValues(t, 1, fetchTokenCalls.Load()) + + req3, err := http.NewRequest(http.MethodGet, "https://other.example.com/query", http.NoBody) + require.NoError(t, err) + _, err = rt.RoundTrip(req3) + require.NoError(t, err) + require.Equal(t, longLivedToken, gotRequest.Header.Get(TokenHeader)) + require.EqualValues(t, 2, getAppInfoCalls.Load()) + require.EqualValues(t, 2, fetchTokenCalls.Load()) + + fakeNow = fakeNow.Add(time.Minute) + req4, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + _, err = rt.RoundTrip(req4) + require.NoError(t, err) + require.Equal(t, longLivedToken, gotRequest.Header.Get(TokenHeader)) + require.EqualValues(t, 2, getAppInfoCalls.Load()) + require.EqualValues(t, 3, fetchTokenCalls.Load()) +} + +func TestRoundTripperConcurrentRequestsShareToken(t *testing.T) { + t.Parallel() + + now := time.Now() + tok := signedTestToken(t, now.Add(time.Hour)) + var fetchTokenCalls atomic.Int32 + deps := testDependencies(t, + func() time.Time { return now }, + func(requestURL *url.URL) (*token.AppInfo, error) { + return &token.AppInfo{AuthDomain: "auth.example.com", AppAUD: "aud", AppDomain: requestURL.Host}, nil + }, + func(*url.URL, *token.AppInfo) (string, error) { + fetchTokenCalls.Add(1) + return tok, nil + }, + ) + + next := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got := req.Header.Get(TokenHeader); got != tok { + return nil, fmt.Errorf("unexpected access token %q", got) + } + return &http.Response{StatusCode: http.StatusOK}, nil + }) + rt := newRoundTripper(next, deps) + + var wg sync.WaitGroup + errs := make(chan error, 20) + for range 20 { + wg.Go(func() { + req, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + if err != nil { + errs <- err + return + } + _, err = rt.RoundTrip(req) + errs <- err + }) + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + require.EqualValues(t, 1, fetchTokenCalls.Load()) +} + +var errGetAppInfo = errors.New("get app info failed") + +func TestRoundTripperGetAppInfoError(t *testing.T) { + t.Parallel() + + deps := testDependencies(t, + time.Now, + func(*url.URL) (*token.AppInfo, error) { return nil, errGetAppInfo }, + func(*url.URL, *token.AppInfo) (string, error) { + t.Fatal("FetchToken must not be called when GetAppInfo fails") + return "", nil + }, + ) + next := roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("next RoundTripper must not be called when authentication fails") + return nil, nil + }) + rt := newRoundTripper(next, deps) + req, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + + _, err = rt.RoundTrip(req) + require.ErrorIs(t, err, errGetAppInfo) +} + +func TestRoundTripperDoesNotSendCancelledRequest(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + deps := testDependencies(t, + time.Now, + func(requestURL *url.URL) (*token.AppInfo, error) { + return &token.AppInfo{AuthDomain: "auth.example.com", AppAUD: "aud", AppDomain: requestURL.Host}, nil + }, + func(*url.URL, *token.AppInfo) (string, error) { + cancel() + return signedTestToken(t, time.Now().Add(time.Hour)), nil + }, + ) + next := roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("next RoundTripper must not receive a cancelled request") + return nil, nil + }) + rt := newRoundTripper(next, deps) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + + _, err = rt.RoundTrip(req) + require.ErrorIs(t, err, context.Canceled) +} + +func TestRoundTripperDoesNotAuthenticateCancelledRequest(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + deps := testDependencies(t, + time.Now, + func(*url.URL) (*token.AppInfo, error) { + t.Fatal("GetAppInfo must not receive a cancelled request") + return nil, nil + }, + func(*url.URL, *token.AppInfo) (string, error) { + t.Fatal("FetchToken must not receive a cancelled request") + return "", nil + }, + ) + rt := newRoundTripper(roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("next RoundTripper must not receive a cancelled request") + return nil, nil + }), deps) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + + _, err = rt.RoundTrip(req) + require.ErrorIs(t, err, context.Canceled) +} + +type closeIdleRoundTripper struct { + closed atomic.Bool +} + +func (*closeIdleRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK}, nil +} + +func (rt *closeIdleRoundTripper) CloseIdleConnections() { + rt.closed.Store(true) +} + +func TestRoundTripperClosesIdleConnections(t *testing.T) { + t.Parallel() + + next := &closeIdleRoundTripper{} + rt := newRoundTripper(next, dependencies{}).(*roundTripper) + rt.CloseIdleConnections() + require.True(t, next.closed.Load()) +} + +func TestNewRoundTripperDefaultsTransport(t *testing.T) { + t.Parallel() + + rt := NewRoundTripper(nil).(*roundTripper) + require.Same(t, http.DefaultTransport, rt.next) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..17d7fa5 --- /dev/null +++ b/config.go @@ -0,0 +1,53 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cfaccess + +import ( + "fmt" + "strings" + + commonconfig "github.com/prometheus/common/config" +) + +// AuthorizationType is the value of commonconfig.Authorization.Type that +// selects Cloudflare Access authentication. +const AuthorizationType = "cf-access" + +// PrepareHTTPClientConfig detects Cloudflare Access authentication in cfg and +// returns a copy suitable for commonconfig.NewClientFromConfig or +// commonconfig.NewRoundTripperFromConfig. When enabled is true, callers must +// wrap the resulting transport with NewRoundTripper. +// +// The returned configuration has Authorization removed so prometheus/common +// does not interpret cf-access as a literal HTTP Authorization scheme. cfg is +// never modified. +// +//nolint:gocritic // Passing by value is intentional: the returned copy can be sanitized without mutating cfg. +func PrepareHTTPClientConfig(cfg commonconfig.HTTPClientConfig) (clean commonconfig.HTTPClientConfig, enabled bool, err error) { + if cfg.Authorization == nil || !strings.EqualFold(strings.TrimSpace(cfg.Authorization.Type), AuthorizationType) { + return cfg, false, nil + } + + auth := cfg.Authorization + if string(auth.Credentials) != "" || auth.CredentialsFile != "" || auth.CredentialsRef != "" { + return cfg, false, fmt.Errorf("authorization credentials, credentials_file & credentials_ref must not be configured when authorization type is %q", AuthorizationType) + } + if cfg.BasicAuth != nil || cfg.OAuth2 != nil || string(cfg.BearerToken) != "" || cfg.BearerTokenFile != "" { + return cfg, false, fmt.Errorf("basic_auth, oauth2, bearer_token & bearer_token_file must not be configured when authorization type is %q", AuthorizationType) + } + + clean = cfg + clean.Authorization = nil + return clean, true, nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..8c6111b --- /dev/null +++ b/config_test.go @@ -0,0 +1,124 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cfaccess + +import ( + "testing" + + commonconfig "github.com/prometheus/common/config" + "github.com/stretchr/testify/require" +) + +func TestPrepareHTTPClientConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config commonconfig.HTTPClientConfig + enabled bool + wantErr string + }{ + { + name: "no authorization", + }, + { + name: "other authorization", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: "Bearer", Credentials: "token"}, + }, + }, + { + name: "Cloudflare Access", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: " CF-Access "}, + }, + enabled: true, + }, + { + name: "inline credentials", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: AuthorizationType, Credentials: "token"}, + }, + wantErr: `authorization credentials, credentials_file & credentials_ref must not be configured when authorization type is "cf-access"`, + }, + { + name: "credentials file", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: AuthorizationType, CredentialsFile: "token-file"}, + }, + wantErr: `authorization credentials, credentials_file & credentials_ref must not be configured when authorization type is "cf-access"`, + }, + { + name: "credentials reference", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: AuthorizationType, CredentialsRef: "token-ref"}, + }, + wantErr: `authorization credentials, credentials_file & credentials_ref must not be configured when authorization type is "cf-access"`, + }, + { + name: "basic authentication", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: AuthorizationType}, + BasicAuth: &commonconfig.BasicAuth{}, + }, + wantErr: `basic_auth, oauth2, bearer_token & bearer_token_file must not be configured when authorization type is "cf-access"`, + }, + { + name: "OAuth2", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: AuthorizationType}, + OAuth2: &commonconfig.OAuth2{}, + }, + wantErr: `basic_auth, oauth2, bearer_token & bearer_token_file must not be configured when authorization type is "cf-access"`, + }, + { + name: "bearer token", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: AuthorizationType}, + BearerToken: "token", + }, + wantErr: `basic_auth, oauth2, bearer_token & bearer_token_file must not be configured when authorization type is "cf-access"`, + }, + { + name: "bearer token file", + config: commonconfig.HTTPClientConfig{ + Authorization: &commonconfig.Authorization{Type: AuthorizationType}, + BearerTokenFile: "token-file", + }, + wantErr: `basic_auth, oauth2, bearer_token & bearer_token_file must not be configured when authorization type is "cf-access"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + originalAuthorization := tc.config.Authorization + clean, enabled, err := PrepareHTTPClientConfig(tc.config) + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + require.False(t, enabled) + return + } + + require.NoError(t, err) + require.Equal(t, tc.enabled, enabled) + require.Same(t, originalAuthorization, tc.config.Authorization) + if tc.enabled { + require.Nil(t, clean.Authorization) + } else { + require.Same(t, originalAuthorization, clean.Authorization) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e6afeb --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/prometheus/cfaccess + +go 1.25.0 + +require ( + github.com/cloudflare/cloudflared v0.0.0-20260306125340-d2a87e9b9345 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/prometheus/common v0.71.0 + github.com/rs/zerolog v1.20.0 + github.com/stretchr/testify v1.12.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jpillora/backoff v1.0.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/procfs v0.21.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/urfave/cli/v2 v2.3.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..72174c5 --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/cloudflared v0.0.0-20260306125340-d2a87e9b9345 h1:nKSn6yOXQY4IY0XMO7sP6OH6VKNZf/tIkz9gCNglkHk= +github.com/cloudflare/cloudflared v0.0.0-20260306125340-d2a87e9b9345/go.mod h1:Wa0JJ6XKazYtLNa6RHFMiVG1Px2AcLP/mkjD6ASMIY8= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= +github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= +github.com/prometheus/procfs v0.21.0 h1:Qh/e6TlBjZf+XLLqNCqFGmCU6Kj/2Bu7kj3oAc0UnXc= +github.com/prometheus/procfs v0.21.0/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs= +github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=