diff --git a/cmd/wonderwall/main.go b/cmd/wonderwall/main.go index 246c017..a300d59 100644 --- a/cmd/wonderwall/main.go +++ b/cmd/wonderwall/main.go @@ -13,7 +13,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-redis/redis/v8" - "github.com/lestrrat-go/jwx/jwk" "github.com/nais/liberator/pkg/conftools" "github.com/nais/liberator/pkg/keygen" log "github.com/sirupsen/logrus" @@ -22,23 +21,23 @@ import ( "github.com/nais/wonderwall/pkg/cryptutil" "github.com/nais/wonderwall/pkg/logging" "github.com/nais/wonderwall/pkg/metrics" + "github.com/nais/wonderwall/pkg/provider" "github.com/nais/wonderwall/pkg/router" "github.com/nais/wonderwall/pkg/session" ) var maskedConfig = []string{ - config.IDPortenClientJWK, + config.OpenIDClientJWK, config.EncryptionKey, config.RedisPassword, } func run() error { - cfg := config.Initialize() - if err := conftools.Load(cfg); err != nil { + cfg, err := config.Initialize() + if err != nil { return err } - - if err := cfg.FetchWellKnownConfig(); err != nil { + if err := conftools.Load(cfg); err != nil { return err } diff --git a/pkg/config/azure.go b/pkg/config/azure.go new file mode 100644 index 0000000..db92e81 --- /dev/null +++ b/pkg/config/azure.go @@ -0,0 +1,31 @@ +package config + +import ( + "github.com/spf13/viper" + + "github.com/nais/wonderwall/pkg/openid" + "github.com/nais/wonderwall/pkg/scopes" +) + +type azure struct { + *BaseConfig +} + +func azureFlags() { + viper.BindEnv(OpenIDClientID, "AZURE_APP_CLIENT_ID") + viper.BindEnv(OpenIDClientJWK, "AZURE_APP_JWK") + viper.BindEnv(OpenIDRedirectURI, "AZURE_APP_REDIRECT_URI") + viper.BindEnv(OpenIDWellKnownURL, "AZURE_APP_WELL_KNOWN_URL") +} + +func (in *BaseConfig) Azure() openid.ClientConfiguration { + return &azure{ + BaseConfig: in, + } +} + +func (in *azure) GetScopes() scopes.Scopes { + return scopes.Defaults(). + WithAzureScope(in.ClientID). + WithAdditional(in.Scopes...) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 39f3284..841748f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,113 +6,77 @@ import ( "github.com/nais/liberator/pkg/conftools" flag "github.com/spf13/pflag" "github.com/spf13/viper" - - "github.com/nais/wonderwall/pkg/token" ) type Config struct { - BindAddress string `json:"bind-address"` - MetricsBindAddress string `json:"metrics-bind-address"` - UpstreamHost string `json:"upstream-host"` - EncryptionKey string `json:"encryption-key"` - IDPorten IDPorten `json:"idporten"` - LogFormat string `json:"log-format"` - LogLevel string `json:"log-level"` - Redis Redis `json:"redis"` - Ingress string `json:"ingress"` - ErrorRedirectURI string `json:"error-redirect-uri"` - AutoLogin bool `json:"auto-login"` -} + BindAddress string `json:"bind-address"` + LogFormat string `json:"log-format"` + LogLevel string `json:"log-level"` + MetricsBindAddress string `json:"metrics-bind-address"` -type Redis struct { - Address string `json:"address"` - Username string `json:"username"` - Password string `json:"password"` - TLS bool `json:"tls"` -} + AutoLogin bool `json:"auto-login"` + EncryptionKey string `json:"encryption-key"` + ErrorRedirectURI string `json:"error-redirect-uri"` + Ingress string `json:"ingress"` + SessionMaxLifetime time.Duration `json:"session-max-lifetime"` + UpstreamHost string `json:"upstream-host"` -type IDPorten struct { - ClientID string `json:"client-id"` - ClientJWK string `json:"client-jwk"` - RedirectURI string `json:"redirect-uri"` - WellKnownURL string `json:"well-known-url"` - WellKnown IDPortenWellKnown `json:"well-known"` - Locale IDPortenLocale `json:"locale"` - SecurityLevel IDPortenSecurityLevel `json:"security-level"` - PostLogoutRedirectURI string `json:"post-logout-redirect-uri"` - Scopes []string `json:"scopes"` - SessionMaxLifetime time.Duration `json:"session-max-lifetime"` -} - -type IDPortenSecurityLevel struct { - Enabled bool `json:"enabled"` - Value string `json:"value"` -} - -type IDPortenLocale struct { - Enabled bool `json:"enabled"` - Value string `json:"value"` + OpenID OpenID `json:"openid"` + Redis Redis `json:"redis"` } const ( - BindAddress = "bind-address" - MetricsBindAddress = "metrics-bind-address" - UpstreamHost = "upstream-host" - LogFormat = "log-format" - LogLevel = "log-level" - EncryptionKey = "encryption-key" - RedisAddress = "redis.address" - RedisUsername = "redis.username" - RedisPassword = "redis.password" - RedisTLS = "redis.tls" - Ingress = "ingress" - ErrorRedirectURI = "error-redirect-uri" - AutoLogin = "auto-login" - IDPortenClientID = "idporten.client-id" - IDPortenClientJWK = "idporten.client-jwk" - IDPortenRedirectURI = "idporten.redirect-uri" - IDPortenWellKnownURL = "idporten.well-known-url" - IDPortenLocaleEnabled = "idporten.locale.enabled" - IDPortenLocaleValue = "idporten.locale.value" - IDPortenSecurityLevelEnabled = "idporten.security-level.enabled" - IDPortenSecurityLevelValue = "idporten.security-level.value" - IDPortenPostLogoutRedirectURI = "idporten.post-logout-redirect-uri" - IDPortenScopes = "idporten.scopes" - IDPortenSessionMaxLifetime = "idporten.session-max-lifetime" + BindAddress = "bind-address" + LogFormat = "log-format" + LogLevel = "log-level" + MetricsBindAddress = "metrics-bind-address" + + AutoLogin = "auto-login" + EncryptionKey = "encryption-key" + ErrorRedirectURI = "error-redirect-uri" + Ingress = "ingress" + SessionMaxLifetime = "session-max-lifetime" + UpstreamHost = "upstream-host" ) -func bindNAIS() { - viper.BindEnv(IDPortenClientID, "IDPORTEN_CLIENT_ID") - viper.BindEnv(IDPortenClientJWK, "IDPORTEN_CLIENT_JWK") - viper.BindEnv(IDPortenRedirectURI, "IDPORTEN_REDIRECT_URI") - viper.BindEnv(IDPortenWellKnownURL, "IDPORTEN_WELL_KNOWN_URL") -} - -func Initialize() *Config { +func Initialize() (*Config, error) { conftools.Initialize("wonderwall") - bindNAIS() + flag.String(BindAddress, "127.0.0.1:8090", "Listen address for public connections.") flag.String(LogFormat, "json", "Log format, either 'json' or 'text'.") flag.String(LogLevel, "debug", "Logging verbosity level.") - flag.String(BindAddress, "127.0.0.1:8090", "Listen address for public connections.") flag.String(MetricsBindAddress, "127.0.0.1:8091", "Listen address for metrics only.") - flag.String(UpstreamHost, "127.0.0.1:8080", "Address of upstream host.") - flag.String(EncryptionKey, "", "Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store.") - flag.String(RedisAddress, "", "Address of Redis. An empty value will use in-memory session storage.") - flag.String(RedisUsername, "", "Username for Redis.") - flag.String(RedisPassword, "", "Password for Redis.") - flag.Bool(RedisTLS, true, "Whether or not to use TLS for connecting to Redis.") - flag.String(Ingress, "/", "Ingress used to access the main application.") - flag.String(ErrorRedirectURI, "", "URI to redirect user to on errors for custom error handling.") + flag.Bool(AutoLogin, false, "Automatically redirect user to login if the user does not have a valid session for all proxied downstream requests.") + flag.String(EncryptionKey, "", "Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store.") + flag.String(ErrorRedirectURI, "", "URI to redirect user to on errors for custom error handling.") + flag.String(Ingress, "/", "Ingress used to access the main application.") + flag.Duration(SessionMaxLifetime, time.Hour, "Max lifetime for user sessions.") + flag.String(UpstreamHost, "127.0.0.1:8080", "Address of upstream host.") - flag.Bool(IDPortenSecurityLevelEnabled, true, "Toggle for setting the sceurity level (acr_values) parameter for authorization requests.") - flag.String(IDPortenSecurityLevelValue, "Level4", "Requested security level, either Level3 or Level4.") - flag.Bool(IDPortenLocaleEnabled, true, "Toggle for setting the locale parameter for authorization requests.") - flag.String(IDPortenLocaleValue, "nb", "Locale for OAuth2 consent screen.") - flag.String(IDPortenPostLogoutRedirectURI, "https://www.nav.no", "URI for redirecting the user after successful logout at IDPorten.") - flag.StringSlice(IDPortenScopes, []string{token.ScopeOpenID}, "List of scopes that should be used during the Auth Code flow.") - flag.Duration(IDPortenSessionMaxLifetime, time.Hour, "Max lifetime for user sessions.") + redisFlags() + openIDFlags() - return &Config{} + flag.String(OpenIDProvider, string(ProviderOpenID), "Provider configuration to load and use, either 'openid', 'azure', 'idporten'.") + flag.Parse() + + if err := viper.ReadInConfig(); err != nil { + if err.(viper.ConfigFileNotFoundError) != err { + return nil, err + } + } + if err := viper.BindPFlags(flag.CommandLine); err != nil { + return nil, err + } + + switch Provider(viper.GetString(OpenIDProvider)) { + case ProviderIDPorten: + idportenFlags() + case ProviderAzure: + azureFlags() + default: + viper.Set(OpenIDProvider, ProviderOpenID) + } + + return &Config{}, nil } diff --git a/pkg/config/idporten.go b/pkg/config/idporten.go new file mode 100644 index 0000000..cb303de --- /dev/null +++ b/pkg/config/idporten.go @@ -0,0 +1,30 @@ +package config + +import ( + "github.com/spf13/viper" + + "github.com/nais/wonderwall/pkg/openid" +) + +type idporten struct { + *BaseConfig +} + +func idportenFlags() { + viper.BindEnv(OpenIDClientID, "IDPORTEN_CLIENT_ID") + viper.BindEnv(OpenIDClientJWK, "IDPORTEN_CLIENT_JWK") + viper.BindEnv(OpenIDRedirectURI, "IDPORTEN_REDIRECT_URI") + viper.BindEnv(OpenIDWellKnownURL, "IDPORTEN_WELL_KNOWN_URL") + + viper.SetDefault(OpenIDPostLogoutRedirectURI, "https://www.nav.no") + viper.SetDefault(OpenIDACRValuesEnabled, true) + viper.SetDefault(OpenIDACRValuesValue, "Level4") + viper.SetDefault(OpenIDUILocalesEnabled, true) + viper.SetDefault(OpenIDUILocalesValue, "nb") +} + +func (in *BaseConfig) IDPorten() openid.ClientConfiguration { + return &idporten{ + BaseConfig: in, + } +} diff --git a/pkg/config/openid.go b/pkg/config/openid.go new file mode 100644 index 0000000..d96fbf1 --- /dev/null +++ b/pkg/config/openid.go @@ -0,0 +1,107 @@ +package config + +import ( + "github.com/lestrrat-go/jwx/jwk" + flag "github.com/spf13/pflag" + + "github.com/nais/wonderwall/pkg/openid" + "github.com/nais/wonderwall/pkg/scopes" +) + +const ( + OpenIDProvider = "openid.provider" + OpenIDClientID = "openid.client-id" + OpenIDClientJWK = "openid.client-jwk" + OpenIDPostLogoutRedirectURI = "openid.post-logout-redirect-uri" + OpenIDRedirectURI = "openid.redirect-uri" + OpenIDScopes = "openid.scopes" + OpenIDWellKnownURL = "openid.well-known-url" + OpenIDACRValuesEnabled = "openid.acr-values.enabled" + OpenIDACRValuesValue = "openid.acr-values.value" + OpenIDUILocalesEnabled = "openid.ui-locales.enabled" + OpenIDUILocalesValue = "openid.ui-locales.value" +) + +type OpenID struct { + Provider Provider `json:"provider"` + ClientID string `json:"client-id"` + ClientJWK string `json:"client-jwk"` + PostLogoutRedirectURI string `json:"post-logout-redirect-uri"` + RedirectURI string `json:"redirect-uri"` + Scopes []string `json:"scopes"` + WellKnownURL string `json:"well-known-url"` + ACRValues openid.OptionalConfiguration `json:"acr-values"` + UILocales openid.OptionalConfiguration `json:"ui-locales"` +} + +type Provider string + +const ( + ProviderAzure Provider = "azure" + ProviderIDPorten Provider = "idporten" + ProviderOpenID Provider = "openid" +) + +type BaseConfig struct { + OpenID + clientJwk jwk.Key +} + +func (in *BaseConfig) GetRedirectURI() string { + return in.RedirectURI +} + +func (in *BaseConfig) GetClientID() string { + return in.ClientID +} + +func (in *BaseConfig) GetClientJWK() jwk.Key { + return in.clientJwk +} + +func (in *BaseConfig) GetPostLogoutRedirectURI() string { + return in.PostLogoutRedirectURI +} + +func (in *BaseConfig) GetScopes() scopes.Scopes { + return scopes.Defaults().WithAdditional(in.Scopes...) +} + +func (in *BaseConfig) GetACRValues() openid.OptionalConfiguration { + return openid.OptionalConfiguration{ + Enabled: in.ACRValues.Enabled, + Value: in.ACRValues.Value, + } +} + +func (in *BaseConfig) GetUILocales() openid.OptionalConfiguration { + return openid.OptionalConfiguration{ + Enabled: in.UILocales.Enabled, + Value: in.UILocales.Value, + } +} + +func (in *BaseConfig) GetWellKnownURL() string { + return in.WellKnownURL +} + +func (c *Config) NewBaseConfig(clientJwk jwk.Key) *BaseConfig { + return &BaseConfig{ + OpenID: c.OpenID, + clientJwk: clientJwk, + } +} + +func openIDFlags() { + flag.String(OpenIDClientID, "", "Client ID for the OpenID client.") + flag.String(OpenIDClientJWK, "", "JWK containing the private key for the OpenID client in string format.") + flag.String(OpenIDPostLogoutRedirectURI, "", "URI for redirecting the user after successful logout at the Identity Provider.") + flag.String(OpenIDRedirectURI, "", "Redirect URI for the OpenID client that should be used in authorization requests.") + flag.StringSlice(OpenIDScopes, []string{}, "List of additional scopes (other than 'openid') that should be used during the login flow.") + flag.String(OpenIDWellKnownURL, "", "URI to the well-known OpenID Configuration metadata document.") + + flag.Bool(OpenIDACRValuesEnabled, false, "Toggle for setting the security level (acr_values) parameter for authorization requests.") + flag.String(OpenIDACRValuesValue, "", "Space separated string that configures the requested acr_values.") + flag.Bool(OpenIDUILocalesEnabled, false, "Toggle for setting the UI locale parameter for authorization requests.") + flag.String(OpenIDUILocalesValue, "", "Space-separated string that configures the default locales for OAuth2 consent screen.") +} diff --git a/pkg/config/redis.go b/pkg/config/redis.go new file mode 100644 index 0000000..24301f0 --- /dev/null +++ b/pkg/config/redis.go @@ -0,0 +1,26 @@ +package config + +import ( + flag "github.com/spf13/pflag" +) + +const ( + RedisAddress = "redis.address" + RedisPassword = "redis.password" + RedisTLS = "redis.tls" + RedisUsername = "redis.username" +) + +type Redis struct { + Address string `json:"address"` + Username string `json:"username"` + Password string `json:"password"` + TLS bool `json:"tls"` +} + +func redisFlags() { + flag.String(RedisAddress, "", "Address of Redis. An empty value will use in-memory session storage.") + flag.String(RedisPassword, "", "Password for Redis.") + flag.Bool(RedisTLS, true, "Whether or not to use TLS for connecting to Redis.") + flag.String(RedisUsername, "", "Username for Redis.") +} diff --git a/pkg/openid/configuration.go b/pkg/openid/configuration.go index a1bc17e..3b6f641 100644 --- a/pkg/openid/configuration.go +++ b/pkg/openid/configuration.go @@ -46,8 +46,8 @@ func (in Supported) Contains(value string) bool { return false } -func FetchWellKnownConfig(wellKnownURI string) (*Configuration, error) { - response, err := http.Get(wellKnownURI) +func FetchWellKnownConfig(config ClientConfiguration) (*Configuration, error) { + response, err := http.Get(config.GetWellKnownURL()) if err != nil { return nil, fmt.Errorf("fetching well known configuration: %w", err) } diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 3aea6ee..f1738aa 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -48,9 +48,9 @@ func NewProvider(cfg *config.Config) (Provider, error) { baseConfig := cfg.NewBaseConfig(clientJwk) var clientConfig openid.ClientConfiguration switch cfg.OpenID.Provider { - case "idporten": + case config.ProviderIDPorten: clientConfig = baseConfig.IDPorten() - case "azure": + case config.ProviderAzure: clientConfig = baseConfig.Azure() case "": return nil, fmt.Errorf("missing required config %s", config.OpenIDProvider) @@ -70,7 +70,7 @@ func NewProvider(cfg *config.Config) (Provider, error) { return nil, fmt.Errorf("missing required config %s", config.OpenIDRedirectURI) } - configuration, err := openid.FetchWellKnownConfig(clientConfig.GetWellKnownURL()) + configuration, err := openid.FetchWellKnownConfig(clientConfig) if err != nil { return nil, fmt.Errorf("fetching well known config: %w", err) } diff --git a/pkg/router/errorhandler.go b/pkg/router/errorhandler.go index ee1286d..c12c034 100644 --- a/pkg/router/errorhandler.go +++ b/pkg/router/errorhandler.go @@ -1,13 +1,16 @@ package router import ( - "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/httplog" - "github.com/nais/wonderwall/pkg/request" "html/template" "net/http" "net/url" "strconv" + + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httplog" + log "github.com/sirupsen/logrus" + + "github.com/nais/wonderwall/pkg/request" ) type ErrorPage struct { @@ -32,7 +35,12 @@ func (h *Handler) respondError(w http.ResponseWriter, r *http.Request, statusCod func (h *Handler) defaultErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int) { w.WriteHeader(statusCode) - t, _ := template.ParseFiles("templates/error.html") + t, err := template.ParseFiles("templates/error.html") + if err != nil { + log.Errorf("parsing error template: %+v", err) + return + } + loginCookie, err := h.getLoginCookie(r) if err != nil { loginCookie = nil diff --git a/pkg/router/handler_default.go b/pkg/router/handler_default.go index 4526648..fd77096 100644 --- a/pkg/router/handler_default.go +++ b/pkg/router/handler_default.go @@ -22,7 +22,7 @@ func (h *Handler) Default(w http.ResponseWriter, r *http.Request) { } director := func(upstreamRequest *http.Request) { - modifyRequest(upstreamRequest, r, h.UpstreamHost) + modifyRequest(upstreamRequest, r, h.Config.UpstreamHost) if isAuthenticated { withAuthentication(upstreamRequest, sessionData) diff --git a/pkg/router/session.go b/pkg/router/session.go index d740b73..7ee67cc 100644 --- a/pkg/router/session.go +++ b/pkg/router/session.go @@ -55,7 +55,7 @@ func (h *Handler) getSessionFromCookie(w http.ResponseWriter, r *http.Request) ( } func (h *Handler) getSessionLifetime(accessToken string) (time.Duration, error) { - defaultSessionLifetime := h.Config.IDPorten.SessionMaxLifetime + defaultSessionLifetime := h.Config.SessionMaxLifetime tok, err := jwt.Parse([]byte(accessToken)) if err != nil {