|
| 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