Skip to content

Commit f6ad6a6

Browse files
committed
Update http_config to support cfaccess
Adds a new auth type cf-access which allows using this tool with Cloudflare Access. Defines the new auth type, create a new RoundTripper to handle the login / token fetch flow, and set it as the client transport if enabled. I originally planned to add this in prometheus/prometheus for `promtool`, but (1) The implementation is cleaner doing it here and (2) I want to do it for `amtool` as well, so implementing it in `common` means not doing it twice. Signed-off-by: Phil Dibowitz <phil@ipom.com>
1 parent 66dd055 commit f6ad6a6

7 files changed

Lines changed: 477 additions & 5 deletions

File tree

config/cfaccess.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package config
15+
16+
import (
17+
"fmt"
18+
"net/http"
19+
"os"
20+
"strings"
21+
"sync"
22+
"time"
23+
24+
"github.com/cloudflare/cloudflared/token"
25+
"github.com/golang-jwt/jwt/v5"
26+
"github.com/rs/zerolog"
27+
)
28+
29+
// cfAccessAuthType is the value of Authorization.Type that selects
30+
// Cloudflare Access authentication instead of a literal HTTP Authorization
31+
// scheme. See https://developers.cloudflare.com/cloudflare-one/policies/access/.
32+
const cfAccessAuthType = "cf-access"
33+
34+
// cfAccessTokenHeader is the header Cloudflare Access checks for a JWT
35+
// obtained through its browser-based login flow.
36+
//
37+
// See https://developers.cloudflare.com/cloudflare-one/tutorials/cli/#curl.
38+
const cfAccessTokenHeader = "Cf-Access-Token"
39+
40+
// cfAccessTokenExpiryMargin is how long before a cached Cloudflare Access
41+
// token's expiry cfAccessRoundTripper proactively fetches a new one.
42+
// Overridable in tests.
43+
var cfAccessTokenExpiryMargin = 30 * time.Second
44+
45+
// cfAccessNow stands in for time.Now, overridable in tests so that token
46+
// expiry can be exercised deterministically instead of via real sleeps.
47+
var cfAccessNow = time.Now
48+
49+
// isCFAccessAuthType reports whether authType selects Cloudflare Access
50+
// authentication.
51+
func isCFAccessAuthType(authType string) bool {
52+
return strings.EqualFold(strings.TrimSpace(authType), cfAccessAuthType)
53+
}
54+
55+
// cfAccessGetAppInfo and cfAccessFetchToken are indirections over the
56+
// github.com/cloudflare/cloudflared/token package, overridable in tests.
57+
var (
58+
cfAccessGetAppInfo = token.GetAppInfo
59+
cfAccessFetchToken = token.FetchToken
60+
)
61+
62+
// cfAccessLogger is shared by all cfAccessRoundTrippers to report the
63+
// progress of interactive Cloudflare Access logins.
64+
var cfAccessLogger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).With().Timestamp().Logger()
65+
66+
// cfAccessApp caches the Cloudflare Access application info and the most
67+
// recently obtained token for a single scheme://host.
68+
type cfAccessApp struct {
69+
mtx sync.Mutex
70+
info *token.AppInfo
71+
token string
72+
expires time.Time
73+
}
74+
75+
// cfAccessRoundTripper authenticates requests against applications protected
76+
// by Cloudflare Access, by attaching a Cf-Access-Token header obtained
77+
// through cloudflared's login flow.
78+
//
79+
// Unlike a static Authorization header, the target application (and
80+
// therefore its audience) is only known once an actual request is made, so
81+
// the login for each scheme://host seen by this RoundTripper is performed
82+
// lazily, on the first request to it, and cached both in memory and (via
83+
// cloudflared) on disk, until the token is close to expiring.
84+
type cfAccessRoundTripper struct {
85+
next http.RoundTripper
86+
87+
mtx sync.Mutex
88+
apps map[string]*cfAccessApp
89+
}
90+
91+
// newCFAccessRoundTripper returns a RoundTripper that authenticates requests
92+
// against Cloudflare Access before forwarding them to next. name identifies
93+
// the calling application in the User-Agent header used while
94+
// authenticating, and in Cloudflare Access's own logs.
95+
func newCFAccessRoundTripper(next http.RoundTripper, name string) http.RoundTripper {
96+
token.Init(name)
97+
return &cfAccessRoundTripper{
98+
next: next,
99+
apps: make(map[string]*cfAccessApp),
100+
}
101+
}
102+
103+
// appFor returns the cfAccessApp tracking state for the host targeted by
104+
// req, creating one if this is the first time it has been seen.
105+
func (rt *cfAccessRoundTripper) appFor(key string) *cfAccessApp {
106+
rt.mtx.Lock()
107+
defer rt.mtx.Unlock()
108+
109+
app, ok := rt.apps[key]
110+
if !ok {
111+
app = &cfAccessApp{}
112+
rt.apps[key] = app
113+
}
114+
return app
115+
}
116+
117+
// RoundTrip implements http.RoundTripper.
118+
func (rt *cfAccessRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
119+
key := req.URL.Scheme + "://" + req.URL.Host
120+
app := rt.appFor(key)
121+
122+
tok, err := app.fetch(req)
123+
if err != nil {
124+
return nil, fmt.Errorf("cloudflare access: %w", err)
125+
}
126+
127+
req.Header.Set(cfAccessTokenHeader, tok)
128+
return rt.next.RoundTrip(req)
129+
}
130+
131+
// fetch returns a valid Cloudflare Access token for the application behind
132+
// req.URL, fetching or refreshing it as needed. It may block on an
133+
// interactive browser login if no valid cached token is available, either
134+
// in memory or in cloudflared's own on-disk token cache.
135+
func (a *cfAccessApp) fetch(req *http.Request) (string, error) {
136+
a.mtx.Lock()
137+
defer a.mtx.Unlock()
138+
139+
if a.token != "" && cfAccessNow().Add(cfAccessTokenExpiryMargin).Before(a.expires) {
140+
return a.token, nil
141+
}
142+
143+
// cloudflared's token flow rewrites the URL while constructing its login
144+
// endpoint. Give it a copy so it cannot alter the request we ultimately send.
145+
appURL := *req.URL
146+
147+
if a.info == nil {
148+
info, err := cfAccessGetAppInfo(&appURL)
149+
if err != nil {
150+
return "", fmt.Errorf("failed to detect Cloudflare Access application for %s://%s: %w", req.URL.Scheme, req.URL.Host, err)
151+
}
152+
a.info = info
153+
}
154+
155+
tok, err := cfAccessFetchToken(&appURL, a.info, false, false, &cfAccessLogger)
156+
if err != nil {
157+
return "", fmt.Errorf("failed to fetch Cloudflare Access token: %w", err)
158+
}
159+
160+
a.token = tok
161+
a.expires = cfAccessTokenExpiry(tok)
162+
return tok, nil
163+
}
164+
165+
// cfAccessTokenExpiry returns the expiry time encoded in the "exp" claim of
166+
// tok, or the zero time if it cannot be determined. tok is not signature
167+
// verified: it was just obtained directly from cloudflared over an
168+
// authenticated channel, so verification against Cloudflare's public keys
169+
// would add complexity without a meaningful security benefit here. A zero
170+
// return value simply means the token will be treated as already expired,
171+
// and a new one fetched (which, thanks to cloudflared's own on-disk cache,
172+
// is cheap and does not by itself trigger a new interactive login) on the
173+
// next request.
174+
func cfAccessTokenExpiry(tok string) time.Time {
175+
claims := jwt.MapClaims{}
176+
if _, _, err := jwt.NewParser().ParseUnverified(tok, claims); err != nil {
177+
return time.Time{}
178+
}
179+
exp, err := claims.GetExpirationTime()
180+
if err != nil || exp == nil {
181+
return time.Time{}
182+
}
183+
return exp.Time
184+
}

config/cfaccess_test.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package config
15+
16+
import (
17+
"errors"
18+
"net/http"
19+
"net/url"
20+
"sync/atomic"
21+
"testing"
22+
"time"
23+
24+
"github.com/cloudflare/cloudflared/token"
25+
"github.com/golang-jwt/jwt/v5"
26+
"github.com/rs/zerolog"
27+
"github.com/stretchr/testify/require"
28+
)
29+
30+
// signedTestToken returns a JWT with the given expiry encoded in its "exp"
31+
// claim. cfAccessTokenExpiry does not verify the signature, so the signing
32+
// key is arbitrary.
33+
func signedTestToken(t *testing.T, expiry time.Time) string {
34+
t.Helper()
35+
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
36+
"exp": jwt.NewNumericDate(expiry),
37+
})
38+
signed, err := tok.SignedString([]byte("test-signing-key"))
39+
require.NoError(t, err)
40+
return signed
41+
}
42+
43+
func TestCFAccessTokenExpiry(t *testing.T) {
44+
t.Run("valid token", func(t *testing.T) {
45+
expiry := time.Now().Add(time.Hour).Truncate(time.Second)
46+
got := cfAccessTokenExpiry(signedTestToken(t, expiry))
47+
require.WithinDuration(t, expiry, got, time.Second)
48+
})
49+
50+
t.Run("malformed token", func(t *testing.T) {
51+
require.True(t, cfAccessTokenExpiry("not-a-jwt").IsZero())
52+
})
53+
54+
t.Run("token without exp claim", func(t *testing.T) {
55+
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{})
56+
signed, err := tok.SignedString([]byte("test-signing-key"))
57+
require.NoError(t, err)
58+
require.True(t, cfAccessTokenExpiry(signed).IsZero())
59+
})
60+
}
61+
62+
func TestIsCFAccessAuthType(t *testing.T) {
63+
for _, tc := range []struct {
64+
authType string
65+
want bool
66+
}{
67+
{"cf-access", true},
68+
{"CF-Access", true},
69+
{" cf-access ", true},
70+
{"Bearer", false},
71+
{"", false},
72+
} {
73+
require.Equalf(t, tc.want, isCFAccessAuthType(tc.authType), "authType=%q", tc.authType)
74+
}
75+
}
76+
77+
// withFakeCFAccess overrides cfAccessGetAppInfo and cfAccessFetchToken for
78+
// the duration of the test, restoring the real cloudflared-backed
79+
// implementations afterwards.
80+
func withFakeCFAccess(
81+
t *testing.T,
82+
getAppInfo func(reqURL *url.URL) (*token.AppInfo, error),
83+
fetchToken func(appURL *url.URL, appInfo *token.AppInfo) (string, error),
84+
) {
85+
t.Helper()
86+
87+
origGetAppInfo := cfAccessGetAppInfo
88+
origFetchToken := cfAccessFetchToken
89+
t.Cleanup(func() {
90+
cfAccessGetAppInfo = origGetAppInfo
91+
cfAccessFetchToken = origFetchToken
92+
})
93+
94+
cfAccessGetAppInfo = getAppInfo
95+
cfAccessFetchToken = func(appURL *url.URL, appInfo *token.AppInfo, _, _ bool, _ *zerolog.Logger) (string, error) {
96+
return fetchToken(appURL, appInfo)
97+
}
98+
}
99+
100+
func TestCFAccessRoundTripper(t *testing.T) {
101+
fakeNow := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
102+
origNow := cfAccessNow
103+
cfAccessNow = func() time.Time { return fakeNow }
104+
t.Cleanup(func() { cfAccessNow = origNow })
105+
106+
origMargin := cfAccessTokenExpiryMargin
107+
cfAccessTokenExpiryMargin = 30 * time.Second
108+
t.Cleanup(func() { cfAccessTokenExpiryMargin = origMargin })
109+
110+
var (
111+
getAppInfoCalls atomic.Int32
112+
fetchTokenCalls atomic.Int32
113+
)
114+
115+
shortLivedToken := signedTestToken(t, fakeNow.Add(time.Minute))
116+
longLivedToken := signedTestToken(t, fakeNow.Add(time.Hour))
117+
118+
withFakeCFAccess(t,
119+
func(reqURL *url.URL) (*token.AppInfo, error) {
120+
getAppInfoCalls.Add(1)
121+
return &token.AppInfo{AuthDomain: "auth." + reqURL.Host, AppAUD: "aud", AppDomain: reqURL.Host}, nil
122+
},
123+
func(appURL *url.URL, _ *token.AppInfo) (string, error) {
124+
// cloudflared constructs its login URL in place. This must not mutate
125+
// the request URL that cfAccessRoundTripper eventually sends.
126+
appURL.Path = "/cdn-cgi/access/cli"
127+
appURL.RawQuery = "token=secret"
128+
n := fetchTokenCalls.Add(1)
129+
if n == 1 {
130+
return shortLivedToken, nil
131+
}
132+
return longLivedToken, nil
133+
},
134+
)
135+
136+
var (
137+
gotHeader string
138+
gotURL string
139+
)
140+
next := NewRoundTripCheckRequest(func(req *http.Request) {
141+
gotHeader = req.Header.Get(cfAccessTokenHeader)
142+
gotURL = req.URL.String()
143+
}, &http.Response{StatusCode: http.StatusOK}, nil)
144+
145+
rt := newCFAccessRoundTripper(next, "test")
146+
147+
req1, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody)
148+
require.NoError(t, err)
149+
_, err = rt.RoundTrip(req1)
150+
require.NoError(t, err)
151+
require.Equal(t, shortLivedToken, gotHeader)
152+
require.Equal(t, "https://app.example.com/query", gotURL)
153+
require.Equal(t, "https://app.example.com/query", req1.URL.String())
154+
require.EqualValues(t, 1, getAppInfoCalls.Load())
155+
require.EqualValues(t, 1, fetchTokenCalls.Load())
156+
157+
// A second request to the same host, while the cached token is still
158+
// valid, must not re-fetch anything.
159+
req2, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody)
160+
require.NoError(t, err)
161+
_, err = rt.RoundTrip(req2)
162+
require.NoError(t, err)
163+
require.Equal(t, shortLivedToken, gotHeader)
164+
require.EqualValues(t, 1, getAppInfoCalls.Load())
165+
require.EqualValues(t, 1, fetchTokenCalls.Load())
166+
167+
// A request to a different host must fetch a fresh token, independent
168+
// of the first host's cached state.
169+
req3, err := http.NewRequest(http.MethodGet, "https://other.example.com/query", http.NoBody)
170+
require.NoError(t, err)
171+
_, err = rt.RoundTrip(req3)
172+
require.NoError(t, err)
173+
require.Equal(t, longLivedToken, gotHeader)
174+
require.EqualValues(t, 2, getAppInfoCalls.Load())
175+
require.EqualValues(t, 2, fetchTokenCalls.Load())
176+
177+
// Advancing the clock past the short-lived token's expiry margin must
178+
// trigger a refetch for the original host, reusing the already-known
179+
// AppInfo.
180+
fakeNow = fakeNow.Add(time.Minute)
181+
req4, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody)
182+
require.NoError(t, err)
183+
_, err = rt.RoundTrip(req4)
184+
require.NoError(t, err)
185+
require.Equal(t, longLivedToken, gotHeader)
186+
require.EqualValuesf(t, 2, getAppInfoCalls.Load(), "AppInfo should be cached across token refreshes")
187+
require.EqualValues(t, 3, fetchTokenCalls.Load())
188+
}
189+
190+
var errFakeGetAppInfo = errors.New("fake GetAppInfo failure")
191+
192+
func TestCFAccessRoundTripperGetAppInfoError(t *testing.T) {
193+
withFakeCFAccess(t,
194+
func(*url.URL) (*token.AppInfo, error) {
195+
return nil, errFakeGetAppInfo
196+
},
197+
func(*url.URL, *token.AppInfo) (string, error) {
198+
t.Fatal("FetchToken must not be called when GetAppInfo fails")
199+
return "", nil
200+
},
201+
)
202+
203+
next := NewRoundTripCheckRequest(func(*http.Request) {
204+
t.Fatal("next RoundTripper must not be called when authentication fails")
205+
}, nil, nil)
206+
207+
rt := newCFAccessRoundTripper(next, "test")
208+
req, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody)
209+
require.NoError(t, err)
210+
211+
_, err = rt.RoundTrip(req)
212+
require.Error(t, err)
213+
require.ErrorIs(t, err, errFakeGetAppInfo)
214+
}

0 commit comments

Comments
 (0)