mirror of
https://github.com/nais/wonderwall.git
synced 2026-05-06 16:36:51 +00:00
feat: add feature toggle for security level; allow user-defined levels
This commit is contained in:
@@ -24,16 +24,21 @@ type Config struct {
|
||||
}
|
||||
|
||||
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 string `json:"locale"`
|
||||
SecurityLevel string `json:"security-level"`
|
||||
PostLogoutRedirectURI string `json:"post-logout-redirect-uri"`
|
||||
Scopes []string `json:"scopes"`
|
||||
SessionMaxLifetime time.Duration `json:"session-max-lifetime"`
|
||||
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 string `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"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -50,7 +55,8 @@ const (
|
||||
IDPortenRedirectURI = "idporten.redirect-uri"
|
||||
IDPortenWellKnownURL = "idporten.well-known-url"
|
||||
IDPortenLocale = "idporten.locale"
|
||||
IDPortenSecurityLevel = "idporten.security-level"
|
||||
IDPortenSecurityLevelEnabled = "idporten.security-level.enabled"
|
||||
IDPortenSecurityLevelValue = "idporten.security-level.value"
|
||||
IDPortenPostLogoutRedirectURI = "idporten.post-logout-redirect-uri"
|
||||
IDPortenScopes = "idporten.scopes"
|
||||
IDPortenSessionMaxLifetime = "idporten.session-max-lifetime"
|
||||
@@ -74,7 +80,8 @@ func Initialize() *Config {
|
||||
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(Redis, "", "Address of Redis. An empty value will use in-memory session storage.")
|
||||
flag.String(IDPortenSecurityLevel, "Level4", "Requested security level, either Level3 or Level4.")
|
||||
flag.Bool(IDPortenSecurityLevelEnabled, true, "Set ID-Porten security level for authorization requests.")
|
||||
flag.String(IDPortenSecurityLevelValue, "Level4", "Requested security level, either Level3 or Level4.")
|
||||
flag.String(IDPortenLocale, "nb", "Locale for OAuth2 consent screen.")
|
||||
flag.String(IDPortenPostLogoutRedirectURI, "https://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.")
|
||||
|
||||
@@ -6,29 +6,40 @@ import (
|
||||
)
|
||||
|
||||
type IDPortenWellKnown struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ResponseModesSupported []string `json:"response_modes_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
UILocalesSupported []string `json:"ui_locales_supported"`
|
||||
ACRValuesSupported []string `json:"acr_values_supported"`
|
||||
FrontchannelLogoutSupported bool `json:"frontchannel_logout_supported"`
|
||||
FrontchannelLogoutSessionSupported bool `json:"frontchannel_logout_session_supported"`
|
||||
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
RequestParameterSupported bool `json:"request_parameter_supported"`
|
||||
RequestURIParameterSupported bool `json:"request_uri_parameter_supported"`
|
||||
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ResponseModesSupported []string `json:"response_modes_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
UILocalesSupported []string `json:"ui_locales_supported"`
|
||||
ACRValuesSupported ACRValuesSupported `json:"acr_values_supported"`
|
||||
FrontchannelLogoutSupported bool `json:"frontchannel_logout_supported"`
|
||||
FrontchannelLogoutSessionSupported bool `json:"frontchannel_logout_session_supported"`
|
||||
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
RequestParameterSupported bool `json:"request_parameter_supported"`
|
||||
RequestURIParameterSupported bool `json:"request_uri_parameter_supported"`
|
||||
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||
}
|
||||
|
||||
type ACRValuesSupported []string
|
||||
|
||||
func (in ACRValuesSupported) Contains(value string) bool {
|
||||
for _, allowed := range in {
|
||||
if allowed == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Config) FetchWellKnownConfig() error {
|
||||
|
||||
@@ -5,14 +5,16 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/nais/wonderwall/pkg/middleware"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nais/wonderwall/pkg/middleware"
|
||||
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
|
||||
"github.com/nais/wonderwall/pkg/config"
|
||||
@@ -28,15 +30,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
LoginCookieLifetime = 10 * time.Minute
|
||||
SessionCookieName = "io.nais.wonderwall.session"
|
||||
StateCookieName = "io.nais.wonderwall.state"
|
||||
NonceCookieName = "io.nais.wonderwall.nonce"
|
||||
CodeVerifierCookieName = "io.nais.wonderwall.code_verifier"
|
||||
RedirectURLCookieName = "io.nais.wonderwall.redirect_url"
|
||||
RedirectURLParameter = "redirect"
|
||||
LoginCookieLifetime = 10 * time.Minute
|
||||
SessionCookieName = "io.nais.wonderwall.session"
|
||||
StateCookieName = "io.nais.wonderwall.state"
|
||||
NonceCookieName = "io.nais.wonderwall.nonce"
|
||||
CodeVerifierCookieName = "io.nais.wonderwall.code_verifier"
|
||||
RedirectURLCookieName = "io.nais.wonderwall.redirect_url"
|
||||
RedirectURLParameter = "redirect"
|
||||
SecurityLevelURLParameter = "level"
|
||||
)
|
||||
|
||||
var InvalidSecurityLevelError = errors.New("InvalidSecurityLevel")
|
||||
|
||||
type Handler struct {
|
||||
Config config.IDPorten
|
||||
Crypter cryptutil.Crypter
|
||||
@@ -92,7 +97,7 @@ type loginParams struct {
|
||||
nonce string
|
||||
}
|
||||
|
||||
func (h *Handler) LoginURL() (*loginParams, error) {
|
||||
func (h *Handler) LoginURL(r *http.Request) (*loginParams, error) {
|
||||
codeVerifier := make([]byte, 64)
|
||||
nonce := make([]byte, 32)
|
||||
state := make([]byte, 32)
|
||||
@@ -130,11 +135,20 @@ func (h *Handler) LoginURL() (*loginParams, error) {
|
||||
v.Add("scope", token.ScopeOpenID)
|
||||
v.Add("state", base64.RawURLEncoding.EncodeToString(state))
|
||||
v.Add("nonce", base64.RawURLEncoding.EncodeToString(nonce))
|
||||
v.Add("acr_values", h.Config.SecurityLevel)
|
||||
v.Add("response_mode", "query")
|
||||
v.Add("ui_locales", h.Config.Locale)
|
||||
v.Add("code_challenge", base64.RawURLEncoding.EncodeToString(codeVerifierHash))
|
||||
v.Add("code_challenge_method", "S256")
|
||||
|
||||
if h.Config.SecurityLevel.Enabled {
|
||||
securityLevel, ok := h.securityLevel(r)
|
||||
if ok {
|
||||
v.Add("acr_values", securityLevel)
|
||||
} else {
|
||||
return nil, fmt.Errorf("%w: invalid value for %s=%s", InvalidSecurityLevelError, SecurityLevelURLParameter, securityLevel)
|
||||
}
|
||||
}
|
||||
|
||||
u.RawQuery = v.Encode()
|
||||
|
||||
return &loginParams{
|
||||
@@ -145,6 +159,21 @@ func (h *Handler) LoginURL() (*loginParams, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) securityLevel(r *http.Request) (string, bool) {
|
||||
level := h.Config.SecurityLevel.Value
|
||||
|
||||
urlParam := r.URL.Query().Get(SecurityLevelURLParameter)
|
||||
if len(urlParam) > 0 {
|
||||
level = urlParam
|
||||
}
|
||||
|
||||
if !h.Config.WellKnown.ACRValuesSupported.Contains(level) {
|
||||
return level, false
|
||||
}
|
||||
|
||||
return level, true
|
||||
}
|
||||
|
||||
// redirect url back to application
|
||||
func CanonicalRedirectURL(r *http.Request) string {
|
||||
redirectURL := "/"
|
||||
@@ -166,10 +195,20 @@ func CanonicalRedirectURL(r *http.Request) string {
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
params, err := h.LoginURL()
|
||||
params, err := h.LoginURL(r)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Errorf("login URL: %+v", err)
|
||||
|
||||
status := func(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, InvalidSecurityLevelError):
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}(err)
|
||||
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -270,7 +309,6 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: should probably redirect to desired path after login
|
||||
http.Redirect(w, r, cookies.Referer, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
@@ -55,9 +56,13 @@ func defaultConfig() config.IDPorten {
|
||||
WellKnown: config.IDPortenWellKnown{
|
||||
Issuer: "issuer",
|
||||
AuthorizationEndpoint: "http://localhost:1234/authorize",
|
||||
ACRValuesSupported: config.ACRValuesSupported{"Level3", "Level4"},
|
||||
},
|
||||
Locale: "nb",
|
||||
SecurityLevel: config.IDPortenSecurityLevel{
|
||||
Enabled: true,
|
||||
Value: "Level4",
|
||||
},
|
||||
Locale: "nb",
|
||||
SecurityLevel: "Level4",
|
||||
PostLogoutRedirectURI: "",
|
||||
SessionMaxLifetime: time.Hour,
|
||||
}
|
||||
@@ -87,9 +92,42 @@ func handler(cfg config.IDPorten) *router.Handler {
|
||||
}
|
||||
|
||||
func TestLoginURL(t *testing.T) {
|
||||
handler := handler(defaultConfig())
|
||||
_, err := handler.LoginURL()
|
||||
assert.NoError(t, err)
|
||||
type loginURLTest struct {
|
||||
url string
|
||||
error error
|
||||
}
|
||||
|
||||
tests := []loginURLTest{
|
||||
{
|
||||
url: "http://localhost:1234/oauth2/login?level=Level4",
|
||||
error: nil,
|
||||
},
|
||||
{
|
||||
url: "http://localhost:1234/oauth2/login",
|
||||
error: nil,
|
||||
},
|
||||
{
|
||||
url: "http://localhost:1234/oauth2/login?level=NoLevel",
|
||||
error: router.InvalidSecurityLevelError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.url, func(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
req, err := http.NewRequest("GET", test.url, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
handler := handler(cfg)
|
||||
_, err = handler.LoginURL(req)
|
||||
|
||||
if test.error != nil {
|
||||
assert.True(t, errors.Is(err, test.error))
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Login(t *testing.T) {
|
||||
@@ -120,7 +158,7 @@ func TestHandler_Login(t *testing.T) {
|
||||
|
||||
assert.Equal(t, idpserver.URL, fmt.Sprintf("%s://%s", u.Scheme, u.Host))
|
||||
assert.Equal(t, "/authorize", u.Path)
|
||||
assert.Equal(t, cfg.SecurityLevel, u.Query().Get("acr_values"))
|
||||
assert.Equal(t, cfg.SecurityLevel.Value, u.Query().Get("acr_values"))
|
||||
assert.Equal(t, cfg.Locale, u.Query().Get("ui_locales"))
|
||||
assert.Equal(t, cfg.ClientID, u.Query().Get("client_id"))
|
||||
assert.Equal(t, cfg.RedirectURI, u.Query().Get("redirect_uri"))
|
||||
|
||||
Reference in New Issue
Block a user