package auth0
import (
config_util "github.com/greencoda/auth0-api-gateway/internal/util/config"
"github.com/greencoda/confiq"
)
type Config struct {
Audience string `cfg:"audience,default=https://your-auth0-api.yourdomain.io"`
Domain string `cfg:"domain,default=your-auth0-tenant.eu.auth0.com"`
}
func NewConfig(configSet *confiq.ConfigSet) (*Config, error) {
return config_util.LoadConfigFromSetWithPrefix[Config](configSet, "auth0")
}
package server
import (
"time"
config_util "github.com/greencoda/auth0-api-gateway/internal/util/config"
"github.com/greencoda/confiq"
)
type Config struct {
Address string `cfg:"address,default=:80"`
ReadTimeout time.Duration `cfg:"readTimeout,default=15s"`
WriteTimeout time.Duration `cfg:"writeTimeout,default=15s"`
IdleTimeout time.Duration `cfg:"idleTimeout,default=15s"`
MaxHeaderBytes int `cfg:"maxHeaderBytes,default=1048576"`
ReleaseStage string `cfg:"releaseStage,default=local"`
LogRequests bool `cfg:"logRequests,default=false"`
LogLevel string `cfg:"logLevel,default=info"`
}
func NewConfig(configSet *confiq.ConfigSet) (*Config, error) {
return config_util.LoadConfigFromSetWithPrefix[Config](configSet, "server")
}
package subrouter
import (
"time"
config_util "github.com/greencoda/auth0-api-gateway/internal/util/config"
"github.com/greencoda/confiq"
)
type AuthorizationConfig struct {
RequiredScopes []string `cfg:"requiredScopes"`
}
type RateLimitConfig struct {
Limit int64 `cfg:"maxRequests"`
Period time.Duration `cfg:"expiration"`
TrustForwardHeader bool `cfg:"trustForwardHeader,default=false"`
}
type CORSConfig struct {
AllowedOrigins []string `cfg:"allowedOrigins"`
AllowedMethods []string `cfg:"allowedMethods"`
AllowedHeaders []string `cfg:"allowedHeaders"`
ExposedHeaders []string `cfg:"exposedHeaders"`
AllowCredentials bool `cfg:"allowCredentials"`
MaxAge int `cfg:"maxAge"`
OptionsPassthrough bool `cfg:"optionsPassthrough"`
Debug bool `cfg:"debug"`
}
type SubrouterConfig struct {
Name string `cfg:"name"`
TargetURL string `cfg:"targetUrl"`
Prefix string `cfg:"prefix"`
StripPrefix bool `cfg:"stripPrefix,default=false"`
AuthorizationConfig *AuthorizationConfig `cfg:"authorizationConfig"`
RateLimitConfig *RateLimitConfig `cfg:"rateLimit"`
GZip bool `cfg:"gzip,default=false"`
CORSConfig *CORSConfig `cfg:"corsConfig"`
}
type Config []SubrouterConfig
func NewConfig(configSet *confiq.ConfigSet) (*Config, error) {
return config_util.LoadConfigFromSetWithPrefix[Config](configSet, "subrouters")
}
package auth0
import (
"context"
"strings"
)
type ICustomAuth0Claims interface {
Validate(context.Context) error
HasAllScopes([]string) bool
}
type CustomAuth0Claims struct {
Scope string `json:"scope"`
}
func (c CustomAuth0Claims) Validate(ctx context.Context) error {
return nil
}
func (c CustomAuth0Claims) HasAllScopes(expectedScopes []string) bool {
scopes := strings.Split(c.Scope, " ")
for _, expectedScope := range expectedScopes {
if !scopeInSlice(expectedScope, scopes) {
return false
}
}
return true
}
func scopeInSlice(expectedScope string, scopes []string) bool {
for _, scope := range scopes {
if scope == expectedScope {
return true
}
}
return false
}
package auth0
import (
"net/http"
jwtmiddleware "github.com/auth0/go-jwt-middleware/v2"
"github.com/auth0/go-jwt-middleware/v2/validator"
"github.com/gorilla/mux"
subrouter_config "github.com/greencoda/auth0-api-gateway/internal/config/subrouter"
)
type IAuth0ScopeValidator interface {
Handler() mux.MiddlewareFunc
}
type Auth0ScopeValidator struct {
middlewareFunc mux.MiddlewareFunc
}
func (a *Auth0ScopeValidator) Handler() mux.MiddlewareFunc {
return a.middlewareFunc
}
func buildAuth0ScopeMiddlewareFunc(config subrouter_config.AuthorizationConfig) mux.MiddlewareFunc {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) {
token, isValidatedClaim := req.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims)
if !isValidatedClaim {
handleScopeError(responseWriter, http.StatusBadRequest, "Cannot access token.")
return
}
customAuth0Claims, isValidatedClaim := token.CustomClaims.(*CustomAuth0Claims)
if !isValidatedClaim {
handleScopeError(responseWriter, http.StatusBadRequest, "Invalid claims in token.")
return
}
if !customAuth0Claims.HasAllScopes(config.RequiredScopes) {
handleScopeError(responseWriter, http.StatusForbidden, "Insufficient access privileges.")
return
}
handler.ServeHTTP(responseWriter, req)
})
}
}
func handleScopeError(responseWriter http.ResponseWriter, httpStatusCode int, message string) {
responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(httpStatusCode)
_, _ = responseWriter.Write([]byte(`{"message":"` + message + `"}`))
}
package auth0
import (
"fmt"
"net/http"
"net/url"
"time"
jwtmiddleware "github.com/auth0/go-jwt-middleware/v2"
"github.com/auth0/go-jwt-middleware/v2/jwks"
"github.com/auth0/go-jwt-middleware/v2/validator"
"github.com/gorilla/mux"
auth0_config "github.com/greencoda/auth0-api-gateway/internal/config/auth0"
)
const jwtCacheTTL = time.Duration(5 * time.Minute)
type IAuth0TokenValidator interface {
Handler() mux.MiddlewareFunc
}
type Auth0TokenValidator struct {
middlewareFunc mux.MiddlewareFunc
}
func (a *Auth0TokenValidator) Handler() mux.MiddlewareFunc {
return a.middlewareFunc
}
func buildJWTMiddlewareFunc(config auth0_config.Config) (mux.MiddlewareFunc, error) {
issuerURL, err := url.Parse("https://" + config.Domain + "/")
if err != nil {
return nil, fmt.Errorf("failed to parse the issuer url: %w", err)
}
var (
audiences []string
cachingProvider = jwks.NewCachingProvider(issuerURL, jwtCacheTTL)
)
if config.Audience != "" {
audiences = []string{config.Audience}
}
jwtValidator, err := validator.New(
cachingProvider.KeyFunc,
validator.RS256,
issuerURL.String(),
audiences,
validator.WithCustomClaims(
func() validator.CustomClaims {
return &CustomAuth0Claims{}
},
),
validator.WithAllowedClockSkew(time.Minute),
)
if err != nil {
return nil, fmt.Errorf("failed to set up the jwt validator: %w", err)
}
errorHandler := func(responseWriter http.ResponseWriter, req *http.Request, err error) {
responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(http.StatusUnauthorized)
_, _ = responseWriter.Write([]byte(`{"message":"Failed to validate JWT: ` + err.Error() + `"}`))
}
return jwtmiddleware.New(
jwtValidator.ValidateToken,
jwtmiddleware.WithErrorHandler(errorHandler),
).CheckJWT, nil
}
package auth0
import (
auth0_config "github.com/greencoda/auth0-api-gateway/internal/config/auth0"
subrouter_config "github.com/greencoda/auth0-api-gateway/internal/config/subrouter"
)
type IAuth0ValidatorFactory interface {
NewAuth0ScopeValidator(config subrouter_config.AuthorizationConfig) IAuth0ScopeValidator
NewAuth0TokenValidator(config auth0_config.Config) (IAuth0TokenValidator, error)
}
func NewAuth0ValidatorFactory() IAuth0ValidatorFactory {
return &Auth0ValidatorFactory{}
}
type Auth0ValidatorFactory struct{}
func (a *Auth0ValidatorFactory) NewAuth0ScopeValidator(config subrouter_config.AuthorizationConfig) IAuth0ScopeValidator {
return &Auth0ScopeValidator{
middlewareFunc: buildAuth0ScopeMiddlewareFunc(config),
}
}
func (a *Auth0ValidatorFactory) NewAuth0TokenValidator(config auth0_config.Config) (IAuth0TokenValidator, error) {
jwtMiddlewareFunc, err := buildJWTMiddlewareFunc(config)
if err != nil {
return nil, err
}
return &Auth0TokenValidator{
middlewareFunc: jwtMiddlewareFunc,
}, nil
}
package cors
import (
"github.com/gorilla/mux"
)
// ICORS interface defines the method to get the CORS middleware handler.
type ICORS interface {
Handler() mux.MiddlewareFunc
}
// CORS implements the ICORS interface and provides the CORS middleware handler.
type CORS struct {
middlewareFunc mux.MiddlewareFunc
}
// Handler returns the CORS middleware function.
func (c *CORS) Handler() mux.MiddlewareFunc {
return c.middlewareFunc
}
package cors
import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
subrouter_config "github.com/greencoda/auth0-api-gateway/internal/config/subrouter"
)
// Factory interface for creating CORS middleware for subrouters.
type ICORSFactory interface {
NewCORS(config subrouter_config.CORSConfig) ICORS
}
// CORSFactory implements the ICORSFactory interface to create CORS middleware.
type CORSFactory struct{}
// NewCORS creates a new CORS middleware based on the provided configuration.
func NewCORSFactory() ICORSFactory {
return &CORSFactory{}
}
// NewCORS creates a new CORS middleware based on the provided configuration.
func (c *CORSFactory) NewCORS(config subrouter_config.CORSConfig) ICORS {
return &CORS{
middlewareFunc: c.buildCORSMiddlewareFunc(config),
}
}
func (c *CORSFactory) buildCORSMiddlewareFunc(config subrouter_config.CORSConfig) mux.MiddlewareFunc {
corsOptions := []handlers.CORSOption{
handlers.AllowedOrigins(config.AllowedOrigins),
handlers.AllowedHeaders(config.AllowedHeaders),
handlers.AllowedMethods(config.AllowedMethods),
handlers.ExposedHeaders(config.ExposedHeaders),
handlers.MaxAge(config.MaxAge),
}
if config.AllowCredentials {
corsOptions = append(corsOptions, handlers.AllowCredentials())
}
return handlers.CORS(corsOptions...)
}
package rateLimit
import (
"github.com/gorilla/mux"
subrouter_config "github.com/greencoda/auth0-api-gateway/internal/config/subrouter"
"github.com/ulule/limiter/v3"
"github.com/ulule/limiter/v3/drivers/middleware/stdlib"
"github.com/ulule/limiter/v3/drivers/store/memory"
)
type IRateLimit interface {
Handler() mux.MiddlewareFunc
}
type RateLimit struct {
middlewareFunc mux.MiddlewareFunc
}
func (c *RateLimit) Handler() mux.MiddlewareFunc {
return c.middlewareFunc
}
func buildRateLimiterFunc(config subrouter_config.RateLimitConfig) mux.MiddlewareFunc {
var (
limiterStore = memory.NewStore()
limiterRate = limiter.Rate{
Period: config.Period,
Limit: config.Limit,
}
)
middleware := stdlib.NewMiddleware(
limiter.New(
limiterStore,
limiterRate,
limiter.WithTrustForwardHeader(config.TrustForwardHeader),
),
)
return middleware.Handler
}
package rateLimit
import (
subrouter_config "github.com/greencoda/auth0-api-gateway/internal/config/subrouter"
)
type IRateLimitFactory interface {
NewRateLimit(config subrouter_config.RateLimitConfig) IRateLimit
}
type RateLimitFactory struct{}
func NewRateLimitFactory() IRateLimitFactory {
return &RateLimitFactory{}
}
func (r *RateLimitFactory) NewRateLimit(config subrouter_config.RateLimitConfig) IRateLimit {
return &RateLimit{
middlewareFunc: buildRateLimiterFunc(config),
}
}
package requestLogger
import (
"net/http"
"github.com/rs/zerolog"
"go.uber.org/fx"
)
type IRequestLogger interface {
Handler(h http.Handler) http.Handler
}
type RequestLogger struct {
logger zerolog.Logger
}
type RequestLoggerParams struct {
fx.In
Logger zerolog.Logger
}
func NewMiddleware(params RequestLoggerParams) IRequestLogger {
return &RequestLogger{
logger: params.Logger,
}
}
func (c *RequestLogger) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) {
c.logger.Info().
Str("method", req.Method).
Str("path", req.URL.Path).
Str("remote_addr", req.RemoteAddr).
Str("user_agent", req.UserAgent()).
Msg("Request received")
h.ServeHTTP(responseWriter, req)
})
}
package server
import (
"errors"
"fmt"
"net/http"
"net/url"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
auth0_config "github.com/greencoda/auth0-api-gateway/internal/config/auth0"
server_config "github.com/greencoda/auth0-api-gateway/internal/config/server"
subrouter_config "github.com/greencoda/auth0-api-gateway/internal/config/subrouter"
auth0_middleware "github.com/greencoda/auth0-api-gateway/internal/middleware/auth0"
cors_middleware "github.com/greencoda/auth0-api-gateway/internal/middleware/cors"
rateLimit_middleware "github.com/greencoda/auth0-api-gateway/internal/middleware/rateLimit"
requestLogger_middleware "github.com/greencoda/auth0-api-gateway/internal/middleware/requestLogger"
reverseProxy_util "github.com/greencoda/auth0-api-gateway/internal/util/reverseProxy"
"github.com/rs/zerolog"
"go.uber.org/fx"
)
var ErrFailedToCreateReverseProxyHandler = errors.New("failed to create reverse proxy handler")
type IReverseProxyHandler http.Handler
type ReverseProxyHandlerParams struct {
fx.In
Auth0Config *auth0_config.Config
ServerConfig *server_config.Config
SubrouterConfigs *subrouter_config.Config
Auth0MiddlewareFactory auth0_middleware.IAuth0ValidatorFactory
CORSMiddlewareFactory cors_middleware.ICORSFactory
RateLimitMiddlewareFactory rateLimit_middleware.IRateLimitFactory
RequestLoggerMiddleware requestLogger_middleware.IRequestLogger
Logger zerolog.Logger
}
func NewReverseProxyHandler(params ReverseProxyHandlerParams) (IReverseProxyHandler, error) {
router := mux.NewRouter()
if params.ServerConfig.LogRequests {
router.Use(params.RequestLoggerMiddleware.Handler)
params.Logger.Info().Msg("Request logging enabled")
}
auth0TokenValidatorMiddleware, err := params.Auth0MiddlewareFactory.NewAuth0TokenValidator(*params.Auth0Config)
if err != nil {
return nil, fmt.Errorf("failed to set up Auth0 token validator middleware: %w", err)
}
for _, subrouterConfig := range *params.SubrouterConfigs {
subRouter := router.PathPrefix(subrouterConfig.Prefix).Subrouter()
targetURL, err := url.Parse(subrouterConfig.TargetURL)
if err != nil {
return nil, fmt.Errorf("failed to parse target API URL '%s' of subrouter '%s': %w", subrouterConfig.TargetURL, subrouterConfig.Name, err)
}
if subrouterConfig.RateLimitConfig != nil {
rateLimiterMiddleware := params.RateLimitMiddlewareFactory.NewRateLimit(*subrouterConfig.RateLimitConfig)
subRouter.Use(rateLimiterMiddleware.Handler())
}
if subrouterConfig.CORSConfig != nil {
corsMiddleware := params.CORSMiddlewareFactory.NewCORS(*subrouterConfig.CORSConfig)
subRouter.Use(corsMiddleware.Handler())
}
if subrouterConfig.AuthorizationConfig != nil {
subRouter.Use(auth0TokenValidatorMiddleware.Handler())
if len(subrouterConfig.AuthorizationConfig.RequiredScopes) > 0 {
auth0ScopeValidatorMiddleware := params.Auth0MiddlewareFactory.NewAuth0ScopeValidator(*subrouterConfig.AuthorizationConfig)
if auth0ScopeValidatorMiddleware == nil {
return nil, ErrFailedToCreateReverseProxyHandler
}
subRouter.Use(auth0ScopeValidatorMiddleware.Handler())
}
}
if subrouterConfig.GZip {
subRouter.Use(handlers.CompressHandler)
}
reverseProxy := reverseProxy_util.NewReverseProxy(targetURL)
var subRouterHandler http.Handler = reverseProxy
if subrouterConfig.StripPrefix {
subRouterHandler = http.StripPrefix(subrouterConfig.Prefix, subRouterHandler)
}
subRouter.NewRoute().Handler(
subRouterHandler,
)
params.Logger.Info().Msgf("Subrouter '%s' (%s) set up with target URL: %s", subrouterConfig.Name, subrouterConfig.Prefix, subrouterConfig.TargetURL)
}
return router, nil
}
package server
import (
"log"
"net/http"
server_config "github.com/greencoda/auth0-api-gateway/internal/config/server"
"github.com/rs/zerolog"
"go.uber.org/fx"
)
type ServerParams struct {
fx.In
ServerConfig *server_config.Config
ReverseProxyHandler IReverseProxyHandler
Logger zerolog.Logger
}
func NewServer(params ServerParams) (*http.Server, error) {
stdLogger := log.New(params.Logger, "", 0)
return &http.Server{
Handler: params.ReverseProxyHandler,
Addr: params.ServerConfig.Address,
ReadTimeout: params.ServerConfig.ReadTimeout,
WriteTimeout: params.ServerConfig.WriteTimeout,
IdleTimeout: params.ServerConfig.IdleTimeout,
MaxHeaderBytes: params.ServerConfig.MaxHeaderBytes,
ErrorLog: stdLogger,
}, nil
}
package config
import (
"errors"
"github.com/greencoda/confiq"
yaml_loader "github.com/greencoda/confiq/loaders/yaml"
)
var ErrNoConfigSet = errors.New("configSet is nil")
type ConfigFilename string
func LoadConfigYAML(configFilename ConfigFilename) (*confiq.ConfigSet, error) {
configSet := confiq.New()
if err := configSet.Load(
yaml_loader.Load().FromFile(string(configFilename)),
); err != nil {
return nil, err
}
return configSet, nil
}
func LoadConfigFromSetWithPrefix[Config any](configSet *confiq.ConfigSet, prefix string) (*Config, error) {
if configSet == nil {
return nil, ErrNoConfigSet
}
var (
config = new(Config)
decodeOptions = confiq.DecodeOptions{
confiq.AsStrict(),
}
)
if prefix != "" {
decodeOptions = append(decodeOptions, confiq.FromPrefix(prefix))
}
err := configSet.Decode(config, decodeOptions...)
if err != nil {
return nil, err
}
return config, nil
}
package logging
import (
fxzerolog "github.com/efectn/fx-zerolog"
server_config "github.com/greencoda/auth0-api-gateway/internal/config/server"
"github.com/rs/zerolog"
"go.uber.org/fx/fxevent"
)
func NewFXLogger(
config *server_config.Config,
logger zerolog.Logger,
) fxevent.Logger {
if config.ReleaseStage != "local" {
return fxevent.NopLogger
}
return &fxzerolog.ZeroLogger{Logger: logger}
}
package logging
import (
"os"
"strings"
server_config "github.com/greencoda/auth0-api-gateway/internal/config/server"
"github.com/rs/zerolog"
)
func NewZeroLogger(config *server_config.Config) zerolog.Logger {
logger := setLogLevel(
zerolog.New(os.Stdout).
With().
Timestamp().
Logger(),
config.LogLevel,
)
if config.ReleaseStage != "local" {
return logger
}
return logger.Output(zerolog.ConsoleWriter{
TimeFormat: zerolog.TimeFieldFormat,
Out: os.Stderr,
})
}
func setLogLevel(logger zerolog.Logger, logLevel string) zerolog.Logger {
switch strings.ToLower(logLevel) {
case "debug":
return logger.Level(zerolog.DebugLevel)
case "info":
return logger.Level(zerolog.InfoLevel)
case "warn":
return logger.Level(zerolog.WarnLevel)
case "error":
return logger.Level(zerolog.ErrorLevel)
case "fatal":
return logger.Level(zerolog.FatalLevel)
case "panic":
return logger.Level(zerolog.PanicLevel)
}
return logger
}
package reverseProxy
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
func NewReverseProxy(target *url.URL) *httputil.ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL)
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}
return &httputil.ReverseProxy{Director: director}
}
func joinPathsWithSlash(pathA, pathB string) string {
var pathSegments []string
for _, path := range []string{pathA, pathB} {
pathTrimmed := strings.Trim(path, "/")
if len(pathTrimmed) > 0 {
pathSegments = append(pathSegments, pathTrimmed)
}
}
return "/" + strings.Join(pathSegments, "/")
}
func joinURLPath(urlA, urlB *url.URL) (path, rawpath string) {
if urlA.RawPath == "" && urlB.RawPath == "" {
return joinPathsWithSlash(urlA.Path, urlB.Path), ""
}
return joinPathsWithSlash(urlA.EscapedPath(), urlB.EscapedPath()), ""
}