Skip to content
Open
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
180 changes: 180 additions & 0 deletions config/cfaccess.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// 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 config

import (
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"

"github.com/cloudflare/cloudflared/token"
"github.com/golang-jwt/jwt/v5"
"github.com/rs/zerolog"
)

// cfAccessAuthType is the value of Authorization.Type that selects
// Cloudflare Access authentication instead of a literal HTTP Authorization
// scheme. See https://developers.cloudflare.com/cloudflare-one/policies/access/.
const cfAccessAuthType = "cf-access"

// cfAccessTokenHeader is the header Cloudflare Access checks for a JWT
// obtained through its browser-based login flow.
//
// See https://developers.cloudflare.com/cloudflare-one/tutorials/cli/#curl.
const cfAccessTokenHeader = "Cf-Access-Token"

// cfAccessTokenExpiryMargin is how long before a cached Cloudflare Access
// token's expiry cfAccessRoundTripper proactively fetches a new one.
// Overridable in tests.
var cfAccessTokenExpiryMargin = 30 * time.Second

// cfAccessNow stands in for time.Now, overridable in tests so that token
// expiry can be exercised deterministically instead of via real sleeps.
var cfAccessNow = time.Now

// isCFAccessAuthType reports whether authType selects Cloudflare Access
// authentication.
func isCFAccessAuthType(authType string) bool {
return strings.EqualFold(strings.TrimSpace(authType), cfAccessAuthType)
}

// cfAccessGetAppInfo and cfAccessFetchToken are indirections over the
// github.com/cloudflare/cloudflared/token package, overridable in tests.
var (
cfAccessGetAppInfo = token.GetAppInfo
cfAccessFetchToken = token.FetchToken
)

// cfAccessLogger is shared by all cfAccessRoundTrippers to report the
// progress of interactive Cloudflare Access logins.
var cfAccessLogger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).With().Timestamp().Logger()

// cfAccessApp caches the Cloudflare Access application info and the most
// recently obtained token for a single scheme://host.
type cfAccessApp struct {
mtx sync.Mutex
info *token.AppInfo
token string
expires time.Time
}

// cfAccessRoundTripper authenticates requests against applications protected
// by Cloudflare Access, by attaching a Cf-Access-Token header obtained
// through cloudflared's login flow.
//
// Unlike a static Authorization header, the target application (and
// therefore its audience) is only known once an actual request is made, so
// the login for each scheme://host seen by this RoundTripper is performed
// lazily, on the first request to it, and cached both in memory and (via
// cloudflared) on disk, until the token is close to expiring.
type cfAccessRoundTripper struct {
next http.RoundTripper

mtx sync.Mutex
apps map[string]*cfAccessApp
}

// newCFAccessRoundTripper returns a RoundTripper that authenticates requests
// against Cloudflare Access before forwarding them to next. name identifies
// the calling application in the User-Agent header used while
// authenticating, and in Cloudflare Access's own logs.
func newCFAccessRoundTripper(next http.RoundTripper, name string) http.RoundTripper {
token.Init(name)
return &cfAccessRoundTripper{
next: next,
apps: make(map[string]*cfAccessApp),
}
}

// appFor returns the cfAccessApp tracking state for the host targeted by
// req, creating one if this is the first time it has been seen.
func (rt *cfAccessRoundTripper) appFor(key string) *cfAccessApp {
rt.mtx.Lock()
defer rt.mtx.Unlock()

app, ok := rt.apps[key]
if !ok {
app = &cfAccessApp{}
rt.apps[key] = app
}
return app
}

// RoundTrip implements http.RoundTripper.
func (rt *cfAccessRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
key := req.URL.Scheme + "://" + req.URL.Host
app := rt.appFor(key)

tok, err := app.fetch(req)
if err != nil {
return nil, fmt.Errorf("cloudflare access: %w", err)
}

req.Header.Set(cfAccessTokenHeader, tok)
return rt.next.RoundTrip(req)
}

// fetch returns a valid Cloudflare Access token for the application behind
// req.URL, fetching or refreshing it as needed. It may block on an
// interactive browser login if no valid cached token is available, either
// in memory or in cloudflared's own on-disk token cache.
func (a *cfAccessApp) fetch(req *http.Request) (string, error) {
a.mtx.Lock()
defer a.mtx.Unlock()

if a.token != "" && cfAccessNow().Add(cfAccessTokenExpiryMargin).Before(a.expires) {
return a.token, nil
}

if a.info == nil {
info, err := cfAccessGetAppInfo(req.URL)
if err != nil {
return "", fmt.Errorf("failed to detect Cloudflare Access application for %s://%s: %w", req.URL.Scheme, req.URL.Host, err)
}
a.info = info
}

tok, err := cfAccessFetchToken(req.URL, a.info, false, false, &cfAccessLogger)
if err != nil {
return "", fmt.Errorf("failed to fetch Cloudflare Access token: %w", err)
}

a.token = tok
a.expires = cfAccessTokenExpiry(tok)
return tok, nil
}

// cfAccessTokenExpiry returns the expiry time encoded in the "exp" claim of
// tok, or the zero time if it cannot be determined. tok is not signature
// verified: it was just obtained directly from cloudflared over an
// authenticated channel, so verification against Cloudflare's public keys
// would add complexity without a meaningful security benefit here. A zero
// return value simply means the token will be treated as already expired,
// and a new one fetched (which, thanks to cloudflared's own on-disk cache,
// is cheap and does not by itself trigger a new interactive login) on the
// next request.
func cfAccessTokenExpiry(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
}
204 changes: 204 additions & 0 deletions config/cfaccess_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// 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 config

import (
"errors"
"net/http"
"net/url"
"sync/atomic"
"testing"
"time"

"github.com/cloudflare/cloudflared/token"
"github.com/golang-jwt/jwt/v5"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
)

// signedTestToken returns a JWT with the given expiry encoded in its "exp"
// claim. cfAccessTokenExpiry does not verify the signature, so the signing
// key is arbitrary.
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 TestCFAccessTokenExpiry(t *testing.T) {
t.Run("valid token", func(t *testing.T) {
expiry := time.Now().Add(time.Hour).Truncate(time.Second)
got := cfAccessTokenExpiry(signedTestToken(t, expiry))
require.WithinDuration(t, expiry, got, time.Second)
})

t.Run("malformed token", func(t *testing.T) {
require.True(t, cfAccessTokenExpiry("not-a-jwt").IsZero())
})

t.Run("token without exp claim", func(t *testing.T) {
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{})
signed, err := tok.SignedString([]byte("test-signing-key"))
require.NoError(t, err)
require.True(t, cfAccessTokenExpiry(signed).IsZero())
})
}

func TestIsCFAccessAuthType(t *testing.T) {
for _, tc := range []struct {
authType string
want bool
}{
{"cf-access", true},
{"CF-Access", true},
{" cf-access ", true},
{"Bearer", false},
{"", false},
} {
require.Equalf(t, tc.want, isCFAccessAuthType(tc.authType), "authType=%q", tc.authType)
}
}

// withFakeCFAccess overrides cfAccessGetAppInfo and cfAccessFetchToken for
// the duration of the test, restoring the real cloudflared-backed
// implementations afterwards.
func withFakeCFAccess(
t *testing.T,
getAppInfo func(reqURL *url.URL) (*token.AppInfo, error),
fetchToken func(appURL *url.URL, appInfo *token.AppInfo) (string, error),
) {
t.Helper()

origGetAppInfo := cfAccessGetAppInfo
origFetchToken := cfAccessFetchToken
t.Cleanup(func() {
cfAccessGetAppInfo = origGetAppInfo
cfAccessFetchToken = origFetchToken
})

cfAccessGetAppInfo = getAppInfo
cfAccessFetchToken = func(appURL *url.URL, appInfo *token.AppInfo, _, _ bool, _ *zerolog.Logger) (string, error) {
return fetchToken(appURL, appInfo)
}
}

func TestCFAccessRoundTripper(t *testing.T) {
fakeNow := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
origNow := cfAccessNow
cfAccessNow = func() time.Time { return fakeNow }
t.Cleanup(func() { cfAccessNow = origNow })

origMargin := cfAccessTokenExpiryMargin
cfAccessTokenExpiryMargin = 30 * time.Second
t.Cleanup(func() { cfAccessTokenExpiryMargin = origMargin })

var (
getAppInfoCalls atomic.Int32
fetchTokenCalls atomic.Int32
)

shortLivedToken := signedTestToken(t, fakeNow.Add(time.Minute))
longLivedToken := signedTestToken(t, fakeNow.Add(time.Hour))

withFakeCFAccess(t,
func(reqURL *url.URL) (*token.AppInfo, error) {
getAppInfoCalls.Add(1)
return &token.AppInfo{AuthDomain: "auth." + reqURL.Host, AppAUD: "aud", AppDomain: reqURL.Host}, nil
},
func(*url.URL, *token.AppInfo) (string, error) {
n := fetchTokenCalls.Add(1)
if n == 1 {
return shortLivedToken, nil
}
return longLivedToken, nil
},
)

var gotHeader string
next := NewRoundTripCheckRequest(func(req *http.Request) {
gotHeader = req.Header.Get(cfAccessTokenHeader)
}, &http.Response{StatusCode: http.StatusOK}, nil)

rt := newCFAccessRoundTripper(next, "test")

req1, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody)
require.NoError(t, err)
_, err = rt.RoundTrip(req1)
require.NoError(t, err)
require.Equal(t, shortLivedToken, gotHeader)
require.EqualValues(t, 1, getAppInfoCalls.Load())
require.EqualValues(t, 1, fetchTokenCalls.Load())

// A second request to the same host, while the cached token is still
// valid, must not re-fetch anything.
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.Equal(t, shortLivedToken, gotHeader)
require.EqualValues(t, 1, getAppInfoCalls.Load())
require.EqualValues(t, 1, fetchTokenCalls.Load())

// A request to a different host must fetch a fresh token, independent
// of the first host's cached state.
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, gotHeader)
require.EqualValues(t, 2, getAppInfoCalls.Load())
require.EqualValues(t, 2, fetchTokenCalls.Load())

// Advancing the clock past the short-lived token's expiry margin must
// trigger a refetch for the original host, reusing the already-known
// AppInfo.
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, gotHeader)
require.EqualValuesf(t, 2, getAppInfoCalls.Load(), "AppInfo should be cached across token refreshes")
require.EqualValues(t, 3, fetchTokenCalls.Load())
}

var errFakeGetAppInfo = errors.New("fake GetAppInfo failure")

func TestCFAccessRoundTripperGetAppInfoError(t *testing.T) {
withFakeCFAccess(t,
func(*url.URL) (*token.AppInfo, error) {
return nil, errFakeGetAppInfo
},
func(*url.URL, *token.AppInfo) (string, error) {
t.Fatal("FetchToken must not be called when GetAppInfo fails")
return "", nil
},
)

next := NewRoundTripCheckRequest(func(*http.Request) {
t.Fatal("next RoundTripper must not be called when authentication fails")
}, nil, nil)

rt := newCFAccessRoundTripper(next, "test")
req, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody)
require.NoError(t, err)

_, err = rt.RoundTrip(req)
require.Error(t, err)
require.ErrorIs(t, err, errFakeGetAppInfo)
}
Loading
Loading