feat: specify login UI version on instance and apps (#9071)

# Which Problems Are Solved

To be able to migrate or test the new login UI, admins might want to
(temporarily) switch individual apps.
At a later point admin might want to make sure all applications use the
new login UI.

# How the Problems Are Solved

- Added a feature flag `` on instance level to require all apps to use
the new login and provide an optional base url.
- if the flag is enabled, all (OIDC) applications will automatically use
the v2 login.
  - if disabled, applications can decide based on their configuration
- Added an option on OIDC apps to use the new login UI and an optional
base url.
- Removed the requirement to use `x-zitadel-login-client` to be
redirected to the login V2 and retrieve created authrequest and link
them to SSO sessions.
- Added a new "IAM_LOGIN_CLIENT" role to allow management of users,
sessions, grants and more without `x-zitadel-login-client`.

# Additional Changes

None

# Additional Context

closes https://github.com/zitadel/zitadel/issues/8702
This commit is contained in:
Livio Spring
2024-12-19 10:37:46 +01:00
committed by GitHub
parent b5e92a6144
commit 50d2b26a28
89 changed files with 1670 additions and 321 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
@@ -16,6 +17,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
@@ -26,7 +28,11 @@ import (
)
const (
LoginClientHeader = "x-zitadel-login-client"
LoginClientHeader = "x-zitadel-login-client"
LoginPostLogoutRedirectParam = "post_logout_redirect"
LoginPath = "/login"
LogoutPath = "/logout"
LogoutDonePath = "/logout/done"
)
func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest, userID string) (_ op.AuthRequest, err error) {
@@ -36,12 +42,34 @@ func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest
span.EndWithError(err)
}()
// for backwards compatibility we pass the login client if set
headers, _ := http_utils.HeadersFromCtx(ctx)
if loginClient := headers.Get(LoginClientHeader); loginClient != "" {
loginClient := headers.Get(LoginClientHeader)
// if the instance requires the v2 login, use it no matter what the application configured
if authz.GetFeatures(ctx).LoginV2.Required {
return o.createAuthRequestLoginClient(ctx, req, userID, loginClient)
}
return o.createAuthRequest(ctx, req, userID)
version, err := o.query.OIDCClientLoginVersion(ctx, req.ClientID)
if err != nil {
return nil, err
}
switch version {
case domain.LoginVersion1:
return o.createAuthRequest(ctx, req, userID)
case domain.LoginVersion2:
return o.createAuthRequestLoginClient(ctx, req, userID, loginClient)
case domain.LoginVersionUnspecified:
fallthrough
default:
// if undefined, use the v2 login if the header is sent, to retain the current behavior
if loginClient != "" {
return o.createAuthRequestLoginClient(ctx, req, userID, loginClient)
}
return o.createAuthRequest(ctx, req, userID)
}
}
func (o *OPStorage) createAuthRequestScopeAndAudience(ctx context.Context, clientID string, reqScope []string) (scope, audience []string, err error) {
@@ -240,18 +268,35 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
// check for the login client header
headers, _ := http_utils.HeadersFromCtx(ctx)
// in case there is no id_token_hint, redirect to the UI and let it decide which session to terminate
if headers.Get(LoginClientHeader) != "" && endSessionRequest.IDTokenHintClaims == nil {
return o.defaultLogoutURLV2 + endSessionRequest.RedirectURI, nil
// V2:
// In case there is no id_token_hint and login V2 is either required by feature
// or requested via header (backwards compatibility),
// we'll redirect to the UI (V2) and let it decide which session to terminate
//
// If there's no id_token_hint and for v1 logins, we handle them separately
if endSessionRequest.IDTokenHintClaims == nil &&
(authz.GetFeatures(ctx).LoginV2.Required || headers.Get(LoginClientHeader) != "") {
redirectURI := v2PostLogoutRedirectURI(endSessionRequest.RedirectURI)
// if no base uri is set, fallback to the default configured in the runtime config
if authz.GetFeatures(ctx).LoginV2.BaseURI == nil || authz.GetFeatures(ctx).LoginV2.BaseURI.String() == "" {
return o.defaultLogoutURLV2 + redirectURI, nil
}
return buildLoginV2LogoutURL(authz.GetFeatures(ctx).LoginV2.BaseURI, redirectURI), nil
}
// If there is no login client header and no id_token_hint or the id_token_hint does not have a session ID,
// do a v1 Terminate session (which terminates all sessions of the user agent, identified by cookie).
// V1:
// We check again for the id_token_hint param and if a session is set in it.
// All explicit V2 sessions with empty id_token_hint are handled above and all V2 session contain a sessionID
// So if any condition is not met, we handle the request as a V1 request and do a (v1) TerminateSession,
// which terminates all sessions of the user agent, identified by cookie.
if endSessionRequest.IDTokenHintClaims == nil || endSessionRequest.IDTokenHintClaims.SessionID == "" {
return endSessionRequest.RedirectURI, o.TerminateSession(ctx, endSessionRequest.UserID, endSessionRequest.ClientID)
}
// If the sessionID is prefixed by V1, we also terminate a v1 session.
// V1:
// If the sessionID is prefixed by V1, we also terminate a v1 session, but based on the SingleV1SessionTermination feature flag,
// we either terminate all sessions of the user agent or only the specific session
if strings.HasPrefix(endSessionRequest.IDTokenHintClaims.SessionID, handler.IDPrefixV1) {
err = o.terminateV1Session(ctx, endSessionRequest.UserID, endSessionRequest.IDTokenHintClaims.SessionID)
if err != nil {
@@ -260,12 +305,31 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
return endSessionRequest.RedirectURI, nil
}
// terminate the v2 session of the id_token_hint
// V2:
// Terminate the v2 session of the id_token_hint
_, err = o.command.TerminateSessionWithoutTokenCheck(ctx, endSessionRequest.IDTokenHintClaims.SessionID)
if err != nil {
return "", err
}
return endSessionRequest.RedirectURI, nil
return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil
}
func buildLoginV2LogoutURL(baseURI *url.URL, redirectURI string) string {
baseURI.JoinPath(LogoutPath)
q := baseURI.Query()
q.Set(LoginPostLogoutRedirectParam, redirectURI)
baseURI.RawQuery = q.Encode()
return baseURI.String()
}
// v2PostLogoutRedirectURI will take care that the post_logout_redirect_uri is correctly set for v2 logins.
// The default value set by the [op.SessionEnder] only handles V1 logins. In case the redirect_uri is set to the default
// we'll return the path for the v2 login.
func v2PostLogoutRedirectURI(redirectURI string) string {
if redirectURI != login.DefaultLoggedOutPath {
return redirectURI
}
return LogoutDonePath
}
// terminateV1Session terminates "v1" sessions created through the login UI.