enhancement: finalize backchannel logout

This commit is contained in:
Florian Schade
2026-02-13 20:41:16 +01:00
committed by Christian Richter
parent 2e36859816
commit 66d220ff44
7 changed files with 430 additions and 94 deletions

View File

@@ -11,6 +11,7 @@ import (
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/justinas/alice"
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
"github.com/opencloud-eu/opencloud/pkg/generators"
"github.com/opencloud-eu/opencloud/pkg/log"
@@ -73,6 +74,7 @@ func Server(cfg *config.Config) *cli.Command {
microstore.Nodes(cfg.PreSignedURL.SigningKeys.Nodes...),
microstore.Database("proxy"),
microstore.Table("signing-keys"),
store.DisablePersistence(cfg.PreSignedURL.SigningKeys.DisablePersistence),
store.Authentication(cfg.PreSignedURL.SigningKeys.AuthUsername, cfg.PreSignedURL.SigningKeys.AuthPassword),
)

View File

@@ -45,6 +45,8 @@ func DefaultConfig() *config.Config {
AccessTokenVerifyMethod: config.AccessTokenVerificationJWT,
SkipUserInfo: false,
UserinfoCache: &config.Cache{
// toDo:
// check if "memory" is the right default or if "nats-js-kv" is a better match
Store: "memory",
Nodes: []string{"127.0.0.1:9233"},
Database: "cache-userinfo",

View File

@@ -3,20 +3,20 @@ package middleware
import (
"context"
"encoding/base64"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/oidc"
"github.com/pkg/errors"
"github.com/vmihailenco/msgpack/v5"
store "go-micro.dev/v4/store"
"go-micro.dev/v4/store"
"golang.org/x/crypto/sha3"
"golang.org/x/oauth2"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/oidc"
)
const (
@@ -115,28 +115,26 @@ func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[stri
m.Logger.Error().Err(err).Msg("failed to write to userinfo cache")
}
if sid := aClaims.SessionID; sid != "" {
// reuse user cache for session id lookup
err = m.userInfoCache.Write(&store.Record{
Key: sid,
Value: []byte(encodedHash),
Expiry: time.Until(expiration),
})
if err != nil {
m.Logger.Error().Err(err).Msg("failed to write session lookup cache")
}
subject, sessionId := aClaims.Subject, aClaims.SessionID
// if no session id is present, we can't do a session lookup,
// so we can skip the cache entry for that.
if sessionId == "" {
return
}
// create an additional entry mapping subject to session id
if sub := aClaims.Subject; sub != "" {
err = m.userInfoCache.Write(&store.Record{
Key: fmt.Sprintf("%s.%s", sub, sid),
Value: []byte(sid),
Expiry: time.Until(expiration),
})
if err != nil {
m.Logger.Error().Err(err).Msg("failed to write subject lookup cache")
}
}
// if the claim has no subject, we can leave it empty,
// it's important to keep the dot in the key to prevent
// sufix and prefix exploration in the cache.
//
// ok: {key: ".sessionId"}
// ok: {key: "subject.sessionId"}
key := strings.Join([]string{subject, sessionId}, ".")
if err := m.userInfoCache.Write(&store.Record{
Key: key,
Value: []byte(encodedHash),
Expiry: time.Until(expiration),
}); err != nil {
m.Logger.Error().Err(err).Msg("failed to write session lookup cache")
}
}
}()

View File

@@ -7,15 +7,24 @@ import (
"strings"
"github.com/go-chi/render"
"github.com/opencloud-eu/opencloud/pkg/oidc"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/pkg/errors"
"github.com/vmihailenco/msgpack/v5"
microstore "go-micro.dev/v4/store"
bcl "github.com/opencloud-eu/opencloud/services/proxy/pkg/staticroutes/internal/backchannellogout"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// handle backchannel logout requests as per https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRequest
// 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
//
// toDo:
// - keyCloak "Sign out all active sessions" fails to log out, no incoming request
// - if the keycloak setting "Backchannel logout session required" is disabled,
// we resolve the session by the subject which can lead to multiple session records,
// we then send a logout event to each connected client and delete our stored record (subject.session & claim).
// but the session still exists in the identity provider.
func (s *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Request) {
// parse the application/x-www-form-urlencoded POST request
logger := s.Logger.SubloggerWithRequestID(r.Context())
@@ -26,13 +35,6 @@ func (s *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Re
return
}
if r.PostFormValue("logout_token") == "" {
logger.Warn().Msg("logout_token is missing")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: "logout_token is missing"})
return
}
logoutToken, err := s.OidcClient.VerifyLogoutToken(r.Context(), r.PostFormValue("logout_token"))
if err != nil {
logger.Warn().Err(err).Msg("VerifyLogoutToken failed")
@@ -41,70 +43,63 @@ func (s *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Re
return
}
var cacheKeys map[string]bool
if strings.TrimSpace(logoutToken.SessionId) != "" {
cacheKeys[logoutToken.SessionId] = true
} else if strings.TrimSpace(logoutToken.Subject) != "" {
records, err := s.UserInfoCache.Read(fmt.Sprintf("%s.*", logoutToken.Subject))
if errors.Is(err, microstore.ErrNotFound) || len(records) == 0 {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
if err != nil {
logger.Error().Err(err).Msg("Error reading userinfo cache")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
for _, record := range records {
cacheKeys[string(record.Value)] = true
cacheKeys[record.Key] = false
}
} else {
logger.Warn().Msg("invalid logout token")
subject, session := strings.Join(strings.Fields(logoutToken.Subject), ""), strings.Join(strings.Fields(logoutToken.SessionId), "")
if subject == "" && session == "" {
jseErr := jse{Error: "invalid_request", ErrorDescription: "invalid logout token: subject and session id are empty"}
logger.Warn().Msg(jseErr.ErrorDescription)
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: "invalid logout token"})
render.JSON(w, r, jseErr)
return
}
for key, isSID := range cacheKeys {
if isSID {
records, err := s.UserInfoCache.Read(key)
if err != nil && !errors.Is(err, microstore.ErrNotFound) {
logger.Error().Err(err).Msg("Error reading userinfo cache")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
for _, record := range records {
err = s.UserInfoCache.Delete(string(record.Value))
if err != nil && !errors.Is(err, microstore.ErrNotFound) {
logger.Error().Err(err).Msg("Error deleting userinfo cache")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
}
requestSubjectAndSession := bcl.SuSe{Session: session, Subject: subject}
// find out which mode of backchannel logout we are in
// by checking if the session or subject is present in the token
logoutMode := bcl.GetLogoutMode(requestSubjectAndSession)
lookupRecords, err := bcl.GetLogoutRecords(requestSubjectAndSession, logoutMode, 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 {
logger.Error().Err(err).Msg("Error reading userinfo cache")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
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, ok := bcl.NewSuSe(key)
if !ok {
logger.Warn().Msgf("invalid logout record key: %s", key)
continue
}
err = s.UserInfoCache.Delete(key)
err := s.publishBackchannelLogoutEvent(r.Context(), subjectSession.Session, value)
if err != nil {
s.Logger.Warn().Err(err).Msg("could not publish backchannel logout event")
}
err = s.UserInfoCache.Delete(value)
if err != nil && !errors.Is(err, microstore.ErrNotFound) {
// Spec requires us to return a 400 BadRequest when the session could not be destroyed
logger.Err(err).Msg(fmt.Errorf("could not delete session from cache (%s)", key).Error())
// We only return on requests that do only attempt to destroy a single session, not multiple
// 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
logger.Err(err).Msg("could not delete user info from cache")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
if isSID {
err := s.publishBackchannelLogoutEvent(r.Context(), key, logoutToken)
if err != nil {
s.Logger.Warn().Err(err).Msg("could not publish backchannel logout event")
}
// we can ignore errors when deleting the lookup record
err = s.UserInfoCache.Delete(key)
if err != nil {
logger.Debug().Err(err).Msg("Failed to cleanup sessionid lookup entry")
}
logger.Debug().Msg("Deleted userinfo from cache")
}
render.Status(r, http.StatusOK)
@@ -112,20 +107,20 @@ func (s *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Re
}
// publishBackchannelLogoutEvent publishes a backchannel logout event when the callback revived from the identity provider
func (s StaticRouteHandler) publishBackchannelLogoutEvent(ctx context.Context, cacheKey string, logoutToken *oidc.LogoutToken) error {
func (s *StaticRouteHandler) publishBackchannelLogoutEvent(ctx context.Context, sessionId, claimKey string) error {
if s.EventsPublisher == nil {
return fmt.Errorf("the events publisher is not set")
}
urecords, err := s.UserInfoCache.Read(cacheKey)
claimRecords, err := s.UserInfoCache.Read(claimKey)
if err != nil {
return fmt.Errorf("reading userinfo cache: %w", err)
}
if len(urecords) == 0 {
if len(claimRecords) == 0 {
return fmt.Errorf("userinfo not found")
}
var claims map[string]interface{}
if err = msgpack.Unmarshal(urecords[0].Value, &claims); err != nil {
if err = msgpack.Unmarshal(claimRecords[0].Value, &claims); err != nil {
return fmt.Errorf("could not unmarshal userinfo: %w", err)
}
@@ -141,7 +136,7 @@ func (s StaticRouteHandler) publishBackchannelLogoutEvent(ctx context.Context, c
e := events.BackchannelLogout{
Executant: user.GetId(),
SessionId: logoutToken.SessionId,
SessionId: sessionId,
Timestamp: utils.TSNow(),
}

View File

@@ -0,0 +1,125 @@
// package backchannellogout provides functions to classify and lookup
// backchannel logout records from the cache store.
package backchannellogout
import (
"fmt"
"strings"
"github.com/pkg/errors"
microstore "go-micro.dev/v4/store"
)
// SuSe 🦎 ;) is a struct that groups the subject and session together
// to prevent mix-ups for ('session, subject' || 'subject, session')
// return values.
type SuSe struct {
Subject string
Session string
}
// NewSuSe parses the subject and session id from the given key and returns a SuSe struct
func NewSuSe(key string) (SuSe, bool) {
var subject, session string
switch keys := strings.Split(strings.Join(strings.Fields(key), ""), "."); {
case len(keys) == 2 && keys[0] == "" && keys[1] != "":
session = keys[1]
case len(keys) == 2 && keys[0] != "" && keys[1] == "":
subject = keys[0]
case len(keys) == 2 && keys[0] != "" && keys[1] != "":
subject = keys[0]
session = keys[1]
case len(keys) == 1 && keys[0] != "":
session = keys[0]
default:
return SuSe{}, false
}
return SuSe{Session: session, Subject: subject}, true
}
// LogoutMode defines the mode of backchannel logout, either by session or by subject
type LogoutMode int
const (
// LogoutModeUnknown is used when the logout mode cannot be determined
LogoutModeUnknown LogoutMode = iota
// LogoutModeSession is used when the logout mode is determined by the session id
LogoutModeSession
// LogoutModeSubject is used when the logout mode is determined by the subject
LogoutModeSubject
)
// GetLogoutMode determines the backchannel logout mode based on the presence of subject and session in the SuSe struct
func GetLogoutMode(suse SuSe) LogoutMode {
switch {
case suse.Session != "":
return LogoutModeSession
case suse.Subject != "":
return LogoutModeSubject
default:
return LogoutModeUnknown
}
}
// ErrSuspiciousCacheResult is returned when the cache result is suspicious
var ErrSuspiciousCacheResult = errors.New("suspicious cache result")
// GetLogoutRecords retrieves the records from the user info cache based on the backchannel
// logout mode and the provided SuSe struct.
// it uses a seperator to prevent sufix and prefix exploration in the cache and checks
// if the retrieved records match the requested subject and or session id as well, to prevent false positives.
func GetLogoutRecords(suse SuSe, mode LogoutMode, store microstore.Store) ([]*microstore.Record, error) {
var key string
var opts []microstore.ReadOption
switch mode {
case LogoutModeSession:
// the dot at the beginning prevents sufix exploration in the cache,
// so only keys that end with '*.session' will be returned, but not '*sion'.
key = "." + suse.Session
opts = append(opts, microstore.ReadSuffix())
case LogoutModeSubject:
// the dot at the end prevents prefix exploration in the cache,
// so only keys that start with 'subject.*' will be returned, but not 'sub*'.
key = suse.Subject + "."
opts = append(opts, microstore.ReadPrefix())
default:
return nil, fmt.Errorf("%w: cannot determine logout mode", ErrSuspiciousCacheResult)
}
records, err := store.Read(key, append(opts, microstore.ReadLimit(1000))...)
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, microstore.ErrNotFound
}
if mode == LogoutModeSession && len(records) > 1 {
return nil, fmt.Errorf("%w: multiple session records found", ErrSuspiciousCacheResult)
}
// double-check if the found records match the requested subject and or session id as well,
// to prevent false positives.
for _, record := range records {
recordSuSe, ok := NewSuSe(record.Key)
if !ok {
return nil, microstore.ErrNotFound
}
switch {
// in session mode, the session id must match, but the subject can be different
case mode == LogoutModeSession && suse.Session == recordSuSe.Session:
continue
// in subject mode, the subject must match, but the session id can be different
case mode == LogoutModeSubject && suse.Subject == recordSuSe.Subject:
continue
}
return nil, fmt.Errorf("%w: record key does not match the requested subject or session", ErrSuspiciousCacheResult)
}
return records, nil
}

View File

@@ -0,0 +1,213 @@
package backchannellogout
import (
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
"go-micro.dev/v4/store"
)
func TestNewSuSe(t *testing.T) {
tests := []struct {
name string
key string
wantsuSe SuSe
wantOk bool
}{
{
name: ".session",
key: ".session",
wantsuSe: SuSe{Session: "session", Subject: ""},
wantOk: true,
},
{
name: ".session",
key: ".session",
wantsuSe: SuSe{Session: "session", Subject: ""},
wantOk: true,
},
{
name: "session",
key: "session",
wantsuSe: SuSe{Session: "session", Subject: ""},
wantOk: true,
},
{
name: "subject.",
key: "subject.",
wantsuSe: SuSe{Session: "", Subject: "subject"},
wantOk: true,
},
{
name: "subject.session",
key: "subject.session",
wantsuSe: SuSe{Session: "session", Subject: "subject"},
wantOk: true,
},
{
name: "dot",
key: ".",
wantsuSe: SuSe{Session: "", Subject: ""},
wantOk: false,
},
{
name: "empty",
key: "",
wantsuSe: SuSe{Session: "", Subject: ""},
wantOk: false,
},
{
name: "whitespace . whitespace",
key: " . ",
wantsuSe: SuSe{Session: "", Subject: ""},
wantOk: false,
},
{
name: "whitespace subject whitespace . whitespace",
key: " subject . ",
wantsuSe: SuSe{Session: "", Subject: "subject"},
wantOk: true,
},
{
name: "whitespace . whitespace session whitespace",
key: " . session ",
wantsuSe: SuSe{Session: "session", Subject: ""},
wantOk: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
suSe, ok := NewSuSe(tt.key)
require.Equal(t, tt.wantOk, ok)
require.Equal(t, tt.wantsuSe, suSe)
})
}
}
func TestGetLogoutMode(t *testing.T) {
tests := []struct {
name string
suSe SuSe
want LogoutMode
}{
{
name: ".session",
suSe: SuSe{Session: "session", Subject: ""},
want: LogoutModeSession,
},
{
name: "subject.session",
suSe: SuSe{Session: "session", Subject: "subject"},
want: LogoutModeSession,
},
{
name: "subject.",
suSe: SuSe{Session: "", Subject: "subject"},
want: LogoutModeSubject,
},
{
name: "",
suSe: SuSe{Session: "", Subject: ""},
want: LogoutModeUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mode := GetLogoutMode(tt.suSe)
require.Equal(t, tt.want, mode)
})
}
}
func TestGetLogoutRecords(t *testing.T) {
sessionStore := store.NewMemoryStore()
recordClaimA := &store.Record{Key: "claim-a", Value: []byte("claim-a-data")}
recordClaimB := &store.Record{Key: "claim-b", Value: []byte("claim-b-data")}
recordClaimC := &store.Record{Key: "claim-c", Value: []byte("claim-c-data")}
recordClaimD := &store.Record{Key: "claim-d", Value: []byte("claim-d-data")}
recordSessionA := &store.Record{Key: "session-a", Value: []byte(recordClaimA.Key)}
recordSessionB := &store.Record{Key: "session-b", Value: []byte(recordClaimB.Key)}
recordSubjectASessionC := &store.Record{Key: "subject-a.session-c", Value: []byte(recordSessionA.Key)}
recordSubjectASessionD := &store.Record{Key: "subject-a.session-d", Value: []byte(recordSessionB.Key)}
for _, r := range []*store.Record{
recordClaimA,
recordClaimB,
recordClaimC,
recordClaimD,
recordSessionA,
recordSessionB,
recordSubjectASessionC,
recordSubjectASessionD,
} {
require.NoError(t, sessionStore.Write(r))
}
tests := []struct {
name string
suSe SuSe
mode LogoutMode
store store.Store
wantRecords []*store.Record
wantError error
}{
{
name: "session-c",
suSe: SuSe{Session: "session-c"},
mode: LogoutModeSession,
store: sessionStore,
wantRecords: []*store.Record{recordSubjectASessionC},
},
{
name: "ession-c",
suSe: SuSe{Session: "ession-c"},
mode: LogoutModeSession,
store: sessionStore,
wantError: store.ErrNotFound,
wantRecords: []*store.Record{},
},
{
name: "subject-a",
suSe: SuSe{Subject: "subject-a"},
mode: LogoutModeSubject,
store: sessionStore,
wantRecords: []*store.Record{recordSubjectASessionC, recordSubjectASessionD},
},
{
name: "subject-",
suSe: SuSe{Subject: "subject-"},
mode: LogoutModeSubject,
store: sessionStore,
wantError: store.ErrNotFound,
wantRecords: []*store.Record{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
records, err := GetLogoutRecords(tt.suSe, tt.mode, tt.store)
require.ErrorIs(t, err, tt.wantError)
require.Len(t, records, len(tt.wantRecords))
sortRecords := func(r []*store.Record) []*store.Record {
slices.SortFunc(r, func(a, b *store.Record) int {
return strings.Compare(a.Key, b.Key)
})
return r
}
records = sortRecords(records)
for i, wantRecords := range sortRecords(tt.wantRecords) {
require.True(t, len(records) >= i+1)
require.Equal(t, wantRecords.Key, records[i].Key)
require.Equal(t, wantRecords.Value, records[i].Value)
}
})
}
}

View File

@@ -4,12 +4,13 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
microstore "go-micro.dev/v4/store"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/oidc"
"github.com/opencloud-eu/opencloud/services/proxy/pkg/config"
"github.com/opencloud-eu/opencloud/services/proxy/pkg/user/backend"
"github.com/opencloud-eu/reva/v2/pkg/events"
microstore "go-micro.dev/v4/store"
)
// StaticRouteHandler defines a Route Handler for static routes