diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index b85355e5e..e82102342 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -345,10 +345,16 @@ try { final credentials = await auth0.webAuthentication().login(); // ... } on WebAuthenticationException catch (e) { - print(e); + if (e.isRetryable) { + // Transient error (e.g. network issue) — safe to retry + } else { + print(e); + } } ``` +The `isRetryable` property indicates whether the error is transient (e.g. a network outage) and the operation can be retried. +
@@ -521,6 +527,27 @@ print(e); } ``` +#### Retryable errors + +The `isRetryable` property on `CredentialsManagerException` indicates whether the error is transient and the operation can be retried. When `true`, the failure is likely due to a temporary condition such as a network outage. When `false`, the failure is permanent (e.g. an invalid refresh token) and retrying will not help — you should log the user out instead. + +```dart +try { + final credentials = await auth0.credentialsManager.credentials(); + // ... +} on CredentialsManagerException catch (e) { + if (e.isRetryable) { + // Transient error (e.g. network issue) — safe to retry + print("Temporary error, retrying..."); + } else { + // Permanent error — log the user out + print("Credentials cannot be renewed: ${e.message}"); + } +} +``` + +> The `isRetryable` property is available on all exception types (`CredentialsManagerException`, `ApiException`, `WebAuthenticationException`) across Android and iOS/macOS. It returns `true` when the underlying failure is network-related, indicating the operation may succeed on retry. + ### Native to Web SSO Native to Web SSO allows authenticated users in your native mobile application to seamlessly transition to your web application without requiring them to log in again. This is achieved by exchanging a refresh token for a Session Transfer Token, which can then be used to establish a session in the web application. @@ -891,10 +918,16 @@ try { connectionOrRealm: connection); // ... } on ApiException catch (e) { - print(e); + if (e.isRetryable) { + // Transient error (e.g. network issue) — safe to retry + } else { + print(e); + } } ``` +The `isRetryable` property indicates whether the error is transient (e.g. a network outage) and the operation can be retried. It returns `true` when `isNetworkError` is `true`. + [Go up ⤴](#examples) ## 🌐📱 Organizations diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerExceptionExtensions.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerExceptionExtensions.kt new file mode 100644 index 000000000..1cfa8688b --- /dev/null +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/CredentialsManagerExceptionExtensions.kt @@ -0,0 +1,19 @@ +package com.auth0.auth0_flutter + +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.storage.CredentialsManagerException + +fun CredentialsManagerException.toMap(): Map { + val exceptionCause = this.cause + val isRetryable = when (exceptionCause) { + is AuthenticationException -> exceptionCause.isNetworkError + else -> false + } + + val map = mutableMapOf("_isRetryable" to isRetryable) + if (exceptionCause != null) { + map["cause"] = exceptionCause.toString() + map["causeStackTrace"] = exceptionCause.stackTraceToString() + } + return map +} diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandler.kt index 664d5ea2f..45abd90ae 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetCredentialsRequestHandler.kt @@ -32,7 +32,7 @@ class GetCredentialsRequestHandler : CredentialsManagerRequestHandler { credentialsManager.getCredentials(scope, minTtl, parameters, object: Callback { override fun onFailure(exception: CredentialsManagerException) { - result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception) + result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception.toMap()) } override fun onSuccess(credentials: Credentials) { diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt index 7e2fd73ff..25aed8597 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/GetSSOCredentialsRequestHandler.kt @@ -6,6 +6,7 @@ import com.auth0.android.authentication.storage.SecureCredentialsManager import com.auth0.android.callback.Callback import com.auth0.android.result.SSOCredentials import com.auth0.auth0_flutter.request_handlers.MethodCallRequest +import com.auth0.auth0_flutter.toMap import io.flutter.plugin.common.MethodChannel class GetSSOCredentialsRequestHandler : CredentialsManagerRequestHandler { @@ -22,7 +23,7 @@ class GetSSOCredentialsRequestHandler : CredentialsManagerRequestHandler { credentialsManager.getSsoCredentials(parameters, object : Callback { override fun onFailure(exception: CredentialsManagerException) { - result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception) + result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception.toMap()) } override fun onSuccess(credentials: SSOCredentials) { diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandler.kt index 7124511d3..a4f1c4db0 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/credentials_manager/RenewCredentialsRequestHandler.kt @@ -24,7 +24,7 @@ class RenewCredentialsRequestHandler : CredentialsManagerRequestHandler { credentialsManager.getCredentials(null, 0, parameters, true, object : Callback { override fun onFailure(exception: CredentialsManagerException) { - result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception) + result.error(exception.message ?: "UNKNOWN ERROR", exception.message, exception.toMap()) } override fun onSuccess(credentials: Credentials) { diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt index e21e983e5..eb8f764b2 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LoginWebAuthRequestHandler.kt @@ -80,7 +80,12 @@ class LoginWebAuthRequestHandler( builder.start(context, object : Callback { override fun onFailure(exception: AuthenticationException) { - result.error(exception.getCode(), exception.getDescription(), exception) + val details = mutableMapOf("_isRetryable" to exception.isNetworkError) + exception.cause?.let { + details["cause"] = it.toString() + details["causeStackTrace"] = it.stackTraceToString() + } + result.error(exception.getCode(), exception.getDescription(), details) } override fun onSuccess(credentials: Credentials) { diff --git a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt index 3e8f90a61..a120b1fff 100644 --- a/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt +++ b/auth0_flutter/android/src/main/kotlin/com/auth0/auth0_flutter/request_handlers/web_auth/LogoutWebAuthRequestHandler.kt @@ -46,7 +46,12 @@ class LogoutWebAuthRequestHandler(private val builderResolver: (MethodCallReques builder.start(context, object : Callback { override fun onFailure(exception: AuthenticationException) { - result.error(exception.getCode(), exception.getDescription(), exception) + val details = mutableMapOf("_isRetryable" to exception.isNetworkError) + exception.cause?.let { + details["cause"] = it.toString() + details["causeStackTrace"] = it.stackTraceToString() + } + result.error(exception.getCode(), exception.getDescription(), details) } override fun onSuccess(res: Void?) { diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerExceptionExtensionsTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerExceptionExtensionsTest.kt new file mode 100644 index 000000000..94a3c6ea7 --- /dev/null +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/CredentialsManagerExceptionExtensionsTest.kt @@ -0,0 +1,94 @@ +package com.auth0.auth0_flutter + +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.storage.CredentialsManagerException +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CredentialsManagerExceptionExtensionsTest { + + @Test + fun `should set isRetryable to false when cause is null`() { + val exception = mock(CredentialsManagerException::class.java) + `when`(exception.cause).thenReturn(null) + + val map = exception.toMap() + + assertThat(map["_isRetryable"], equalTo(false)) + } + + @Test + fun `should set isRetryable to true when cause is a network AuthenticationException`() { + val authException = mock(AuthenticationException::class.java) + `when`(authException.isNetworkError).thenReturn(true) + + val exception = mock(CredentialsManagerException::class.java) + `when`(exception.cause).thenReturn(authException) + + val map = exception.toMap() + + assertThat(map["_isRetryable"], equalTo(true)) + } + + @Test + fun `should set isRetryable to false when cause is a non-network AuthenticationException`() { + val authException = mock(AuthenticationException::class.java) + `when`(authException.isNetworkError).thenReturn(false) + + val exception = mock(CredentialsManagerException::class.java) + `when`(exception.cause).thenReturn(authException) + + val map = exception.toMap() + + assertThat(map["_isRetryable"], equalTo(false)) + } + + @Test + fun `should set isRetryable to false when cause is a generic exception`() { + val exception = mock(CredentialsManagerException::class.java) + `when`(exception.cause).thenReturn(RuntimeException("generic error")) + + val map = exception.toMap() + + assertThat(map["_isRetryable"], equalTo(false)) + } + + @Test + fun `should include cause in map when cause is present`() { + val cause = RuntimeException("network error") + val exception = mock(CredentialsManagerException::class.java) + `when`(exception.cause).thenReturn(cause) + + val map = exception.toMap() + + assertThat(map["cause"], equalTo(cause.toString())) + } + + @Test + fun `should include causeStackTrace in map when cause is present`() { + val cause = RuntimeException("network error") + val exception = mock(CredentialsManagerException::class.java) + `when`(exception.cause).thenReturn(cause) + + val map = exception.toMap() + + assertThat(map["causeStackTrace"], equalTo(cause.stackTraceToString())) + } + + @Test + fun `should not include cause or causeStackTrace in map when cause is null`() { + val exception = mock(CredentialsManagerException::class.java) + `when`(exception.cause).thenReturn(null) + + val map = exception.toMap() + + assertThat(map.containsKey("cause"), equalTo(false)) + assertThat(map.containsKey("causeStackTrace"), equalTo(false)) + } +} diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt index f24d6753e..6db3c70c2 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LoginWebAuthRequestHandlerTest.kt @@ -305,7 +305,36 @@ class LoginWebAuthRequestHandlerTest { val mockRequest = MethodCallRequest(mockAccount, hashMapOf()) handler.handle(mock(), mockRequest, mockResult) - verify(mockResult).error("code", "description", exception) + verify(mockResult).error(eq("code"), eq("description"), eq(mapOf("_isRetryable" to false))) + } + + @Test + fun `returns cause and causeStackTrace in error details when cause is present`() { + val builder = mock() + val mockResult = mock() + val cause = RuntimeException("network error") + val exception = mock() + whenever(exception.getCode()).thenReturn("code") + whenever(exception.getDescription()).thenReturn("description") + whenever(exception.isNetworkError).thenReturn(true) + whenever(exception.cause).thenReturn(cause) + + doAnswer { invocation -> + val cb = invocation.getArgument>(1) + cb.onFailure(exception) + }.`when`(builder).start(any(), any()) + + val handler = LoginWebAuthRequestHandler { _ -> builder } + val mockAccount = mock() + val mockRequest = MethodCallRequest(mockAccount, hashMapOf()) + handler.handle(mock(), mockRequest, mockResult) + + verify(mockResult).error(eq("code"), eq("description"), check { + val map = it as Map<*, *> + assertThat(map["_isRetryable"], equalTo(true)) + assertThat(map["cause"], equalTo(cause.toString())) + assertThat(map["causeStackTrace"], equalTo(cause.stackTraceToString())) + }) } @Test diff --git a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt index e0efb40a0..5666f4911 100644 --- a/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt +++ b/auth0_flutter/android/src/test/kotlin/com/auth0/auth0_flutter/LogoutWebAuthRequestHandlerTest.kt @@ -9,6 +9,8 @@ import com.auth0.android.provider.WebAuthProvider import com.auth0.auth0_flutter.request_handlers.MethodCallRequest import com.auth0.auth0_flutter.request_handlers.web_auth.LogoutWebAuthRequestHandler import io.flutter.plugin.common.MethodChannel.Result +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.* @@ -122,7 +124,34 @@ class LogoutWebAuthRequestHandlerTest { handler.handle(mock(), MethodCallRequest(Auth0.getInstance("test-client", "test-domain"), mock()), mockResult) - verify(mockResult).error("code", "description", exception) + verify(mockResult).error(eq("code"), eq("description"), eq(mapOf("_isRetryable" to false))) + } + + @Test + fun `returns cause and causeStackTrace in error details when cause is present`() { + val mockBuilder = mock() + val mockResult = mock() + val handler = LogoutWebAuthRequestHandler { mockBuilder } + val cause = RuntimeException("network error") + val exception = mock() + whenever(exception.getCode()).thenReturn("code") + whenever(exception.getDescription()).thenReturn("description") + whenever(exception.isNetworkError).thenReturn(true) + whenever(exception.cause).thenReturn(cause) + + doAnswer { invocation -> + val callback = invocation.getArgument>(1) + callback.onFailure(exception) + }.`when`(mockBuilder).start(any(), any()) + + handler.handle(mock(), MethodCallRequest(Auth0.getInstance("test-client", "test-domain"), mock()), mockResult) + + verify(mockResult).error(eq("code"), eq("description"), check { + val map = it as Map<*, *> + assertThat(map["_isRetryable"], equalTo(true)) + assertThat(map["cause"], equalTo(cause.toString())) + assertThat(map["causeStackTrace"], equalTo(cause.stackTraceToString())) + }) } @Test diff --git a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerExtensions.swift b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerExtensions.swift index 9401dffe0..a3efc75ba 100644 --- a/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerExtensions.swift +++ b/auth0_flutter/darwin/Classes/CredentialsManager/CredentialsManagerExtensions.swift @@ -20,8 +20,13 @@ extension FlutterError { default: code = "UNKNOWN" } + let isRetryable = (credentialsManagerError.cause as? Auth0APIError)?.isRetryable ?? false + + var errorDetails = credentialsManagerError.details + errorDetails["_isRetryable"] = isRetryable + self.init(code: code, message: String(describing: credentialsManagerError), - details: credentialsManagerError.details) + details: errorDetails) } } diff --git a/auth0_flutter/darwin/Classes/WebAuth/WebAuthExtensions.swift b/auth0_flutter/darwin/Classes/WebAuth/WebAuthExtensions.swift index fe7318a94..263c728ed 100644 --- a/auth0_flutter/darwin/Classes/WebAuth/WebAuthExtensions.swift +++ b/auth0_flutter/darwin/Classes/WebAuth/WebAuthExtensions.swift @@ -20,7 +20,10 @@ extension FlutterError { case .other: code = "OTHER" default: code = "UNKNOWN" } - self.init(code: code, message: String(describing: webAuthError), details: webAuthError.details) + var details = webAuthError.details + let isRetryable = (webAuthError.cause as? Auth0APIError)?.isRetryable ?? false + details["_isRetryable"] = isRetryable + self.init(code: code, message: String(describing: webAuthError), details: details) } } diff --git a/auth0_flutter/darwin/auth0_flutter.podspec b/auth0_flutter/darwin/auth0_flutter.podspec index 5797d1292..830789925 100644 --- a/auth0_flutter/darwin/auth0_flutter.podspec +++ b/auth0_flutter/darwin/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.16.2' + s.dependency 'Auth0', '2.18.0' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerExtensionsTests.swift b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerExtensionsTests.swift index c9fa1e602..3af6ade4e 100644 --- a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerExtensionsTests.swift +++ b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerExtensionsTests.swift @@ -19,4 +19,24 @@ class CredentialsManagerExtensionsTests: XCTestCase { assert(flutterError: flutterError, is: error, with: code) } } + + func testIsRetryableIsFalseForErrorsWithoutNetworkCause() { + let nonRetryableErrors: [CredentialsManagerError] = [ + .noCredentials, + .noRefreshToken, + .renewFailed, + .storeFailed, + .biometricsFailed, + .revokeFailed, + .largeMinTTL + ] + for error in nonRetryableErrors { + let flutterError = FlutterError(from: error) + let details = flutterError.details as! [String: Any] + XCTAssertEqual(details["_isRetryable"] as? Bool, false, + "Expected isRetryable to be false for \(error)") + } + } + + } diff --git a/auth0_flutter/example/ios/Tests/Utilities.swift b/auth0_flutter/example/ios/Tests/Utilities.swift index 74963e744..2828969c5 100644 --- a/auth0_flutter/example/ios/Tests/Utilities.swift +++ b/auth0_flutter/example/ios/Tests/Utilities.swift @@ -112,6 +112,12 @@ func assert(result: Any?, isError handlerError: HandlerError) { func assert(flutterError: FlutterError, is webAuthError: WebAuthError, with code: String) { XCTAssertEqual(flutterError.code, code) XCTAssertEqual(flutterError.message, String(describing: webAuthError)) + + guard let details = flutterError.details as? [String: Any] else { + return XCTFail("The FlutterError is missing the 'details' dictionary") + } + XCTAssertNotNil(details["_isRetryable"]) + XCTAssertTrue(details["_isRetryable"] is Bool) } func assert(flutterError: FlutterError, is authenticationError: AuthenticationError) { @@ -151,4 +157,10 @@ func assert(flutterError: FlutterError, is authenticationError: AuthenticationEr func assert(flutterError: FlutterError, is credentialsManagerError: CredentialsManagerError, with code: String) { XCTAssertEqual(flutterError.code, code) XCTAssertEqual(flutterError.message, String(describing: credentialsManagerError)) + + guard let details = flutterError.details as? [String: Any] else { + return XCTFail("The FlutterError is missing the 'details' dictionary") + } + XCTAssertNotNil(details["_isRetryable"]) + XCTAssertTrue(details["_isRetryable"] is Bool) } diff --git a/auth0_flutter/example/ios/Tests/WebAuth/WebAuthExtensionsTests.swift b/auth0_flutter/example/ios/Tests/WebAuth/WebAuthExtensionsTests.swift index 1b8fe238f..4bef3c43c 100644 --- a/auth0_flutter/example/ios/Tests/WebAuth/WebAuthExtensionsTests.swift +++ b/auth0_flutter/example/ios/Tests/WebAuth/WebAuthExtensionsTests.swift @@ -21,4 +21,24 @@ class WebAuthExtensionsTests: XCTestCase { assert(flutterError: flutterError, is: error, with: code) } } + + func testIsRetryableIsFalseForNonNetworkErrors() { + let nonRetryableErrors: [WebAuthError] = [ + .userCancelled, + .noBundleIdentifier, + .invalidInvitationURL, + .noAuthorizationCode, + .pkceNotAllowed, + .idTokenValidationFailed, + .transactionActiveAlready, + .other + ] + for error in nonRetryableErrors { + let flutterError = FlutterError(from: error) + let details = flutterError.details as! [String: Any] + XCTAssertEqual(details["_isRetryable"] as? Bool, false, + "Expected isRetryable to be false for \(error)") + } + } + } diff --git a/auth0_flutter/ios/auth0_flutter.podspec b/auth0_flutter/ios/auth0_flutter.podspec index 5797d1292..830789925 100644 --- a/auth0_flutter/ios/auth0_flutter.podspec +++ b/auth0_flutter/ios/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.16.2' + s.dependency 'Auth0', '2.18.0' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter/macos/auth0_flutter.podspec b/auth0_flutter/macos/auth0_flutter.podspec index 5797d1292..830789925 100644 --- a/auth0_flutter/macos/auth0_flutter.podspec +++ b/auth0_flutter/macos/auth0_flutter.podspec @@ -19,7 +19,7 @@ Pod::Spec.new do |s| s.osx.deployment_target = '11.0' s.osx.dependency 'FlutterMacOS' - s.dependency 'Auth0', '2.16.2' + s.dependency 'Auth0', '2.18.0' s.dependency 'JWTDecode', '3.3.0' s.dependency 'SimpleKeychain', '1.3.0' diff --git a/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart b/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart index bf03dcbde..02cd441d8 100644 --- a/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart +++ b/auth0_flutter_platform_interface/lib/src/auth/api_exception.dart @@ -72,4 +72,5 @@ class ApiException extends Auth0Exception { bool get isPasswordLeaked => _errorFlags.getBooleanOrFalse('isPasswordLeaked'); bool get isLoginRequired => _errorFlags.getBooleanOrFalse('isLoginRequired'); + bool get isRetryable => isNetworkError; } diff --git a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_exception.dart b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_exception.dart index ec8ffdccc..4f463c47c 100644 --- a/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_exception.dart +++ b/auth0_flutter_platform_interface/lib/src/credentials-manager/credentials_manager_exception.dart @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import '../auth0_exception.dart'; import '../extensions/exception_extensions.dart'; +import '../extensions/map_extensions.dart'; // ignore: comment_references /// Exception thrown by [MethodChannelCredentialsManager] when something goes @@ -37,4 +38,10 @@ An error occurred while trying to use the Refresh Token to renew the Credentials code == ''' Credentials need to be renewed but no Refresh Token is available to renew them.'''; + + /// Whether the error is transient and the operation can be retried. + /// When `true`, the failure is likely due to a temporary condition such as + /// a network outage. When `false`, the failure is permanent (e.g. an + /// invalid refresh token) and the user should be logged out instead. + bool get isRetryable => details.getBooleanOrFalse('_isRetryable'); } diff --git a/auth0_flutter_platform_interface/lib/src/web-auth/web_authentication_exception.dart b/auth0_flutter_platform_interface/lib/src/web-auth/web_authentication_exception.dart index dcfa87c5c..8e3d0b401 100644 --- a/auth0_flutter_platform_interface/lib/src/web-auth/web_authentication_exception.dart +++ b/auth0_flutter_platform_interface/lib/src/web-auth/web_authentication_exception.dart @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import '../auth0_exception.dart'; import '../extensions/exception_extensions.dart'; +import '../extensions/map_extensions.dart'; class WebAuthenticationException extends Auth0Exception { const WebAuthenticationException(final String code, final String message, @@ -16,4 +17,6 @@ class WebAuthenticationException extends Auth0Exception { bool get isUserCancelledException => code == 'USER_CANCELLED' || code == 'a0.authentication_canceled'; + + bool get isRetryable => details.getBooleanOrFalse('_isRetryable'); } diff --git a/auth0_flutter_platform_interface/test/api_exception_test.dart b/auth0_flutter_platform_interface/test/api_exception_test.dart index 7793de308..3cedc27f1 100644 --- a/auth0_flutter_platform_interface/test/api_exception_test.dart +++ b/auth0_flutter_platform_interface/test/api_exception_test.dart @@ -251,4 +251,35 @@ void main() { final exception = ApiException.fromPlatformException(platformException); expect(exception.mfaToken, null); }); + + test('isRetryable returns true when isNetworkError is true', () async { + final details = { + '_errorFlags': {'isNetworkError': true} + }; + final platformException = + PlatformException(code: 'test-code', details: details); + + final exception = ApiException.fromPlatformException(platformException); + expect(exception.isRetryable, true); + }); + + test('isRetryable returns false when isNetworkError is false', () async { + final details = { + '_errorFlags': {'isNetworkError': false} + }; + final platformException = + PlatformException(code: 'test-code', details: details); + + final exception = ApiException.fromPlatformException(platformException); + expect(exception.isRetryable, false); + }); + + test('isRetryable returns false when errorFlags are missing', () async { + final details = {}; + final platformException = + PlatformException(code: 'test-code', details: details); + + final exception = ApiException.fromPlatformException(platformException); + expect(exception.isRetryable, false); + }); } diff --git a/auth0_flutter_platform_interface/test/credential_manager_exception_test.dart b/auth0_flutter_platform_interface/test/credential_manager_exception_test.dart index 4c7f0e97b..1b0d4a741 100644 --- a/auth0_flutter_platform_interface/test/credential_manager_exception_test.dart +++ b/auth0_flutter_platform_interface/test/credential_manager_exception_test.dart @@ -18,5 +18,38 @@ void main() { expect(exception.message, 'test-message'); expect(exception.details['details-prop'], 'details-value'); }); + + test('isRetryable returns true when _isRetryable flag is true', () { + final details = {'_isRetryable': true}; + final platformException = PlatformException( + code: 'RENEW_FAILED', message: 'test-message', details: details); + + final exception = + CredentialsManagerException.fromPlatformException(platformException); + + expect(exception.isRetryable, true); + }); + + test('isRetryable returns false when _isRetryable flag is false', () { + final details = {'_isRetryable': false}; + final platformException = PlatformException( + code: 'RENEW_FAILED', message: 'test-message', details: details); + + final exception = + CredentialsManagerException.fromPlatformException(platformException); + + expect(exception.isRetryable, false); + }); + + test('isRetryable returns false when _isRetryable flag is missing', () { + final details = {}; + final platformException = PlatformException( + code: 'RENEW_FAILED', message: 'test-message', details: details); + + final exception = + CredentialsManagerException.fromPlatformException(platformException); + + expect(exception.isRetryable, false); + }); }); } diff --git a/auth0_flutter_platform_interface/test/web_authentication_exception_test.dart b/auth0_flutter_platform_interface/test/web_authentication_exception_test.dart index 42eee9957..d81fb7973 100644 --- a/auth0_flutter_platform_interface/test/web_authentication_exception_test.dart +++ b/auth0_flutter_platform_interface/test/web_authentication_exception_test.dart @@ -18,5 +18,38 @@ void main() { expect(exception.message, 'test-message'); expect(exception.details['details-prop'], 'details-value'); }); + + test('isRetryable returns true when _isRetryable flag is true', () { + final details = {'_isRetryable': true}; + final platformException = PlatformException( + code: 'OTHER', message: 'test-message', details: details); + + final exception = + WebAuthenticationException.fromPlatformException(platformException); + + expect(exception.isRetryable, true); + }); + + test('isRetryable returns false when _isRetryable flag is false', () { + final details = {'_isRetryable': false}; + final platformException = PlatformException( + code: 'USER_CANCELLED', message: 'test-message', details: details); + + final exception = + WebAuthenticationException.fromPlatformException(platformException); + + expect(exception.isRetryable, false); + }); + + test('isRetryable returns false when _isRetryable flag is missing', () { + final details = {}; + final platformException = PlatformException( + code: 'OTHER', message: 'test-message', details: details); + + final exception = + WebAuthenticationException.fromPlatformException(platformException); + + expect(exception.isRetryable, false); + }); }); }