Livio Spring fa5e590aab
fix(idp): prevent server errors for idps using form post for callbacks (#9097)
# Which Problems Are Solved

Some IdP callbacks use HTTP form POST to return their data on callbacks.
For handling CSRF in the login after such calls, a 302 Found to the
corresponding non form callback (in ZITADEL) is sent. Depending on the
size of the initial form body, this could lead to ZITADEL terminating
the connection, resulting in the user not getting a response or an
intermediate proxy to return them an HTTP 502.

# How the Problems Are Solved

- the form body is parsed and stored into the ZITADEL cache (using the
configured database by default)
- the redirect (302 Found) is performed with the request id
- the callback retrieves the data from the cache instead of the query
parameters (will fallback to latter to handle open uncached requests)

# Additional Changes

- fixed a typo in the default (cache) configuration: `LastUsage` ->
`LastUseAge`

# Additional Context

- reported by a customer
- needs to be backported to current cloud version (2.66.x)

---------

Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com>
2025-01-06 10:47:46 +01:00

253 lines
8.2 KiB
Go

package login
import (
"context"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/csrf"
"github.com/gorilla/mux"
"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/statik"
auth_repository "github.com/zitadel/zitadel/internal/auth/repository"
"github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
"github.com/zitadel/zitadel/internal/cache"
"github.com/zitadel/zitadel/internal/cache/connector"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/form"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/static"
)
type Login struct {
endpoint string
router http.Handler
renderer *Renderer
parser *form.Parser
command *command.Commands
query *query.Queries
staticStorage static.Storage
authRepo auth_repository.Repository
externalSecure bool
consolePath string
oidcAuthCallbackURL func(context.Context, string) string
samlAuthCallbackURL func(context.Context, string) string
idpConfigAlg crypto.EncryptionAlgorithm
userCodeAlg crypto.EncryptionAlgorithm
caches *Caches
}
type Config struct {
LanguageCookieName string
CSRFCookieName string
Cache middleware.CacheConfig
AssetCache middleware.CacheConfig
// LoginV2
DefaultOTPEmailURLV2 string
}
const (
login = "LOGIN"
HandlerPrefix = "/ui/login"
DefaultLoggedOutPath = HandlerPrefix + EndpointLogoutDone
)
func CreateLogin(config Config,
command *command.Commands,
query *query.Queries,
authRepo *eventsourcing.EsRepository,
staticStorage static.Storage,
consolePath string,
oidcAuthCallbackURL func(context.Context, string) string,
samlAuthCallbackURL func(context.Context, string) string,
externalSecure bool,
userAgentCookie,
issuerInterceptor,
oidcInstanceHandler,
samlInstanceHandler,
assetCache,
accessHandler mux.MiddlewareFunc,
userCodeAlg crypto.EncryptionAlgorithm,
idpConfigAlg crypto.EncryptionAlgorithm,
csrfCookieKey []byte,
cacheConnectors connector.Connectors,
) (*Login, error) {
login := &Login{
oidcAuthCallbackURL: oidcAuthCallbackURL,
samlAuthCallbackURL: samlAuthCallbackURL,
externalSecure: externalSecure,
consolePath: consolePath,
command: command,
query: query,
staticStorage: staticStorage,
authRepo: authRepo,
idpConfigAlg: idpConfigAlg,
userCodeAlg: userCodeAlg,
}
csrfInterceptor := createCSRFInterceptor(config.CSRFCookieName, csrfCookieKey, externalSecure, login.csrfErrorHandler())
cacheInterceptor := createCacheInterceptor(config.Cache.MaxAge, config.Cache.SharedMaxAge, assetCache)
security := middleware.SecurityHeaders(csp(), login.cspErrorHandler)
login.router = CreateRouter(login, middleware.TelemetryHandler(IgnoreInstanceEndpoints...), oidcInstanceHandler, samlInstanceHandler, csrfInterceptor, cacheInterceptor, security, userAgentCookie, issuerInterceptor, accessHandler)
login.renderer = CreateRenderer(HandlerPrefix, staticStorage, config.LanguageCookieName)
login.parser = form.NewParser()
var err error
login.caches, err = startCaches(context.Background(), cacheConnectors)
if err != nil {
return nil, err
}
return login, nil
}
func csp() *middleware.CSP {
csp := middleware.DefaultSCP
csp.ObjectSrc = middleware.CSPSourceOptsSelf()
csp.StyleSrc = csp.StyleSrc.AddNonce()
csp.ScriptSrc = csp.ScriptSrc.AddNonce().AddHash("sha256", "AjPdJSbZmeWHnEc5ykvJFay8FTWeTeRbs9dutfZ0HqE=")
return &csp
}
func createCSRFInterceptor(cookieName string, csrfCookieKey []byte, externalSecure bool, errorHandler http.Handler) func(http.Handler) http.Handler {
path := "/"
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, EndpointResources) {
handler.ServeHTTP(w, r)
return
}
// ignore form post callback
// it will redirect to the "normal" callback, where the cookie is set again
if (r.URL.Path == EndpointExternalLoginCallbackFormPost || r.URL.Path == EndpointSAMLACS) && r.Method == http.MethodPost {
handler.ServeHTTP(w, r)
return
}
// by default we use SameSite Lax and the externalSecure (TLS) for the secure flag
sameSiteMode := csrf.SameSiteLaxMode
secureOnly := externalSecure
instance := authz.GetInstance(r.Context())
// in case of `allow iframe`...
if len(instance.SecurityPolicyAllowedOrigins()) > 0 {
// ... we need to change to SameSite none ...
sameSiteMode = csrf.SameSiteNoneMode
// ... and since SameSite none requires the secure flag, we'll set it for TLS and for localhost
// (regardless of the TLS / externalSecure settings)
secureOnly = externalSecure || http_utils.DomainContext(r.Context()).RequestedDomain() == "localhost"
}
csrf.Protect(csrfCookieKey,
csrf.Secure(secureOnly),
csrf.CookieName(http_utils.SetCookiePrefix(cookieName, externalSecure, http_utils.PrefixHost)),
csrf.Path(path),
csrf.ErrorHandler(errorHandler),
csrf.SameSite(sameSiteMode),
)(handler).ServeHTTP(w, r)
})
}
}
func createCacheInterceptor(maxAge, sharedMaxAge time.Duration, assetCache mux.MiddlewareFunc) func(http.Handler) http.Handler {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, EndpointDynamicResources) {
assetCache.Middleware(handler).ServeHTTP(w, r)
return
}
if strings.HasPrefix(r.URL.Path, EndpointResources) {
middleware.AssetsCacheInterceptor(maxAge, sharedMaxAge).Handler(handler).ServeHTTP(w, r)
return
}
middleware.NoCacheInterceptor().Handler(handler).ServeHTTP(w, r)
})
}
}
func (l *Login) Handler() http.Handler {
return l.router
}
func (l *Login) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgName string) ([]string, error) {
orgDomain, err := domain.NewIAMDomainName(orgName, http_utils.DomainContext(ctx).RequestedDomain())
if err != nil {
return nil, err
}
loginName, err := query.NewUserPreferredLoginNameSearchQuery("@"+orgDomain, query.TextEndsWithIgnoreCase)
if err != nil {
return nil, err
}
users, err := l.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, nil)
if err != nil {
return nil, err
}
userIDs := make([]string, len(users.Users))
for i, user := range users.Users {
userIDs[i] = user.ID
}
return userIDs, nil
}
func setContext(ctx context.Context, resourceOwner string) context.Context {
data := authz.CtxData{
UserID: login,
OrgID: resourceOwner,
}
return authz.SetCtxData(ctx, data)
}
func setUserContext(ctx context.Context, userID, resourceOwner string) context.Context {
data := authz.CtxData{
UserID: userID,
OrgID: resourceOwner,
}
return authz.SetCtxData(ctx, data)
}
func (l *Login) baseURL(ctx context.Context) string {
return http_utils.DomainContext(ctx).Origin() + HandlerPrefix
}
type Caches struct {
idpFormCallbacks cache.Cache[idpFormCallbackIndex, string, *idpFormCallback]
}
func startCaches(background context.Context, connectors connector.Connectors) (_ *Caches, err error) {
caches := new(Caches)
caches.idpFormCallbacks, err = connector.StartCache[idpFormCallbackIndex, string, *idpFormCallback](background, []idpFormCallbackIndex{idpFormCallbackIndexRequestID}, cache.PurposeIdPFormCallback, connectors.Config.IdPFormCallbacks, connectors)
if err != nil {
return nil, err
}
return caches, nil
}
type idpFormCallbackIndex int
const (
idpFormCallbackIndexUnspecified idpFormCallbackIndex = iota
idpFormCallbackIndexRequestID
)
type idpFormCallback struct {
InstanceID string
State string
Form url.Values
}
// Keys implements cache.Entry
func (c *idpFormCallback) Keys(i idpFormCallbackIndex) []string {
if i == idpFormCallbackIndexRequestID {
return []string{idpFormCallbackKey(c.InstanceID, c.State)}
}
return nil
}
func idpFormCallbackKey(instanceID, state string) string {
return instanceID + "-" + state
}