// package backchannellogout provides functions to classify and lookup // backchannel logout records from the cache store. package backchannellogout import ( "encoding/base64" "errors" "strings" microstore "go-micro.dev/v4/store" ) // keyEncoding is the base64 encoding used for session and subject keys var keyEncoding = base64.URLEncoding // ErrInvalidKey indicates that the provided key does not conform to the expected format. var ErrInvalidKey = errors.New("invalid key format") // NewKey converts the subject and session to a base64 encoded key func NewKey(subject, session string) (string, error) { subjectSession := strings.Join([]string{ keyEncoding.EncodeToString([]byte(subject)), keyEncoding.EncodeToString([]byte(session)), }, ".") if subjectSession == "." { return "", ErrInvalidKey } return subjectSession, nil } // LogoutMode defines the mode of backchannel logout, either by session or by subject type LogoutMode int const ( // LogoutModeUndefined is used when the logout mode cannot be determined LogoutModeUndefined LogoutMode = iota // LogoutModeSubject is used when the logout mode is determined by the subject LogoutModeSubject // LogoutModeSession is used when the logout mode is determined by the session id LogoutModeSession ) // ErrDecoding is returned when decoding fails var ErrDecoding = errors.New("failed to decode") // 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 { encodedSubject string encodedSession string } // Subject decodes and returns the subject or an error func (suse SuSe) Subject() (string, error) { subject, err := keyEncoding.DecodeString(suse.encodedSubject) if err != nil { return "", errors.Join(errors.New("failed to decode subject"), ErrDecoding, err) } return string(subject), nil } // Session decodes and returns the session or an error func (suse SuSe) Session() (string, error) { subject, err := keyEncoding.DecodeString(suse.encodedSession) if err != nil { return "", errors.Join(errors.New("failed to decode session"), ErrDecoding, err) } return string(subject), nil } // Mode determines the backchannel logout mode based on the presence of subject and session func (suse SuSe) Mode() LogoutMode { switch { case suse.encodedSession == "" && suse.encodedSubject != "": return LogoutModeSubject case suse.encodedSession != "": return LogoutModeSession default: return LogoutModeUndefined } } // ErrInvalidSubjectOrSession is returned when the provided key does not match the expected key format var ErrInvalidSubjectOrSession = errors.New("invalid subject or session") // NewSuSe parses the subject and session id from the given key and returns a SuSe struct func NewSuSe(key string) (SuSe, error) { suse := SuSe{} keys := strings.Split(key, ".") switch len(keys) { case 1: suse.encodedSession = keys[0] case 2: suse.encodedSubject = keys[0] suse.encodedSession = keys[1] default: return suse, ErrInvalidSubjectOrSession } if suse.encodedSubject == "" && suse.encodedSession == "" { return suse, ErrInvalidSubjectOrSession } if _, err := suse.Subject(); err != nil { return suse, errors.Join(ErrInvalidSubjectOrSession, err) } if _, err := suse.Session(); err != nil { return suse, errors.Join(ErrInvalidSubjectOrSession, err) } if mode := suse.Mode(); mode == LogoutModeUndefined { return suse, ErrInvalidSubjectOrSession } return suse, nil } // 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, store microstore.Store) ([]*microstore.Record, error) { var key string var opts []microstore.ReadOption switch { case suse.Mode() == LogoutModeSubject && suse.encodedSubject != "": // 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.encodedSubject + "." opts = append(opts, microstore.ReadPrefix()) case suse.Mode() == LogoutModeSession && suse.encodedSession != "": // 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.encodedSession opts = append(opts, microstore.ReadSuffix()) default: return nil, errors.Join(errors.New("cannot determine logout mode"), ErrSuspiciousCacheResult) } // the go micro memory store requires a limit to work, why??? 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 suse.Mode() == LogoutModeSession && len(records) > 1 { return nil, errors.Join(errors.New("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, err := NewSuSe(record.Key) if err != nil { // never leak any key-related information return nil, errors.Join(errors.New("failed to parse key"), ErrSuspiciousCacheResult, err) } switch { // in subject mode, the subject must match, but the session id can be different case suse.Mode() == LogoutModeSubject && suse.encodedSubject == recordSuSe.encodedSubject: continue // in session mode, the session id must match, but the subject can be different case suse.Mode() == LogoutModeSession && suse.encodedSession == recordSuSe.encodedSession: continue } return nil, errors.Join(errors.New("key does not match the requested subject or session"), ErrSuspiciousCacheResult) } return records, nil }