Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions auth0_flutter/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>

<details>
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any> {
val exceptionCause = this.cause
val isRetryable = when (exceptionCause) {
is AuthenticationException -> exceptionCause.isNetworkError
else -> false
}

val map = mutableMapOf<String, Any>("_isRetryable" to isRetryable)
if (exceptionCause != null) {
map["cause"] = exceptionCause.toString()
map["causeStackTrace"] = exceptionCause.stackTraceToString()
}
return map
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class GetCredentialsRequestHandler : CredentialsManagerRequestHandler {
credentialsManager.getCredentials(scope, minTtl, parameters, object:
Callback<Credentials, CredentialsManagerException> {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,7 +23,7 @@ class GetSSOCredentialsRequestHandler : CredentialsManagerRequestHandler {
credentialsManager.getSsoCredentials(parameters, object :
Callback<SSOCredentials, CredentialsManagerException> {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class RenewCredentialsRequestHandler : CredentialsManagerRequestHandler {
credentialsManager.getCredentials(null, 0, parameters, true, object :
Callback<Credentials, CredentialsManagerException> {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ class LoginWebAuthRequestHandler(

builder.start(context, object : Callback<Credentials, AuthenticationException> {
override fun onFailure(exception: AuthenticationException) {
result.error(exception.getCode(), exception.getDescription(), exception)
val details = mutableMapOf<String, Any>("_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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ class LogoutWebAuthRequestHandler(private val builderResolver: (MethodCallReques

builder.start(context, object : Callback<Void?, AuthenticationException> {
override fun onFailure(exception: AuthenticationException) {
result.error(exception.getCode(), exception.getDescription(), exception)
val details = mutableMapOf<String, Any>("_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?) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,36 @@ class LoginWebAuthRequestHandlerTest {
val mockRequest = MethodCallRequest(mockAccount, hashMapOf<String, Any>())
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<WebAuthProvider.Builder>()
val mockResult = mock<Result>()
val cause = RuntimeException("network error")
val exception = mock<AuthenticationException>()
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<Callback<Credentials, AuthenticationException>>(1)
cb.onFailure(exception)
}.`when`(builder).start(any(), any())

val handler = LoginWebAuthRequestHandler { _ -> builder }
val mockAccount = mock<Auth0>()
val mockRequest = MethodCallRequest(mockAccount, hashMapOf<String, Any>())
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -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<WebAuthProvider.LogoutBuilder>()
val mockResult = mock<Result>()
val handler = LogoutWebAuthRequestHandler { mockBuilder }
val cause = RuntimeException("network error")
val exception = mock<AuthenticationException>()
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<Callback<Void?, AuthenticationException>>(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

2 changes: 1 addition & 1 deletion auth0_flutter/darwin/auth0_flutter.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}


}
Loading
Loading