package staticroutes import ( "context" "fmt" "net/http" "github.com/go-chi/render" "github.com/pkg/errors" "github.com/vmihailenco/msgpack/v5" microstore "go-micro.dev/v4/store" bcl "github.com/qsfera/server/services/proxy/pkg/staticroutes/internal/backchannellogout" "github.com/opencloud-eu/reva/v2/pkg/events" "github.com/opencloud-eu/reva/v2/pkg/utils" ) // NewRecordKey converts the subject and session to a base64 encoded key var NewRecordKey = bcl.NewKey // backchannelLogout handles backchannel logout requests from the identity provider and invalidates the related sessions in the cache // spec: https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRequest // // known side effects of backchannel logout in keycloak: // // - keyCloak "Sign out all active sessions" does not send a backchannel logout request, // as the devs mention, this may lead to thousands of backchannel logout requests, // therefore, they recommend a short token lifetime. // https://github.com/keycloak/keycloak/issues/27342#issuecomment-2408461913 // // - keyCloak user self-service portal, "Sign out all devices" may not send a backchannel // logout request for each session, it's not mentionex explicitly, // but maybe the reason for that is the same as for "Sign out all active sessions" // to prevent a flood of backchannel logout requests. // // - if the keycloak setting "Backchannel logout session required" is disabled (or the token has no session id), // we resolve the session by the subject which can lead to multiple session records (subject.*), // we then send a logout event (sse) to each connected client and delete our stored cache record (subject.session & claim). // all sessions besides the one that triggered the backchannel logout continue to exist in the identity provider, // so the user will not be fully logged out until all sessions are logged out or expired. // this leads to the situation that web renders the logout view even if the instance is not fully logged out yet. func (s *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Request) { logger := s.Logger.SubloggerWithRequestID(r.Context()) if err := r.ParseForm(); err != nil { logger.Warn().Err(err).Msg("ParseForm failed") render.Status(r, http.StatusBadRequest) render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()}) return } logoutToken, err := s.OidcClient.VerifyLogoutToken(r.Context(), r.PostFormValue("logout_token")) if err != nil { msg := "failed to verify logout token" logger.Warn().Err(err).Msg(msg) render.Status(r, http.StatusBadRequest) render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg}) return } lookupKey, err := bcl.NewKey(logoutToken.Subject, logoutToken.SessionId) if err != nil { msg := "failed to build key from logout token" logger.Warn().Err(err).Msg(msg) render.Status(r, http.StatusBadRequest) render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg}) return } requestSubjectAndSession, err := bcl.NewSuSe(lookupKey) if err != nil { msg := "failed to build subjec.session from lookupKey" logger.Error().Err(err).Msg(msg) render.Status(r, http.StatusBadRequest) render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg}) return } lookupRecords, err := bcl.GetLogoutRecords(requestSubjectAndSession, s.UserInfoCache) if errors.Is(err, microstore.ErrNotFound) || len(lookupRecords) == 0 { render.Status(r, http.StatusOK) render.JSON(w, r, nil) return } if err != nil { msg := "failed to read userinfo cache" logger.Error().Err(err).Msg(msg) render.Status(r, http.StatusBadRequest) render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg}) return } for _, record := range lookupRecords { // the record key is in the format "subject.session" or ".session" // the record value is the key of the record that contains the claim in its value key, value := record.Key, string(record.Value) subjectSession, err := bcl.NewSuSe(key) if err != nil { // never leak any key-related information logger.Warn().Err(err).Msgf("failed to parse key: %s", key) continue } session, err := subjectSession.Session() if err != nil { logger.Warn().Err(err).Msgf("failed to read session for: %s", key) continue } if requestSubjectAndSession.Mode() == bcl.LogoutModeSession { if err := s.publishBackchannelLogoutEvent(r.Context(), session, value); err != nil { s.Logger.Warn().Err(err).Msgf("failed to publish backchannel logout event for: %s", key) continue } } err = s.UserInfoCache.Delete(value) if err != nil && !errors.Is(err, microstore.ErrNotFound) { // we have to return a 400 BadRequest when we fail to delete the session // https://openid.net/specs/openid-connect-backchannel-1_0.html#rfc.section.2.8 msg := "failed to delete record" s.Logger.Warn().Err(err).Msgf("%s for: %s", msg, key) render.Status(r, http.StatusBadRequest) render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: msg}) return } // we can ignore errors when deleting the lookup record err = s.UserInfoCache.Delete(key) if err != nil { logger.Debug().Err(err).Msgf("failed to delete record for: %s", key) } } render.Status(r, http.StatusOK) render.JSON(w, r, nil) } // publishBackchannelLogoutEvent publishes a backchannel logout event when the callback revived from the identity provider func (s *StaticRouteHandler) publishBackchannelLogoutEvent(ctx context.Context, sessionId, claimKey string) error { if s.EventsPublisher == nil { return errors.New("events publisher not set") } claimRecords, err := s.UserInfoCache.Read(claimKey) switch { case err != nil: return fmt.Errorf("failed to read userinfo cache: %w", err) case len(claimRecords) == 0: return fmt.Errorf("no claim found for key: %s", claimKey) } var claims map[string]any if err = msgpack.Unmarshal(claimRecords[0].Value, &claims); err != nil { return fmt.Errorf("failed to unmarshal claims: %w", err) } oidcClaim, ok := claims[s.Config.UserOIDCClaim].(string) if !ok { return fmt.Errorf("failed to get claim %w", err) } user, _, err := s.UserProvider.GetUserByClaims(ctx, s.Config.UserCS3Claim, oidcClaim) if err != nil || user.GetId() == nil { return fmt.Errorf("failed to get user by claims: %w", err) } e := events.BackchannelLogout{ Executant: user.GetId(), SessionId: sessionId, Timestamp: utils.TSNow(), } if err := events.Publish(ctx, s.EventsPublisher, e); err != nil { return fmt.Errorf("failed to publish user logout event %w", err) } return nil }