diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index 141521c..f90cba6 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -139,10 +139,36 @@ func (s *Standalone) Login(w http.ResponseWriter, r *http.Request) { return } + logger := mw.LogEntryFrom(r) fields := log.Fields{ "redirect_after_login": canonicalRedirect, } - mw.LogEntryFrom(r).WithFields(fields).Info("login: redirecting to identity provider") + + if acr := login.Acr; acr != "" { + fields["acr"] = acr + } + + if locale := login.Locale; locale != "" { + fields["locale"] = locale + } + + if prompt := login.Prompt; prompt != "" { + fields["prompt"] = prompt + logger.Infof("login: prompt='%s'; clearing local session...", prompt) + + sess, _ := s.SessionManager.Get(r) + if sess != nil { + err := s.SessionManager.Delete(r.Context(), sess) + if err != nil && !errors.Is(err, session.ErrNotFound) { + s.InternalError(w, r, fmt.Errorf("login: destroying session: %w", err)) + return + } + } + + cookie.Clear(w, cookie.Session, s.GetCookieOptions(r)) + } + + logger.WithFields(fields).Info("login: redirecting to identity provider") http.Redirect(w, r, login.AuthCodeURL, http.StatusFound) } diff --git a/pkg/handler/handler_sso_proxy.go b/pkg/handler/handler_sso_proxy.go index e10e3b6..75031ff 100644 --- a/pkg/handler/handler_sso_proxy.go +++ b/pkg/handler/handler_sso_proxy.go @@ -120,6 +120,9 @@ func (s *SSOProxy) Login(w http.ResponseWriter, r *http.Request) { if reqQuery.Has(openidclient.LocaleURLParameter) { targetQuery.Set(openidclient.LocaleURLParameter, reqQuery.Get(openidclient.LocaleURLParameter)) } + if reqQuery.Has(openidclient.PromptURLParameter) { + targetQuery.Set(openidclient.PromptURLParameter, reqQuery.Get(openidclient.PromptURLParameter)) + } target.RawQuery = targetQuery.Encode() diff --git a/pkg/handler/handler_test.go b/pkg/handler/handler_test.go index fe700d0..c4da1eb 100644 --- a/pkg/handler/handler_test.go +++ b/pkg/handler/handler_test.go @@ -56,6 +56,52 @@ func TestLogin(t *testing.T) { assert.NotEmpty(t, callbackURL.Query().Get("code")) } +func TestLoginPrompt(t *testing.T) { + cfg := mock.Config() + idp := mock.NewIdentityProvider(cfg) + defer idp.Close() + + rpClient := idp.RelyingPartyClient() + + // initial login and callback + initialSessionCookie := login(t, rpClient, idp) + + // verify session created + sess := sessionInfo(t, idp, rpClient) + assert.Equal(t, http.StatusOK, sess.StatusCode) + + // trigger authorize with prompt=login + loginURL, err := url.Parse(idp.RelyingPartyServer.URL + "/oauth2/login?prompt=login") + assert.NoError(t, err) + loginResp := get(t, rpClient, loginURL.String()) + assert.Equal(t, http.StatusFound, loginResp.StatusCode) + + cookies := rpClient.Jar.Cookies(loginURL) + sessionCookie := getCookieFromJar(cookie.Session, cookies) + loginCookie := getCookieFromJar(cookie.Login, cookies) + loginLegacyCookie := getCookieFromJar(cookie.LoginLegacy, cookies) + + assert.Nil(t, sessionCookie) + assert.NotNil(t, loginCookie) + assert.NotNil(t, loginLegacyCookie) + + // verify session deleted + sess = sessionInfo(t, idp, rpClient) + assert.Equal(t, http.StatusUnauthorized, sess.StatusCode) + + // follow redirect to idp + authorizeResp := get(t, rpClient, loginResp.Location.String()) + assert.Equal(t, http.StatusFound, authorizeResp.StatusCode) + + // follow callback back to rp + sessionCookie = callback(t, rpClient, authorizeResp) + + // verify new session created + sess = sessionInfo(t, idp, rpClient) + assert.Equal(t, http.StatusOK, sess.StatusCode) + assert.NotEqual(t, initialSessionCookie.Value, sessionCookie.Value) +} + func TestCallback(t *testing.T) { cfg := mock.Config() idp := mock.NewIdentityProvider(cfg) diff --git a/pkg/openid/client/login.go b/pkg/openid/client/login.go index 19aaced..9f194c7 100644 --- a/pkg/openid/client/login.go +++ b/pkg/openid/client/login.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "slices" "golang.org/x/oauth2" @@ -19,11 +20,14 @@ import ( const ( LocaleURLParameter = "locale" SecurityLevelURLParameter = "level" + PromptURLParameter = "prompt" + MaxAgeURLParameter = "max_age" ) var ( ErrInvalidSecurityLevel = errors.New("InvalidSecurityLevel") ErrInvalidLocale = errors.New("InvalidLocale") + ErrInvalidPrompt = errors.New("InvalidPrompt") ErrInvalidLoginParameter = errors.New("InvalidLoginParameter") // LoginParameterMapping maps incoming login parameters to OpenID Connect parameters @@ -31,6 +35,8 @@ var ( LocaleURLParameter: "ui_locales", SecurityLevelURLParameter: "acr_values", } + + PromptAllowedValues = []string{"login", "select_account"} ) func NewLogin(c *Client, r *http.Request) (*Login, error) { @@ -49,6 +55,11 @@ func NewLogin(c *Client, r *http.Request) (*Login, error) { return nil, fmt.Errorf("%w: %w", ErrInvalidLocale, err) } + prompt, err := getPromptParam(r) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPrompt, err) + } + nonce, err := strings.GenerateBase64(32) if err != nil { return nil, fmt.Errorf("creating nonce: %w", err) @@ -80,8 +91,16 @@ func NewLogin(c *Client, r *http.Request) (*Login, error) { opts = append(opts, oauth2.SetAuthURLParam(LoginParameterMapping[LocaleURLParameter], locale)) } + if len(prompt) > 0 { + opts = append(opts, oauth2.SetAuthURLParam(PromptURLParameter, prompt)) + opts = append(opts, oauth2.SetAuthURLParam(MaxAgeURLParameter, "0")) + } + return &Login{ AuthCodeURL: c.oauth2Config.AuthCodeURL(state, opts...), + Acr: acr, + Locale: locale, + Prompt: prompt, LoginCookie: &openid.LoginCookie{ Acr: acr, CodeVerifier: codeVerifier, @@ -94,6 +113,9 @@ func NewLogin(c *Client, r *http.Request) (*Login, error) { type Login struct { AuthCodeURL string + Acr string + Locale string + Prompt string *openid.LoginCookie } @@ -163,3 +185,16 @@ func getLocaleParam(c *Client, r *http.Request) (string, error) { return "", fmt.Errorf("%w: invalid value for %s=%s (must be one of '%s')", ErrInvalidLoginParameter, LocaleURLParameter, paramValue, supported) } + +func getPromptParam(r *http.Request) (string, error) { + paramValue := r.URL.Query().Get(PromptURLParameter) + if len(paramValue) == 0 { + return "", nil + } + + if slices.Contains(PromptAllowedValues, paramValue) { + return paramValue, nil + } + + return "", fmt.Errorf("%w: invalid value for %s=%s (must be one of '%s')", ErrInvalidLoginParameter, PromptURLParameter, paramValue, PromptAllowedValues) +} diff --git a/pkg/openid/client/login_test.go b/pkg/openid/client/login_test.go index e0d6fdc..f8597da 100644 --- a/pkg/openid/client/login_test.go +++ b/pkg/openid/client/login_test.go @@ -17,11 +17,11 @@ import ( func TestLogin_URL(t *testing.T) { type loginURLTest struct { - name string - url string - extraParams map[string]string - metadata *config.ProviderMetadata - error error + name string + url string + wantParams map[string]string + metadata *config.ProviderMetadata + error error } tests := []loginURLTest{ @@ -33,7 +33,7 @@ func TestLogin_URL(t *testing.T) { { name: "happy path with level", url: mock.Ingress + "/oauth2/login?level=Level3", - extraParams: map[string]string{ + wantParams: map[string]string{ "acr_values": "Level3", }, error: nil, @@ -41,15 +41,24 @@ func TestLogin_URL(t *testing.T) { { name: "happy path with locale", url: mock.Ingress + "/oauth2/login?locale=nb", - extraParams: map[string]string{ + wantParams: map[string]string{ "ui_locales": "nb", }, error: nil, }, + { + name: "happy path with prompt", + url: mock.Ingress + "/oauth2/login?prompt=login", + wantParams: map[string]string{ + "prompt": "login", + "max_age": "0", + }, + error: nil, + }, { name: "happy path with both locale and level", url: mock.Ingress + "/oauth2/login?level=Level3&locale=nb", - extraParams: map[string]string{ + wantParams: map[string]string{ "acr_values": "Level3", "ui_locales": "nb", }, @@ -65,10 +74,15 @@ func TestLogin_URL(t *testing.T) { url: mock.Ingress + "/oauth2/login?locale=es", error: client.ErrInvalidLocale, }, + { + name: "invalid prompt", + url: mock.Ingress + "/oauth2/login?prompt=invalid", + error: client.ErrInvalidPrompt, + }, { name: "level idporten-loa-substantial should translate to Level3 for old IDP", url: mock.Ingress + "/oauth2/login?level=idporten-loa-substantial", - extraParams: map[string]string{ + wantParams: map[string]string{ "acr_values": "Level3", }, error: nil, @@ -76,7 +90,7 @@ func TestLogin_URL(t *testing.T) { { name: "level idporten-loa-high should translate to Level4 for old IDP", url: mock.Ingress + "/oauth2/login?level=idporten-loa-high", - extraParams: map[string]string{ + wantParams: map[string]string{ "acr_values": "Level4", }, error: nil, @@ -84,7 +98,7 @@ func TestLogin_URL(t *testing.T) { { name: "level Level3 should translate to idporten-loa-substantial for new IDP", url: mock.Ingress + "/oauth2/login?level=Level3", - extraParams: map[string]string{ + wantParams: map[string]string{ "acr_values": "idporten-loa-substantial", }, metadata: &config.ProviderMetadata{ @@ -96,7 +110,7 @@ func TestLogin_URL(t *testing.T) { { name: "level Level4 should translate to idporten-loa-high for new IDP", url: mock.Ingress + "/oauth2/login?level=Level4", - extraParams: map[string]string{ + wantParams: map[string]string{ "acr_values": "idporten-loa-high", }, metadata: &config.ProviderMetadata{ @@ -155,8 +169,8 @@ func TestLogin_URL(t *testing.T) { assert.ElementsMatch(t, query["code_challenge_method"], []string{"S256"}) assert.ElementsMatch(t, query["code_challenge"], []string{oauth2.S256ChallengeFromVerifier(result.CodeVerifier)}) - if test.extraParams != nil { - for key, value := range test.extraParams { + if test.wantParams != nil { + for key, value := range test.wantParams { assert.Contains(t, query, key) assert.ElementsMatch(t, query[key], []string{value}) }