From 774202b1c867676d5de62ed18ac378897b134b7c Mon Sep 17 00:00:00 2001 From: MFMarkus Date: Wed, 25 Feb 2026 10:46:16 +0100 Subject: [PATCH] Allow cloudflare service tokens to be allowed list through epoxy --- README.md | 1 + cmd/epoxyd/main.go | 23 ++++++++++- internal/cf/cf.go | 1 + internal/config/config.go | 81 ++++++++++++++++++++++----------------- internal/extjwt/extjwt.go | 43 +++++++++++++++++++-- 5 files changed, 108 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index f9629b3..2d6a1e8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ In this mode epoxy can be used as a regular reverse proxy or static file server. * `CF_JWKS_URL` Cloudflare JWKS Url to [validate JWT](https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/)\ e.g. `https://.cloudflareaccess.com/cdn-cgi/access/certs` * `CF_APP_AUD` Cloudflare Application Audience (AUD) Tag. +* `CF_SERVICE_TOKEN_ALLOWLIST` Comma separated list of Cloudflare Service Tokens to allow. #### Dev mode server * `DEV_ADDR` address to serve at, e.g. `":8080"` or `"127.0.0.1:8080"` diff --git a/cmd/epoxyd/main.go b/cmd/epoxyd/main.go index dcd1c64..04f0735 100644 --- a/cmd/epoxyd/main.go +++ b/cmd/epoxyd/main.go @@ -22,6 +22,27 @@ import ( func main() { cfg := config.Get() + + log.New(). + WithField("CF_ADDR", cfg.CfAddr). + WithField("CF_JWKS_URL", cfg.CfJwkUrl). + WithField("CF_APP_AUD", cfg.CfAppAud). + WithField("CF_SERVICE_TOKEN_ALLOWLIST", cfg.CfServiceTokenAllowlist). + WithField("EXT_JWKS_URL", cfg.ExtJwkUrl). + WithField("EXT_JWT_URL", cfg.ExtJwtUrl). + WithField("EXT_JWT_SUBJECT_PATH", cfg.ExtJwtSubjectPath). + WithField("DEV_ADDR", cfg.DevAddr). + WithField("DEV_ALLOWED_USER_SUFFIX", cfg.DevAllowedUserSuffix). + WithField("DEV_SESSION_DURATION", cfg.DevSessionDuration). + WithField("DEV_DISABLE_SECURE_COOKIE", cfg.DevDisableSecureCookie). + WithField("NO_AUTH_ENABLE", cfg.NoAuthEnable). + WithField("NO_AUTH_ADDR", cfg.NoAuthAddr). + WithField("PUBLIC_DIR", cfg.PublicDir). + WithField("PUBLIC_PREFIX", cfg.PublicPrefix). + WithField("CONTENT_SECURITY_POLICY", cfg.ContentSecurityPolicy). + WithField("ROUTES", cfg.Routes). + Info("epoxy config") + var publicFs fs.FS if cfg.PublicDir != "" { publicFs = os.DirFS(cfg.PublicDir) @@ -38,7 +59,7 @@ func main() { return gzhttp.GzipHandler(h) } middlewares := []epoxy.Middleware{ - extjwt.Middleware(cfg.ExtJwkUrl, cfg.ExtJwtUrl), + extjwt.Middleware(cfg.ExtJwkUrl, cfg.ExtJwtUrl, cfg.CfServiceTokenAllowlist), cf.Middleware(cfg.CfAppAud, cfg.CfJwkUrl), nocache.Middleware, gzipMiddleware, diff --git a/internal/cf/cf.go b/internal/cf/cf.go index ae83c5c..bdb2056 100644 --- a/internal/cf/cf.go +++ b/internal/cf/cf.go @@ -62,4 +62,5 @@ type Claims struct { Type string `json:"type"` IdentityNonce string `json:"identity_nonce"` Country string `json:"country"` + CommonName string `json:"common_name"` } diff --git a/internal/config/config.go b/internal/config/config.go index dcd3246..1c14a4f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,8 @@ type config struct { CfJwksUrl string `env:"CF_JWKS_URL"` CfAppAud string `env:"CF_APP_AUD"` + CfServiceTokenAllowlist string `env:"CF_SERVICE_TOKEN_ALLOWLIST"` + DevAddr string `env:"DEV_ADDR" envDefault:":7070"` DevAllowedUserSuffix string `env:"DEV_ALLOWED_USER_SUFFIX"` DevBcryptHash string `env:"DEV_BCRYPT_HASH"` @@ -66,24 +68,30 @@ func Get() Config { log.New().Fatal("error: DEV_SESSION_DURATION is negative") } + var serviceTokenAllowList []string + if c.CfServiceTokenAllowlist != "" { + serviceTokenAllowList = strings.Split(strings.TrimSpace(c.CfServiceTokenAllowlist), ",") + } + cfg = Config{ - Routes: routes, - PublicDir: strings.TrimSpace(c.PublicDir), - PublicPrefix: strings.TrimSpace(c.PublicPrefix), - CfAddr: strings.TrimSpace(c.CfAddr), - CfJwkUrl: strings.TrimSpace(c.CfJwksUrl), - CfAppAud: strings.TrimSpace(c.CfAppAud), - ExtJwkUrl: strings.TrimSpace(c.ExtJwksUrl), - ExtJwtUrl: strings.TrimSpace(c.ExtJwtUrl), - ExtJwtSubjectPath: strings.TrimSpace(c.ExtJwtSubjectPath), - DevAddr: strings.TrimSpace(c.DevAddr), - DevBcryptHash: strings.TrimSpace(c.DevBcryptHash), - DevAllowedUserSuffix: strings.TrimSpace(c.DevAllowedUserSuffix), - NoAuthEnable: c.NoAuthEnable, - NoAuthAddr: strings.TrimSpace(c.NoAuthAddr), - DevSessionDuration: c.DevSessionDuration, - DevDisableSecureCookie: c.DevDisableSecureCookie, - ContentSecurityPolicy: c.ContentSecurityPolicy, + Routes: routes, + PublicDir: strings.TrimSpace(c.PublicDir), + PublicPrefix: strings.TrimSpace(c.PublicPrefix), + CfAddr: strings.TrimSpace(c.CfAddr), + CfJwkUrl: strings.TrimSpace(c.CfJwksUrl), + CfAppAud: strings.TrimSpace(c.CfAppAud), + CfServiceTokenAllowlist: serviceTokenAllowList, + ExtJwkUrl: strings.TrimSpace(c.ExtJwksUrl), + ExtJwtUrl: strings.TrimSpace(c.ExtJwtUrl), + ExtJwtSubjectPath: strings.TrimSpace(c.ExtJwtSubjectPath), + DevAddr: strings.TrimSpace(c.DevAddr), + DevBcryptHash: strings.TrimSpace(c.DevBcryptHash), + DevAllowedUserSuffix: strings.TrimSpace(c.DevAllowedUserSuffix), + NoAuthEnable: c.NoAuthEnable, + NoAuthAddr: strings.TrimSpace(c.NoAuthAddr), + DevSessionDuration: c.DevSessionDuration, + DevDisableSecureCookie: c.DevDisableSecureCookie, + ContentSecurityPolicy: c.ContentSecurityPolicy, } if strings.TrimSpace(c.JwtEc256) != "" { @@ -106,25 +114,26 @@ func Get() Config { } type Config struct { - Routes []epoxy.Route - PublicDir string - PublicPrefix string - CfAddr string - CfJwkUrl string - CfAppAud string - DevAddr string - DevAllowedUserSuffix string - DevBcryptHash string - DevSessionDuration time.Duration - DevDisableSecureCookie bool - ExtJwkUrl string - ExtJwtUrl string - ExtJwtSubjectPath string - NoAuthEnable bool - NoAuthAddr string - JwtEc256 *ecdsa.PrivateKey - JwtEc256Pub *ecdsa.PublicKey - ContentSecurityPolicy string + Routes []epoxy.Route + PublicDir string + PublicPrefix string + CfAddr string + CfJwkUrl string + CfAppAud string + CfServiceTokenAllowlist []string + DevAddr string + DevAllowedUserSuffix string + DevBcryptHash string + DevSessionDuration time.Duration + DevDisableSecureCookie bool + ExtJwkUrl string + ExtJwtUrl string + ExtJwtSubjectPath string + NoAuthEnable bool + NoAuthAddr string + JwtEc256 *ecdsa.PrivateKey + JwtEc256Pub *ecdsa.PublicKey + ContentSecurityPolicy string } func parseRoutes(routesString string) ([]epoxy.Route, error) { diff --git a/internal/extjwt/extjwt.go b/internal/extjwt/extjwt.go index 8a8fa92..0508888 100644 --- a/internal/extjwt/extjwt.go +++ b/internal/extjwt/extjwt.go @@ -5,20 +5,21 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net/http" + "time" + "github.com/golang-jwt/jwt/v5" "github.com/modfin/epoxy/internal/cf" "github.com/modfin/epoxy/internal/log" "github.com/modfin/epoxy/internal/simplecache" "github.com/modfin/epoxy/pkg/epoxy" "github.com/modfin/epoxy/pkg/jwk" - "io" - "net/http" - "time" ) type contextKey struct{} -func Middleware(extJwkUrl string, extJwtUrl string) epoxy.Middleware { +func Middleware(extJwkUrl string, extJwtUrl string, cfServiceTokenAllowlist []string) epoxy.Middleware { if extJwkUrl == "" || extJwtUrl == "" { log.New().Fatal("extjwt: missing required parameters") } @@ -34,6 +35,14 @@ func Middleware(extJwkUrl string, extJwtUrl string) epoxy.Middleware { w.WriteHeader(http.StatusInternalServerError) return } + // Check if this is a service token by looking for common_name in claims + serviceTokenClaims := checkCloudflareServiceToken(r.Context(), cfAuth, cfServiceTokenAllowlist) + if serviceTokenClaims != nil { + ctx := context.WithValue(r.Context(), contextKey{}, *serviceTokenClaims) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + extJwt, err := getAndParseExtJwt(r.Context(), jwkCache, extJwtCache, extJwkUrl, extJwtUrl, cfAuth.Raw) if err != nil { log.New().WithError(fmt.Errorf("extjwt: error getting and parsing token: %w", err)).AddToContext(r.Context()) @@ -46,6 +55,32 @@ func Middleware(extJwkUrl string, extJwtUrl string) epoxy.Middleware { } } +func checkCloudflareServiceToken(ctx context.Context, cfAuth *jwt.Token, cfServiceTokenAllowlist []string) *jwt.MapClaims { + if claims, hasClaims := cfAuth.Claims.(jwt.MapClaims); hasClaims { + if commonName, hasCommonName := claims["common_name"].(string); hasCommonName && commonName != "" { + for _, allowedId := range cfServiceTokenAllowlist { + if commonName == allowedId { + log.New().WithField("service_token", commonName).AddToContext(ctx) + // Create synthetic ext claims for service token + serviceTokenClaims := jwt.MapClaims{ + "iss": "epoxy:service-token", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + "user": map[string]any{ + "email": commonName, + "full_name": commonName, + "first_name": "Service Token", + "groups": []string{"service-token"}, + }, + } + return &serviceTokenClaims + } + } + } + } + return nil +} + func ExtValidationClaims(ctx context.Context) (jwt.MapClaims, error) { if c, ok := ctx.Value(contextKey{}).(jwt.MapClaims); ok { return c, nil