diff --git a/.github/actions/rl-scanner/action.yml b/.github/actions/rl-scanner/action.yml index f5a16441..1b424cdf 100644 --- a/.github/actions/rl-scanner/action.yml +++ b/.github/actions/rl-scanner/action.yml @@ -23,7 +23,7 @@ runs: pip install boto3 requests - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # pin@v4.3.0 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # pin@v4.3.0 with: role-to-assume: ${{ env.PRODSEC_TOOLS_ARN }} aws-region: 'us-east-1' diff --git a/.github/actions/setup-darwin/action.yml b/.github/actions/setup-darwin/action.yml index a49b2ad4..1e612c5b 100644 --- a/.github/actions/setup-darwin/action.yml +++ b/.github/actions/setup-darwin/action.yml @@ -36,7 +36,7 @@ runs: shell: bash - name: Set up Ruby - uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # pin@v1.299.0 + uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # pin@v1.300.0 with: ruby-version: ${{ inputs.ruby }} bundler-cache: true diff --git a/.github/actions/setup-publish/action.yml b/.github/actions/setup-publish/action.yml index d3e1a104..f6e44d24 100644 --- a/.github/actions/setup-publish/action.yml +++ b/.github/actions/setup-publish/action.yml @@ -15,7 +15,7 @@ runs: steps: - name: Install Flutter - uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # pin@v2.22.0 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # pin@v2.23.0 with: flutter-version: ${{ inputs.flutter }} channel: stable diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index adb3e602..e44d519e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -556,28 +556,28 @@ jobs: path: coverage/windows - name: Upload coverage report for auth0_flutter - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: name: Auth0 Flutter flags: auth0_flutter directory: coverage/auth0_flutter - name: Upload coverage report for auth0_flutter_platform_interface - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: name: Auth0 Flutter flags: auth0_flutter_platform_interface directory: coverage/auth0_flutter_platform_interface - name: Upload coverage report for iOS - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: name: Auth0 Flutter flags: auth0_flutter_ios directory: coverage/ios - name: Upload coverage report for Android - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: name: Auth0 Flutter flags: auth0_flutter_android diff --git a/.github/workflows/sca_scan.yml b/.github/workflows/sca_scan.yml new file mode 100644 index 00000000..a945d5a4 --- /dev/null +++ b/.github/workflows/sca_scan.yml @@ -0,0 +1,13 @@ +name: SCA + +on: + pull_request: + branches: ["main"] + workflow_dispatch: + +jobs: + snyk-cli: + uses: auth0/devsecops-tooling/.github/workflows/sca-scan.yml@main + with: + java-version: "17" + secrets: inherit diff --git a/appium-test/package-lock.json b/appium-test/package-lock.json index e2b6f96b..c9dcb44c 100644 --- a/appium-test/package-lock.json +++ b/appium-test/package-lock.json @@ -461,9 +461,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.1.tgz", + "integrity": "sha512-0yaL8JdxTknKDILitVpfYfV2Ob6yb3udX/hK97M7I3jOeznBNxQPtVvTUtnhUkyHlxFWyr5Lvknmgzoc7jf+1Q==", "dev": true, "license": "MIT", "engines": { @@ -1807,9 +1807,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, diff --git a/auth0_flutter/EXAMPLES.md b/auth0_flutter/EXAMPLES.md index 2b593d08..533ae1db 100644 --- a/auth0_flutter/EXAMPLES.md +++ b/auth0_flutter/EXAMPLES.md @@ -382,10 +382,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. +
@@ -750,6 +756,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. @@ -1121,10 +1148,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 00000000..1cfa8688 --- /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 664d5ea2..45abd90a 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 7e2fd73f..25aed859 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 7124511d..a4f1c4db 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 5eb210a9..a480d207 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 3e8f90a6..a120b1ff 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 00000000..94a3c6ea --- /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 f24d6753..6db3c70c 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 e0efb40a..5666f491 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 9401dffe..a3efc75b 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 fe7318a9..6f706b99 100644 --- a/auth0_flutter/darwin/Classes/WebAuth/WebAuthExtensions.swift +++ b/auth0_flutter/darwin/Classes/WebAuth/WebAuthExtensions.swift @@ -10,17 +10,20 @@ extension FlutterError { convenience init(from webAuthError: WebAuthError) { var code: String switch webAuthError { - case .noBundleIdentifier: code = "NO_BUNDLE_IDENTIFIER" - case .invalidInvitationURL: code = "INVALID_INVITATION_URL" - case .userCancelled: code = "USER_CANCELLED" - case .noAuthorizationCode: code = "NO_AUTHORIZATION_CODE" - case .pkceNotAllowed: code = "PKCE_NOT_ALLOWED" - case .idTokenValidationFailed: code = "ID_TOKEN_VALIDATION_FAILED" - case .transactionActiveAlready: code = "TRANSACTION_ACTIVE_ALREADY" - case .other: code = "OTHER" + case WebAuthError.noBundleIdentifier: code = "NO_BUNDLE_IDENTIFIER" + case WebAuthError.invalidInvitationURL: code = "INVALID_INVITATION_URL" + case WebAuthError.userCancelled: code = "USER_CANCELLED" + case WebAuthError.noAuthorizationCode: code = "NO_AUTHORIZATION_CODE" + case WebAuthError.pkceNotAllowed: code = "PKCE_NOT_ALLOWED" + case WebAuthError.idTokenValidationFailed: code = "ID_TOKEN_VALIDATION_FAILED" + case WebAuthError.transactionActiveAlready: code = "TRANSACTION_ACTIVE_ALREADY" + case WebAuthError.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 64559143..d684d11b 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/Gemfile.lock b/auth0_flutter/example/ios/Gemfile.lock index 28fd29b6..bc587521 100644 --- a/auth0_flutter/example/ios/Gemfile.lock +++ b/auth0_flutter/example/ios/Gemfile.lock @@ -3,20 +3,33 @@ GEM specs: CFPropertyList (3.0.6) rexml - activesupport (7.0.7.2) - concurrent-ruby (~> 1.0, >= 1.0.2) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) atomos (0.1.3) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.0.1) claide (1.1.0) clamp (1.3.2) colored2 (3.1.2) - concurrent-ruby (1.2.2) - i18n (1.14.1) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + drb (2.2.3) + i18n (1.14.8) concurrent-ruby (~> 1.0) + logger (1.7.0) mini_portile2 (2.8.9) - minitest (5.19.0) + minitest (5.27.0) nanaimo (0.3.0) nokogiri (1.19.1) mini_portile2 (~> 2.8.2) @@ -25,6 +38,7 @@ GEM racc (~> 1.4) racc (1.8.1) rexml (3.4.2) + securerandom (0.4.1) slather (2.7.4) CFPropertyList (>= 2.2, < 4) activesupport diff --git a/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerExtensionsTests.swift b/auth0_flutter/example/ios/Tests/CredentialsManager/CredentialsManagerExtensionsTests.swift index c9fa1e60..3af6ade4 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 74963e74..2828969c 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 1b8fe238..4bef3c43 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 64559143..d684d11b 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 64559143..d684d11b 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 bf03dcbd..02cd441d 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 ec8ffdcc..4f463c47 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 dcfa87c5..8e3d0b40 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 7793de30..3cedc27f 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 4c7f0e97..1b0d4a74 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 42eee995..d81fb797 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); + }); }); }