package server import ( "context" "encoding/json" "errors" "fmt" "io/fs" "log/slog" "net" "net/http" "net/netip" "net/url" "os" "path" "slices" "sort" "strings" "sync" "sync/atomic" "time" gosundheit "github.com/AppsFlyer/go-sundheit" "github.com/google/uuid" "github.com/gorilla/handlers" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/crypto/bcrypt" "github.com/dexidp/dex/connector" "github.com/dexidp/dex/connector/atlassiancrowd" "github.com/dexidp/dex/connector/authproxy" "github.com/dexidp/dex/connector/bitbucketcloud" "github.com/dexidp/dex/connector/gitea" "github.com/dexidp/dex/connector/github" "github.com/dexidp/dex/connector/gitlab" "github.com/dexidp/dex/connector/google" "github.com/dexidp/dex/connector/keystone" "github.com/dexidp/dex/connector/ldap" "github.com/dexidp/dex/connector/linkedin" "github.com/dexidp/dex/connector/microsoft" "github.com/dexidp/dex/connector/mock" "github.com/dexidp/dex/connector/oauth" "github.com/dexidp/dex/connector/oidc" "github.com/dexidp/dex/connector/openshift" "github.com/dexidp/dex/connector/saml" "github.com/dexidp/dex/pkg/featureflags" "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" "github.com/dexidp/dex/web" ) // LocalConnector is the local passwordDB connector which is an internal // connector maintained by the server. const LocalConnector = "local" // Connector is a connector with resource version metadata. type Connector struct { ResourceVersion string Connector connector.Connector GrantTypes []string } // GrantTypeAllowed checks if the given grant type is allowed for this connector. // If no grant types are configured, all are allowed. func GrantTypeAllowed(configuredTypes []string, grantType string) bool { return len(configuredTypes) == 0 || slices.Contains(configuredTypes, grantType) } // Config holds the server's configuration options. // // Multiple servers using the same storage are expected to be configured identically. type Config struct { Issuer string // The backing persistence layer. Storage storage.Storage AllowedGrantTypes []string // Valid values are "code" to enable the code flow and "token" to enable the implicit // flow. If no response types are supplied this value defaults to "code". SupportedResponseTypes []string // Headers is a map of headers to be added to the all responses. Headers http.Header // Header to extract real ip from. RealIPHeader string TrustedRealIPCIDRs []netip.Prefix // List of allowed origins for CORS requests on discovery, token and keys endpoint. // If none are indicated, CORS requests are disabled. Passing in "*" will allow any // domain. AllowedOrigins []string // List of allowed headers for CORS requests on discovery, token, and keys endpoint. AllowedHeaders []string // If enabled, the server won't prompt the user to approve authorization requests. // Logging in implies approval. SkipApprovalScreen bool // If enabled, the connectors selection page will always be shown even if there's only one AlwaysShowLoginScreen bool IDTokensValidFor time.Duration // Defaults to 24 hours AuthRequestsValidFor time.Duration // Defaults to 24 hours DeviceRequestsValidFor time.Duration // Defaults to 5 minutes // Refresh token expiration settings RefreshTokenPolicy *RefreshTokenPolicy // If set, the server will use this connector to handle password grants PasswordConnector string // PKCE configuration PKCE PKCEConfig GCFrequency time.Duration // Defaults to 5 minutes // If specified, the server will use this function for determining time. Now func() time.Time Web WebConfig Logger *slog.Logger // Signer is used to sign tokens. Signer signer.Signer PrometheusRegistry *prometheus.Registry HealthChecker gosundheit.Health // If enabled, the server will continue starting even if some connectors fail to initialize. // This allows the server to operate with a subset of connectors if some are misconfigured. ContinueOnConnectorFailure bool } // WebConfig holds the server's frontend templates and asset configuration. type WebConfig struct { // A file path to static web assets. // // It is expected to contain the following directories: // // * static - Static static served at "( issuer URL )/static". // * templates - HTML templates controlled by dex. // * themes/(theme) - Static static served at "( issuer URL )/theme". Dir string // Alternative way to programmatically configure static web assets. // If Dir is specified, WebFS is ignored. // It's expected to contain the same files and directories as mentioned above. // // Note: this is experimental. Might get removed without notice! WebFS fs.FS // Defaults to "( issuer URL )/theme/logo.png" LogoURL string // Defaults to "dex" Issuer string // Defaults to "light" Theme string // Map of extra values passed into the templates Extra map[string]string } // PKCEConfig holds PKCE (Proof Key for Code Exchange) settings. type PKCEConfig struct { // If true, PKCE is required for all authorization code flows. Enforce bool // Supported code challenge methods. Defaults to ["S256", "plain"]. CodeChallengeMethodsSupported []string } func value(val, defaultValue time.Duration) time.Duration { if val == 0 { return defaultValue } return val } // Server is the top level object. type Server struct { issuerURL url.URL // mutex for the connectors map. mu sync.Mutex // Map of connector IDs to connectors. connectors map[string]Connector storage storage.Storage mux http.Handler templates *templates // If enabled, don't prompt user for approval after logging in through connector. skipApproval bool // If enabled, show the connector selection screen even if there's only one alwaysShowLogin bool // Used for password grant passwordConnector string supportedResponseTypes map[string]bool supportedGrantTypes []string pkce PKCEConfig now func() time.Time idTokensValidFor time.Duration authRequestsValidFor time.Duration deviceRequestsValidFor time.Duration refreshTokenPolicy *RefreshTokenPolicy logger *slog.Logger signer signer.Signer } // NewServer constructs a server from the provided config. func NewServer(ctx context.Context, c Config) (*Server, error) { return newServer(ctx, c) } func newServer(ctx context.Context, c Config) (*Server, error) { issuerURL, err := url.Parse(c.Issuer) if err != nil { return nil, fmt.Errorf("server: can't parse issuer URL") } if c.Storage == nil { return nil, errors.New("server: storage cannot be nil") } if len(c.SupportedResponseTypes) == 0 { c.SupportedResponseTypes = []string{responseTypeCode} } if len(c.AllowedHeaders) == 0 { c.AllowedHeaders = []string{"Authorization"} } supportedChallengeMethods := map[string]bool{ codeChallengeMethodS256: true, codeChallengeMethodPlain: true, } if len(c.PKCE.CodeChallengeMethodsSupported) == 0 { c.PKCE.CodeChallengeMethodsSupported = []string{codeChallengeMethodS256, codeChallengeMethodPlain} } for _, m := range c.PKCE.CodeChallengeMethodsSupported { if !supportedChallengeMethods[m] { return nil, fmt.Errorf("unsupported PKCE challenge method %q", m) } } allSupportedGrants := map[string]bool{ grantTypeAuthorizationCode: true, grantTypeRefreshToken: true, grantTypeDeviceCode: true, grantTypeTokenExchange: true, } supportedRes := make(map[string]bool) for _, respType := range c.SupportedResponseTypes { switch respType { case responseTypeCode, responseTypeIDToken, responseTypeCodeIDToken: // continue case responseTypeToken, responseTypeCodeToken, responseTypeIDTokenToken, responseTypeCodeIDTokenToken: // response_type=token is an implicit flow, let's add it to the discovery info // https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.1 allSupportedGrants[grantTypeImplicit] = true default: return nil, fmt.Errorf("unsupported response_type %q", respType) } supportedRes[respType] = true } if c.PasswordConnector != "" { allSupportedGrants[grantTypePassword] = true } allSupportedGrants[grantTypeClientCredentials] = true var supportedGrants []string if len(c.AllowedGrantTypes) > 0 { for _, grant := range c.AllowedGrantTypes { if allSupportedGrants[grant] { supportedGrants = append(supportedGrants, grant) } } } else { for grant := range allSupportedGrants { supportedGrants = append(supportedGrants, grant) } } sort.Strings(supportedGrants) webFS := web.FS() if c.Web.Dir != "" { webFS = os.DirFS(c.Web.Dir) } else if c.Web.WebFS != nil { webFS = c.Web.WebFS } web := webConfig{ webFS: webFS, logoURL: c.Web.LogoURL, issuerURL: c.Issuer, issuer: c.Web.Issuer, theme: c.Web.Theme, extra: c.Web.Extra, } static, theme, robots, tmpls, err := loadWebConfig(web) if err != nil { return nil, fmt.Errorf("server: failed to load web static: %v", err) } now := c.Now if now == nil { now = time.Now } s := &Server{ issuerURL: *issuerURL, connectors: make(map[string]Connector), storage: newKeyCacher(c.Storage, now), supportedResponseTypes: supportedRes, supportedGrantTypes: supportedGrants, pkce: c.PKCE, idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute), refreshTokenPolicy: c.RefreshTokenPolicy, skipApproval: c.SkipApprovalScreen, alwaysShowLogin: c.AlwaysShowLoginScreen, now: now, templates: tmpls, passwordConnector: c.PasswordConnector, logger: c.Logger, signer: c.Signer, } // Retrieves connector objects in backend storage. This list includes the static connectors // defined in the ConfigMap and dynamic connectors retrieved from the storage. storageConnectors, err := c.Storage.ListConnectors(ctx) if err != nil { return nil, fmt.Errorf("server: failed to list connector objects from storage: %v", err) } if len(storageConnectors) == 0 && len(s.connectors) == 0 { return nil, errors.New("server: no connectors specified") } var failedCount int for _, conn := range storageConnectors { if _, err := s.OpenConnector(conn); err != nil { failedCount++ if c.ContinueOnConnectorFailure { s.logger.Error("server: Failed to open connector", "id", conn.ID, "err", err) continue } return nil, fmt.Errorf("server: Failed to open connector %s: %v", conn.ID, err) } } if c.ContinueOnConnectorFailure && failedCount == len(storageConnectors) { return nil, fmt.Errorf("server: failed to open all connectors (%d/%d)", failedCount, len(storageConnectors)) } if featureflags.SessionsEnabled.Enabled() { s.logger.InfoContext(ctx, "sessions feature flag is enabled") } instrumentHandler := func(_ string, handler http.Handler) http.HandlerFunc { return handler.ServeHTTP } if c.PrometheusRegistry != nil { requestCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "http_requests_total", Help: "Count of all HTTP requests.", }, []string{"code", "method", "handler"}) durationHist := prometheus.NewHistogramVec(prometheus.HistogramOpts{ Name: "request_duration_seconds", Help: "A histogram of latencies for requests.", Buckets: []float64{.25, .5, 1, 2.5, 5, 10}, }, []string{"code", "method", "handler"}) sizeHist := prometheus.NewHistogramVec(prometheus.HistogramOpts{ Name: "response_size_bytes", Help: "A histogram of response sizes for requests.", Buckets: []float64{200, 500, 900, 1500}, }, []string{"code", "method", "handler"}) c.PrometheusRegistry.MustRegister(requestCounter, durationHist, sizeHist) instrumentHandler = func(handlerName string, handler http.Handler) http.HandlerFunc { return promhttp.InstrumentHandlerDuration(durationHist.MustCurryWith(prometheus.Labels{"handler": handlerName}), promhttp.InstrumentHandlerCounter(requestCounter.MustCurryWith(prometheus.Labels{"handler": handlerName}), promhttp.InstrumentHandlerResponseSize(sizeHist.MustCurryWith(prometheus.Labels{"handler": handlerName}), handler), ), ) } } parseRealIP := func(r *http.Request) (string, error) { remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return "", err } remoteIP, err := netip.ParseAddr(remoteAddr) if err != nil { return "", err } for _, n := range c.TrustedRealIPCIDRs { if !n.Contains(remoteIP) { return remoteAddr, nil // Fallback to the address from the request if the header is provided } } ipVal := r.Header.Get(c.RealIPHeader) if ipVal != "" { ip, err := netip.ParseAddr(ipVal) if err == nil { return ip.String(), nil } } return remoteAddr, nil } handlerWithHeaders := func(handlerName string, handler http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { for k, v := range c.Headers { w.Header()[k] = v } // Context values are used for logging purposes with the log/slog logger. rCtx := r.Context() rCtx = WithRequestID(rCtx) if c.RealIPHeader != "" { realIP, err := parseRealIP(r) if err == nil { rCtx = WithRemoteIP(rCtx, realIP) } } r = r.WithContext(rCtx) instrumentHandler(handlerName, handler)(w, r) } } r := mux.NewRouter().SkipClean(true).UseEncodedPath() handle := func(p string, h http.Handler) { r.Handle(path.Join(issuerURL.Path, p), handlerWithHeaders(p, h)) } handleFunc := func(p string, h http.HandlerFunc) { handle(p, h) } handlePrefix := func(p string, h http.Handler) { prefix := path.Join(issuerURL.Path, p) r.PathPrefix(prefix).Handler(http.StripPrefix(prefix, h)) } handleWithCORS := func(p string, h http.HandlerFunc) { var handler http.Handler = h if len(c.AllowedOrigins) > 0 { cors := handlers.CORS( handlers.AllowedOrigins(c.AllowedOrigins), handlers.AllowedHeaders(c.AllowedHeaders), ) handler = cors(handler) } r.Handle(path.Join(issuerURL.Path, p), handlerWithHeaders(p, handler)) } r.NotFoundHandler = http.NotFoundHandler() discoveryHandler, err := s.discoveryHandler(ctx) if err != nil { return nil, err } handleWithCORS("/.well-known/openid-configuration", discoveryHandler) // Handle the root path for the better user experience. handleWithCORS("/", func(w http.ResponseWriter, r *http.Request) { _, err := fmt.Fprintf(w, `