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
13 changes: 13 additions & 0 deletions .sugarjar.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Cloudflare Access authentication for Prometheus Go components.
Copyright 2026 The Prometheus Authors
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
181 changes: 181 additions & 0 deletions cfaccess.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading