diff --git a/neo4j/auth/auth.go b/neo4j/auth/auth.go index 31a990e6..0c83e50d 100644 --- a/neo4j/auth/auth.go +++ b/neo4j/auth/auth.go @@ -21,7 +21,9 @@ package auth import ( "context" + "github.com/neo4j/neo4j-go-driver/v5/neo4j/db" "github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/auth" + "github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/collections" "github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/racing" "reflect" "time" @@ -51,25 +53,29 @@ type TokenManager interface { // The token returned must always belong to the same identity. // Switching identities using the `TokenManager` is undefined behavior. GetAuthToken(ctx context.Context) (auth.Token, error) - // OnTokenExpired is called by the driver when the provided token expires - // OnTokenExpired should invalidate the current token if it matches the provided one - OnTokenExpired(context.Context, auth.Token) error + + // HandleSecurityException is called when the server returns any `Neo.ClientError.Security.*` error. + // It should return true if the error was handled, in which case the driver will mark the error as retryable. + HandleSecurityException(context.Context, auth.Token, *db.Neo4jError) (bool, error) } +type authTokenProvider = func(context.Context) (auth.Token, error) + type authTokenWithExpirationProvider = func(context.Context) (auth.Token, *time.Time, error) -type expirationBasedTokenManager struct { - provider authTokenWithExpirationProvider - token *auth.Token - expiration *time.Time - mutex racing.Mutex - now *func() time.Time +type neo4jAuthTokenManager struct { + provider authTokenWithExpirationProvider + token *auth.Token + expiration *time.Time + mutex racing.Mutex + now *func() time.Time + handledSecurityCodes collections.Set[string] } -func (m *expirationBasedTokenManager) GetAuthToken(ctx context.Context) (auth.Token, error) { +func (m *neo4jAuthTokenManager) GetAuthToken(ctx context.Context) (auth.Token, error) { if !m.mutex.TryLock(ctx) { return auth.Token{}, racing.LockTimeoutError( - "could not acquire lock in time when getting token in ExpirationBasedTokenManager") + "could not acquire lock in time when getting token in neo4jAuthTokenManager") } defer m.mutex.Unlock() if m.token == nil || m.expiration != nil && (*m.now)().After(*m.expiration) { @@ -83,34 +89,78 @@ func (m *expirationBasedTokenManager) GetAuthToken(ctx context.Context) (auth.To return *m.token, nil } -func (m *expirationBasedTokenManager) OnTokenExpired(ctx context.Context, token auth.Token) error { +func (m *neo4jAuthTokenManager) HandleSecurityException(ctx context.Context, token auth.Token, securityException *db.Neo4jError) (bool, error) { + if !m.handledSecurityCodes.Contains(securityException.Code) { + return false, nil + } if !m.mutex.TryLock(ctx) { - return racing.LockTimeoutError( - "could not acquire lock in time when handling token expiration in ExpirationBasedTokenManager") + return false, racing.LockTimeoutError( + "could not acquire lock in time when handling security exception in neo4jAuthTokenManager") } defer m.mutex.Unlock() if m.token != nil && reflect.DeepEqual(token.Tokens, m.token.Tokens) { m.token = nil } - return nil + return true, nil } -// ExpirationBasedTokenManager creates a token manager for potentially expiring auth info. +// BasicTokenManager generates a TokenManager to manage basic auth password rotation. +// The provider is invoked solely when a new token instance is required, triggered by server +// rejection of the current token due to an authentication exception. +// +// WARNING: // -// The first and only argument is a provider function that returns auth information and an optional expiration time. -// If the expiration time is nil, the auth info is assumed to never expire. +// The provider function *must not* interact with the driver in any way as this can cause deadlocks and undefined +// behaviour. +// +// The provider function must only ever return auth information belonging to the same identity. +// Switching identities is undefined behavior. +// +// BasicTokenManager is part of the re-authentication preview feature +// (see README on what it means in terms of support and compatibility guarantees) +func BasicTokenManager(provider authTokenProvider) TokenManager { + now := time.Now + return &neo4jAuthTokenManager{ + provider: wrapWithNilExpiration(provider), + mutex: racing.NewMutex(), + now: &now, + handledSecurityCodes: collections.NewSet([]string{ + "Neo.ClientError.Security.Unauthorized", + }), + } +} + +// BearerTokenManager generates a TokenManager to manage possibly expiring authentication details. +// +// The provider is invoked when a new token instance is required, triggered by server +// rejection of the current token due to authentication or token expiration exceptions. // // WARNING: // -// The provider function *must not* interact with the driver in any way as this can cause deadlocks and undefined -// behaviour. +// The provider function *must not* interact with the driver in any way as this can cause deadlocks and undefined +// behaviour. // -// The provider function only ever return auth information belonging to the same identity. -// Switching identities is undefined behavior. +// The provider function must only ever return auth information belonging to the same identity. +// Switching identities is undefined behavior. // -// ExpirationBasedTokenManager is part of the re-authentication preview feature +// BearerTokenManager is part of the re-authentication preview feature // (see README on what it means in terms of support and compatibility guarantees) -func ExpirationBasedTokenManager(provider authTokenWithExpirationProvider) TokenManager { +func BearerTokenManager(provider authTokenWithExpirationProvider) TokenManager { now := time.Now - return &expirationBasedTokenManager{provider: provider, mutex: racing.NewMutex(), now: &now} + return &neo4jAuthTokenManager{ + provider: provider, + mutex: racing.NewMutex(), + now: &now, + handledSecurityCodes: collections.NewSet([]string{ + "Neo.ClientError.Security.TokenExpired", + "Neo.ClientError.Security.Unauthorized", + }), + } +} + +func wrapWithNilExpiration(provider authTokenProvider) authTokenWithExpirationProvider { + return func(ctx context.Context) (auth.Token, *time.Time, error) { + token, err := provider(ctx) + return token, nil, err + } } diff --git a/neo4j/auth/auth_examples_test.go b/neo4j/auth/auth_examples_test.go index ab16c10a..47a27575 100644 --- a/neo4j/auth/auth_examples_test.go +++ b/neo4j/auth/auth_examples_test.go @@ -28,23 +28,41 @@ import ( "time" ) -func ExampleExpirationBasedTokenManager() { - myProvider := func(ctx context.Context) (neo4j.AuthToken, *time.Time, error) { - // some way to getting a token +func ExampleBasicTokenManager() { + fetchBasicAuthToken := func(ctx context.Context) (neo4j.AuthToken, error) { + // some way of getting basic authentication information + username, password, realm, err := getBasicAuth() + if err != nil { + return neo4j.AuthToken{}, err + } + // create and return a basic authentication token with provided username, password and realm + return neo4j.BasicAuth(username, password, realm), nil + } + // create a new driver with a basic token manager which uses provider to handle basic auth password rotation. + _, _ = neo4j.NewDriverWithContext(getUrl(), auth.BasicTokenManager(fetchBasicAuthToken)) +} + +func ExampleBearerTokenManager() { + fetchAuthTokenFromMyProvider := func(ctx context.Context) (neo4j.AuthToken, *time.Time, error) { + // some way of getting a token token, err := getSsoToken(ctx) if err != nil { return neo4j.AuthToken{}, nil, err } // assume we know our tokens expire every 60 seconds - expiresIn := time.Now().Add(60 * time.Second) // Include a little buffer so that we fetch a new token *before* the old one expires expiresIn = expiresIn.Add(-10 * time.Second) // or return nil instead of `&expiresIn` if we don't expect it to expire return token, &expiresIn, nil } + // create a new driver with a bearer token manager which uses provider to handle possibly expiring auth tokens. + _, _ = neo4j.NewDriverWithContext(getUrl(), auth.BearerTokenManager(fetchAuthTokenFromMyProvider)) +} - _, _ = neo4j.NewDriverWithContext(getUrl(), auth.ExpirationBasedTokenManager(myProvider)) +func getBasicAuth() (username, password, realm string, error error) { + username, password, realm = "username", "password", "realm" + return } func getSsoToken(context.Context) (neo4j.AuthToken, error) { diff --git a/neo4j/auth/auth_testkit.go b/neo4j/auth/auth_testkit.go index 49e4471e..6108258c 100644 --- a/neo4j/auth/auth_testkit.go +++ b/neo4j/auth/auth_testkit.go @@ -1,3 +1,5 @@ +//go:build internal_testkit + /* * Copyright (c) "Neo4j" * Neo4j Sweden AB [https://neo4j.com] @@ -17,20 +19,18 @@ * limitations under the License. */ -//go:build internal_testkit - package auth import "time" func SetTimer(t TokenManager, timer func() time.Time) { - if t, ok := t.(*expirationBasedTokenManager); ok { + if t, ok := t.(*neo4jAuthTokenManager); ok { t.now = &timer } } func ResetTime(t TokenManager) { - if t, ok := t.(*expirationBasedTokenManager); ok { + if t, ok := t.(*neo4jAuthTokenManager); ok { now := time.Now t.now = &now } diff --git a/neo4j/db/errors.go b/neo4j/db/errors.go index 585725ae..52dce579 100644 --- a/neo4j/db/errors.go +++ b/neo4j/db/errors.go @@ -88,6 +88,10 @@ func (e *Neo4jError) reclassify() { } } +func (e *Neo4jError) HasSecurityCode() bool { + return strings.HasPrefix(e.Code, "Neo.ClientError.Security.") +} + func (e *Neo4jError) IsAuthenticationFailed() bool { return e.Code == "Neo.ClientError.Security.Unauthorized" } diff --git a/neo4j/internal/auth/auth.go b/neo4j/internal/auth/auth.go index b2cea824..adcc74fc 100644 --- a/neo4j/internal/auth/auth.go +++ b/neo4j/internal/auth/auth.go @@ -19,7 +19,10 @@ package auth -import "context" +import ( + "context" + "github.com/neo4j/neo4j-go-driver/v5/neo4j/db" +) type Token struct { Tokens map[string]any @@ -29,4 +32,6 @@ func (a Token) GetAuthToken(context.Context) (Token, error) { return a, nil } -func (a Token) OnTokenExpired(context.Context, Token) error { return nil } +func (a Token) HandleSecurityException(context.Context, Token, *db.Neo4jError) (bool, error) { + return false, nil +} diff --git a/neo4j/internal/collections/set.go b/neo4j/internal/collections/set.go index 6b83b8d8..16127f6f 100644 --- a/neo4j/internal/collections/set.go +++ b/neo4j/internal/collections/set.go @@ -73,3 +73,8 @@ func (set Set[T]) Copy() Set[T] { } return result } + +func (set Set[T]) Contains(value T) bool { + _, ok := set[value] + return ok +} diff --git a/neo4j/internal/collections/set_test.go b/neo4j/internal/collections/set_test.go index 6f65be62..6488b6da 100644 --- a/neo4j/internal/collections/set_test.go +++ b/neo4j/internal/collections/set_test.go @@ -147,6 +147,28 @@ func TestSet(outer *testing.T) { t.Error(err) } }) + + outer.Run("contains", func(t *testing.T) { + strings := collections.NewSet([]string{ + "golang", + "neo4j", + }) + expected := "golang" + if found := strings.Contains(expected); !found { + t.Errorf("Set should have contained %v", expected) + } + }) + + outer.Run("does not contain", func(t *testing.T) { + strings := collections.NewSet([]string{ + "golang", + "neo4j", + }) + expected := "foobar" + if found := strings.Contains(expected); found { + t.Errorf("Set should not have contain %v", expected) + } + }) } func containsExactlyOnce[T comparable](values collections.Set[T], search T) bool { diff --git a/neo4j/internal/pool/pool.go b/neo4j/internal/pool/pool.go index 163b220e..eaa685f7 100644 --- a/neo4j/internal/pool/pool.go +++ b/neo4j/internal/pool/pool.go @@ -27,7 +27,6 @@ import ( "context" "github.com/neo4j/neo4j-go-driver/v5/neo4j/config" "github.com/neo4j/neo4j-go-driver/v5/neo4j/db" - "github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/auth" "github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/bolt" idb "github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/db" "github.com/neo4j/neo4j-go-driver/v5/neo4j/internal/errorutil" @@ -453,8 +452,7 @@ func (p *Pool) Return(ctx context.Context, c idb.Connection) { } func (p *Pool) OnNeo4jError(ctx context.Context, connection idb.Connection, error *db.Neo4jError) error { - switch error.Code { - case "Neo.ClientError.Security.AuthorizationExpired": + if error.Code == "Neo.ClientError.Security.AuthorizationExpired" { serverName := connection.ServerName() p.serversMut.Lock() defer p.serversMut.Unlock() @@ -462,26 +460,28 @@ func (p *Pool) OnNeo4jError(ctx context.Context, connection idb.Connection, erro server.executeForAllConnections(func(c idb.Connection) { c.ResetAuth() }) - case "Neo.ClientError.Security.TokenExpired": + } + if error.Code == "Neo.TransientError.General.DatabaseUnavailable" { + p.deactivate(ctx, connection.ServerName()) + } + if error.IsRetriableCluster() { + var database string + if dbSelector, ok := connection.(idb.DatabaseSelector); ok { + database = dbSelector.Database() + } + p.deactivateWriter(connection.ServerName(), database) + } + if error.HasSecurityCode() { manager, token := connection.GetCurrentAuth() if manager != nil { - if err := manager.OnTokenExpired(ctx, token); err != nil { + handled, err := manager.HandleSecurityException(ctx, token, error) + if err != nil { return err } - if _, isStaticToken := manager.(auth.Token); !isStaticToken { + if handled { error.MarkRetriable() } } - case "Neo.TransientError.General.DatabaseUnavailable": - p.deactivate(ctx, connection.ServerName()) - default: - if error.IsRetriableCluster() { - var database string - if dbSelector, ok := connection.(idb.DatabaseSelector); ok { - database = dbSelector.Database() - } - p.deactivateWriter(connection.ServerName(), database) - } } return nil diff --git a/testkit-backend/backend.go b/testkit-backend/backend.go index d4d26fd0..c4bf4723 100644 --- a/testkit-backend/backend.go +++ b/testkit-backend/backend.go @@ -43,25 +43,26 @@ import ( // Handles a testkit backend session. // Tracks all objects (and errors) that is created by testkit frontend. type backend struct { - rd *bufio.Reader // Socket to read requests from - wr io.Writer // Socket to write responses (and logs) on, don't buffer (WriteString on bufio was weird...) - drivers map[string]neo4j.DriverWithContext - sessionStates map[string]*sessionState - results map[string]neo4j.ResultWithContext - managedTransactions map[string]neo4j.ManagedTransaction - explicitTransactions map[string]neo4j.ExplicitTransaction - recordedErrors map[string]error - resolvedAddresses map[string][]any - authTokenManagers map[string]auth.TokenManager - resolvedGetAuthTokens map[string]neo4j.AuthToken - resolvedOnTokenExpiries map[string]bool - resolvedExpiringTokens map[string]AuthTokenAndExpiration - id int // ID to use for next object created by frontend - wrLock sync.Mutex - suppliedBookmarks map[string]neo4j.Bookmarks - consumedBookmarks map[string]struct{} - bookmarkManagers map[string]neo4j.BookmarkManager - timer *Timer + rd *bufio.Reader // Socket to read requests from + wr io.Writer // Socket to write responses (and logs) on, don't buffer (WriteString on bufio was weird...) + drivers map[string]neo4j.DriverWithContext + sessionStates map[string]*sessionState + results map[string]neo4j.ResultWithContext + managedTransactions map[string]neo4j.ManagedTransaction + explicitTransactions map[string]neo4j.ExplicitTransaction + recordedErrors map[string]error + resolvedAddresses map[string][]any + authTokenManagers map[string]auth.TokenManager + resolvedGetAuthTokens map[string]neo4j.AuthToken + resolvedHandleSecurityException map[string]bool + resolvedBasicTokens map[string]AuthToken + resolvedBearerTokens map[string]AuthTokenAndExpiration + id int // ID to use for next object created by frontend + wrLock sync.Mutex + suppliedBookmarks map[string]neo4j.Bookmarks + consumedBookmarks map[string]struct{} + bookmarkManagers map[string]neo4j.BookmarkManager + timer *Timer } type Timer struct { @@ -85,8 +86,12 @@ type sessionState struct { } type GenericTokenManager struct { - GetAuthTokenFunc func() neo4j.AuthToken - OnTokenExpiredFunc func(neo4j.AuthToken) + GetAuthTokenFunc func() neo4j.AuthToken + HandleSecurityExceptionFunc func(neo4j.AuthToken, *db.Neo4jError) bool +} + +type AuthToken struct { + token neo4j.AuthToken } type AuthTokenAndExpiration struct { @@ -98,9 +103,9 @@ func (g GenericTokenManager) GetAuthToken(_ context.Context) (neo4j.AuthToken, e return g.GetAuthTokenFunc(), nil } -func (g GenericTokenManager) OnTokenExpired(_ context.Context, token neo4j.AuthToken) error { - g.OnTokenExpiredFunc(token) - return nil +func (g GenericTokenManager) HandleSecurityException(_ context.Context, token neo4j.AuthToken, securityException *db.Neo4jError) (bool, error) { + handled := g.HandleSecurityExceptionFunc(token, securityException) + return handled, nil } const ( @@ -113,23 +118,24 @@ var ctx = context.Background() func newBackend(rd *bufio.Reader, wr io.Writer) *backend { return &backend{ - rd: rd, - wr: wr, - drivers: make(map[string]neo4j.DriverWithContext), - sessionStates: make(map[string]*sessionState), - results: make(map[string]neo4j.ResultWithContext), - managedTransactions: make(map[string]neo4j.ManagedTransaction), - explicitTransactions: make(map[string]neo4j.ExplicitTransaction), - recordedErrors: make(map[string]error), - resolvedAddresses: make(map[string][]any), - authTokenManagers: make(map[string]auth.TokenManager), - resolvedGetAuthTokens: make(map[string]neo4j.AuthToken), - resolvedOnTokenExpiries: make(map[string]bool), - resolvedExpiringTokens: make(map[string]AuthTokenAndExpiration), - id: 0, - bookmarkManagers: make(map[string]neo4j.BookmarkManager), - suppliedBookmarks: make(map[string]neo4j.Bookmarks), - consumedBookmarks: make(map[string]struct{}), + rd: rd, + wr: wr, + drivers: make(map[string]neo4j.DriverWithContext), + sessionStates: make(map[string]*sessionState), + results: make(map[string]neo4j.ResultWithContext), + managedTransactions: make(map[string]neo4j.ManagedTransaction), + explicitTransactions: make(map[string]neo4j.ExplicitTransaction), + recordedErrors: make(map[string]error), + resolvedAddresses: make(map[string][]any), + authTokenManagers: make(map[string]auth.TokenManager), + resolvedGetAuthTokens: make(map[string]neo4j.AuthToken), + resolvedHandleSecurityException: make(map[string]bool), + resolvedBasicTokens: make(map[string]AuthToken), + resolvedBearerTokens: make(map[string]AuthTokenAndExpiration), + id: 0, + bookmarkManagers: make(map[string]neo4j.BookmarkManager), + suppliedBookmarks: make(map[string]neo4j.Bookmarks), + consumedBookmarks: make(map[string]struct{}), } } @@ -172,6 +178,7 @@ func (b *backend) writeError(err error) { // track of this error so that we can reuse the real thing within a retryable tx fmt.Printf("Error: %s (%T)\n", err.Error(), err) code := "" + retriable := neo4j.IsRetryable(err) _, isHydrationError := err.(*db.ProtocolError) tokenErr, isTokenExpiredErr := err.(*neo4j.TokenExpiredError) if isTokenExpiredErr { @@ -193,7 +200,9 @@ func (b *backend) writeError(err error) { "id": id, "errorType": strings.Split(err.Error(), ":")[0], "msg": err.Error(), - "code": code}) + "code": code, + "retryable": retriable, + }) return } @@ -1004,20 +1013,21 @@ func (b *backend) handleRequest(req map[string]any) { } } }, - OnTokenExpiredFunc: func(token neo4j.AuthToken) { + HandleSecurityExceptionFunc: func(token neo4j.AuthToken, error *db.Neo4jError) bool { id := b.nextId() b.writeResponse( - "AuthTokenManagerOnAuthExpiredRequest", + "AuthTokenManagerHandleSecurityExceptionRequest", map[string]any{ "id": id, "authTokenManagerId": managerId, "auth": serializeAuth(token), + "errorCode": error.Code, }) for { b.process() - if _, ok := b.resolvedOnTokenExpiries[id]; ok { - delete(b.resolvedOnTokenExpiries, id) - return + if handled, ok := b.resolvedHandleSecurityException[id]; ok { + delete(b.resolvedHandleSecurityException, id) + return handled } } }, @@ -1032,26 +1042,56 @@ func (b *backend) handleRequest(req map[string]any) { return } b.resolvedGetAuthTokens[id] = token - case "AuthTokenManagerOnAuthExpiredCompleted": + case "AuthTokenManagerHandleSecurityExceptionCompleted": + handled := data["handled"].(bool) + id := data["requestId"].(string) + b.resolvedHandleSecurityException[id] = handled + case "NewBasicAuthTokenManager": + managerId := b.nextId() + + manager := auth.BasicTokenManager( + func(context.Context) (neo4j.AuthToken, error) { + id := b.nextId() + b.writeResponse( + "BasicAuthTokenProviderRequest", + map[string]any{ + "id": id, + "basicAuthTokenManagerId": managerId, + }) + for { + b.process() + if basicToken, ok := b.resolvedBasicTokens[id]; ok { + delete(b.resolvedBasicTokens, id) + return basicToken.token, nil + } + } + }) + if b.timer != nil { + auth.SetTimer(manager, b.timer.Now) + } + b.authTokenManagers[managerId] = manager + b.writeResponse("BasicAuthTokenManager", map[string]any{"id": managerId}) + case "BasicAuthTokenProviderCompleted": id := data["requestId"].(string) - b.resolvedOnTokenExpiries[id] = true - case "NewExpirationBasedAuthTokenManager": + token, _ := getAuth(data["auth"].(map[string]any)["data"].(map[string]any)) + b.resolvedBasicTokens[id] = AuthToken{token} + case "NewBearerAuthTokenManager": managerId := b.nextId() - manager := auth.ExpirationBasedTokenManager( + manager := auth.BearerTokenManager( func(context.Context) (neo4j.AuthToken, *time.Time, error) { id := b.nextId() b.writeResponse( - "ExpirationBasedAuthTokenProviderRequest", + "BearerAuthTokenProviderRequest", map[string]any{ - "id": id, - "expirationBasedAuthTokenManagerId": managerId, + "id": id, + "bearerAuthTokenManagerId": managerId, }) for { b.process() - if expiringToken, ok := b.resolvedExpiringTokens[id]; ok { - delete(b.resolvedExpiringTokens, id) - return expiringToken.token, expiringToken.expiration, nil + if bearerToken, ok := b.resolvedBearerTokens[id]; ok { + delete(b.resolvedBearerTokens, id) + return bearerToken.token, bearerToken.expiration, nil } } }) @@ -1059,11 +1099,11 @@ func (b *backend) handleRequest(req map[string]any) { auth.SetTimer(manager, b.timer.Now) } b.authTokenManagers[managerId] = manager - b.writeResponse("ExpirationBasedAuthTokenManager", map[string]any{"id": managerId}) - case "ExpirationBasedAuthTokenProviderCompleted": + b.writeResponse("BearerAuthTokenManager", map[string]any{"id": managerId}) + case "BearerAuthTokenProviderCompleted": id := data["requestId"].(string) - expiringToken := data["auth"].(map[string]any)["data"].(map[string]any) - token, err := getAuth(expiringToken["auth"].(map[string]any)["data"].(map[string]any)) + bearerToken := data["auth"].(map[string]any)["data"].(map[string]any) + token, err := getAuth(bearerToken["auth"].(map[string]any)["data"].(map[string]any)) if err != nil { b.writeError(err) return @@ -1075,13 +1115,13 @@ func (b *backend) handleRequest(req map[string]any) { } else { now = time.Now } - expiresInRaw := expiringToken["expiresInMs"] + expiresInRaw := bearerToken["expiresInMs"] if expiresInRaw != nil { - expiresIn := time.Millisecond * time.Duration(asInt64(expiringToken["expiresInMs"].(json.Number))) + expiresIn := time.Millisecond * time.Duration(asInt64(bearerToken["expiresInMs"].(json.Number))) expirationTime := now().Add(expiresIn) expiration = &expirationTime } - b.resolvedExpiringTokens[id] = AuthTokenAndExpiration{token, expiration} + b.resolvedBearerTokens[id] = AuthTokenAndExpiration{token, expiration} case "AuthTokenManagerClose": id := data["id"].(string) delete(b.authTokenManagers, id) @@ -1106,6 +1146,7 @@ func (b *backend) handleRequest(req map[string]any) { "Feature:API:Result.Peek", //"Feature:API:Result.Single", //"Feature:API:Result.SingleOptional", + "Feature:API:RetryableExceptions", "Feature:API:Session:AuthConfig", //"Feature:API:Session:NotificationsConfig", //"Feature:API:SSLConfig",