diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b6aa8d91..754d67b289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ [Full Changelog](In progress) +## FxA Client + +### What's changed + +- Major changes to the Kotlin wrappers. They now present a higher-level action/event system. The + goal here is to allow new consumers to use this directly, without a lot of extra code like + android-components needed. + ## Rust log forwarder ### 🦊 What's Changed 🦊 diff --git a/build.gradle b/build.gradle index f6cdaf881c..fc0b63f048 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ buildscript { ktlint_version = '0.50.0' gradle_download_task_version = '5.2.1' junit_version = '4.13.2' + mockk_version = '1.13.8' mockito_core_version = '5.5.0' robolectric_core_version = '4.10.3' rust_android_gradle_version = '0.9.3' diff --git a/components/fxa-client/android/build.gradle b/components/fxa-client/android/build.gradle index b70607cd1c..681a77424e 100644 --- a/components/fxa-client/android/build.gradle +++ b/components/fxa-client/android/build.gradle @@ -4,6 +4,10 @@ apply from: "$rootDir/publish.gradle" dependencies { api project(':sync15') + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" + + testImplementation("io.mockk:mockk:${mockk_version}") + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_coroutines_version" } ext.configureUniFFIBindgen("../src/fxa_client.udl") diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/Config.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/Config.kt index 761de5fd52..78c16dd0a7 100644 --- a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/Config.kt +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/Config.kt @@ -4,55 +4,13 @@ package mozilla.appservices.fxaclient -/** - * Config represents the server endpoint configurations needed for the - * authentication flow. - */ -class Config constructor( - val server: FxaServer, - val clientId: String, - val redirectUri: String, - val tokenServerUrlOverride: String? = null, -) { - enum class Server(val rustServer: FxaServer) { - RELEASE(FxaServer.Release), - STABLE(FxaServer.Stable), - STAGE(FxaServer.Stage), - CHINA(FxaServer.China), - LOCALDEV(FxaServer.LocalDev), - ; - - val contentUrl get() = this.rustServer.contentUrl - } - - constructor( - server: Server, - clientId: String, - redirectUri: String, - tokenServerUrlOverride: String? = null, - ) : this(server.rustServer, clientId, redirectUri, tokenServerUrlOverride) - - constructor( - contentUrl: String, - clientId: String, - redirectUri: String, - tokenServerUrlOverride: String? = null, - ) : this(FxaServer.Custom(contentUrl), clientId, redirectUri, tokenServerUrlOverride) - - val contentUrl get() = this.server.contentUrl - - // Rust defines a config and server class that's virtually identically to these. We should - // remove the wrapper soon, but let's wait until we have a batch of breaking changes and do them - // all at once. - fun intoRustConfig() = FxaConfig(server, clientId, redirectUri, tokenServerUrlOverride) +fun FxaServer.isCustom() = this is FxaServer.Custom + +fun FxaServer.contentUrl() = when (this) { + is FxaServer.Release -> "https://accounts.firefox.com" + is FxaServer.Stable -> "https://stable.dev.lcip.org" + is FxaServer.Stage -> "https://accounts.stage.mozaws.net" + is FxaServer.China -> "https://accounts.firefox.com.cn" + is FxaServer.LocalDev -> "http://127.0.0.1:3030" + is FxaServer.Custom -> this.url } - -val FxaServer.contentUrl: String - get() = when (this) { - is FxaServer.Release -> "https://accounts.firefox.com" - is FxaServer.Stable -> "https://stable.dev.lcip.org" - is FxaServer.Stage -> "https://accounts.stage.mozaws.net" - is FxaServer.China -> "https://accounts.firefox.com.cn" - is FxaServer.LocalDev -> "http://127.0.0.1:3030" - is FxaServer.Custom -> this.url - } diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaActionProcessor.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaActionProcessor.kt new file mode 100644 index 0000000000..0b6044fedc --- /dev/null +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaActionProcessor.kt @@ -0,0 +1,342 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.appservices.fxaclient + +import android.util.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Processes actions sent to [FxaClient.queueAction] and sends events to the [FxaEventHandler] + * + * See [FxaClient.queueAction] for details on why we handle actions like this. + * + * [FxaActionProcessor] is "inert" on construction and won't process any events. Call + * [runActionProcessorManager] start event processing. This creates a manager task that monitors + * the action handling loop and restarts it if it throws for any reason. + * + */ +internal class FxaActionProcessor( + private val inner: FirefoxAccount, + private val eventHandler: FxaEventHandler, + private val persistState: () -> Unit, + initialState: FxaAuthState = FxaAuthState.fromRust(inner.getAuthState()), +) { + // Set a high bound on the channel so that if our the processChannel() task dies, or is never + // started, we should eventually see error reports. + private val channel = Channel(100) + internal val retryLogic = RetryLogic() + internal var currentState = initialState + + @Volatile + internal var simulateNetworkErrorFlag: Boolean = false + + // Queue a new action for processing + fun queue(action: FxaAction) { + // trySend allows this function to be non-suspend and will never fail since the channel size + // is UNLIMITED + channel.trySend(action) + } + + // Shutdown the ActionProcessor + fun close() { + channel.close() + } + + internal suspend fun processChannel() { + Log.d(LOG_TAG, "processChannel started: $currentState") + for (action in channel) { + Log.d(LOG_TAG, "Processing action: $action") + processAction(action) + Log.d(LOG_TAG, "Action processed: $action") + } + // Exiting the for loop means the channel is closed, which means we are closed. + // Time to quit. + } + + @Suppress("ComplexMethod") + internal suspend fun processAction(action: FxaAction) { + val currentState = currentState + // Match on the current state since many actions are only valid from a particular state + when (currentState) { + is FxaAuthState.Connected -> when (action) { + is FxaAction.Disconnect -> handleDisconnect(action) + FxaAction.CheckAuthorization -> handleCheckAuthorization(currentState) + is FxaAction.InitializeDevice -> handleInitializeDevice(action) + is FxaAction.EnsureCapabilities -> handleEnsureCapabilities(action) + is FxaAction.SetDeviceName -> handleSetDeviceName(action) + is FxaAction.SetDevicePushSubscription -> handleSetDevicePushSubscription(action) + is FxaAction.SendSingleTab -> handleSendSingleTab(action) + else -> Log.e(LOG_TAG, "Invalid $action (state: $currentState)") + } + is FxaAuthState.Disconnected -> when (action) { + is FxaAction.BeginOAuthFlow -> handleBeginOAuthFlow(currentState, action) + is FxaAction.BeginPairingFlow -> handleBeginPairingFlow(currentState, action) + is FxaAction.CompleteOAuthFlow -> handleCompleteFlow(currentState, action) + is FxaAction.CancelOAuthFlow -> handleCancelFlow(currentState) + // If we see Disconnect or CheckAuthorization from the Disconnected state, just ignore it + FxaAction.CheckAuthorization -> Unit + is FxaAction.Disconnect -> Unit + else -> Log.e(LOG_TAG, "Invalid $action (state: $currentState)") + } + } + } + + internal suspend fun sendEvent(event: FxaEvent) { + Log.d(LOG_TAG, "Sending $event") + @Suppress("TooGenericExceptionCaught") + try { + eventHandler.onFxaEvent(event) + } catch (e: Exception) { + Log.e(LOG_TAG, "Exception sending $event", e) + } + } + + private suspend fun changeState(newState: FxaAuthState, transition: FxaAuthStateTransition) { + Log.d(LOG_TAG, "Changing state from $currentState to $newState") + currentState = newState + sendEvent(FxaEvent.AuthStateChanged(newState, transition)) + } + + // Perform an operation, retrying after network errors + private suspend fun withNetworkRetry(operation: suspend () -> T): T { + while (true) { + try { + if (simulateNetworkErrorFlag) { + simulateNetworkErrorFlag = false + throw FxaException.Network("Simulated Error") + } + return operation() + } catch (e: FxaException.Network) { + if (retryLogic.shouldRetryAfterNetworkError()) { + Log.d(LOG_TAG, "Network error: retrying") + continue + } else { + Log.d(LOG_TAG, "Network error: not retrying") + throw e + } + } + } + } + + // Perform an operation, retrying after network errors and calling checkAuthorizationStatus + // after auth errors + private suspend fun withRetry(operation: suspend () -> T): T { + while (true) { + try { + return withNetworkRetry(operation) + } catch (e: FxaException.Authentication) { + val currentState = currentState + + if (currentState !is FxaAuthState.Connected) { + throw e + } + + if (retryLogic.shouldRecheckAuthStatus()) { + Log.d(LOG_TAG, "Auth error: re-checking") + handleCheckAuthorization(currentState) + } else { + Log.d(LOG_TAG, "Auth error: disconnecting") + inner.disconnectFromAuthIssues() + persistState() + changeState(FxaAuthState.Disconnected(true), FxaAuthStateTransition.AUTH_CHECK_FAILED) + } + + if (this.currentState is FxaAuthState.Connected) { + continue + } else { + throw e + } + } + } + } + + private suspend fun handleBeginOAuthFlow(currentState: FxaAuthState.Disconnected, action: FxaAction.BeginOAuthFlow) { + handleBeginEitherOAuthFlow(currentState, action.result) { + inner.beginOauthFlow(action.scopes.toList(), action.entrypoint, MetricsParams(mapOf())) + } + } + + private suspend fun handleBeginPairingFlow(currentState: FxaAuthState.Disconnected, action: FxaAction.BeginPairingFlow) { + handleBeginEitherOAuthFlow(currentState, action.result) { + inner.beginPairingFlow(action.pairingUrl, action.scopes.toList(), action.entrypoint, MetricsParams(mapOf())) + } + } + + private suspend fun handleBeginEitherOAuthFlow(currentState: FxaAuthState.Disconnected, result: CompletableDeferred?, operation: () -> String) { + try { + val url = withRetry { operation() } + persistState() + changeState(currentState.copy(connecting = true), FxaAuthStateTransition.OAUTH_STARTED) + sendEvent(FxaEvent.BeginOAuthFlow(url)) + result?.complete(url) + } catch (e: FxaException) { + Log.e(LOG_TAG, "Exception when handling BeginOAuthFlow", e) + persistState() + changeState(currentState.copy(connecting = false), FxaAuthStateTransition.OAUTH_FAILED_TO_BEGIN) + result?.complete(null) + } + } + + private suspend fun handleCompleteFlow(currentState: FxaAuthState.Disconnected, action: FxaAction.CompleteOAuthFlow) { + try { + withRetry { inner.completeOauthFlow(action.code, action.state) } + persistState() + changeState(FxaAuthState.Connected(), FxaAuthStateTransition.OAUTH_COMPLETE) + } catch (e: FxaException) { + persistState() + Log.e(LOG_TAG, "Exception when handling CompleteOAuthFlow", e) + changeState(currentState, FxaAuthStateTransition.OAUTH_FAILED_TO_COMPLETE) + } + } + + private suspend fun handleCancelFlow(currentState: FxaAuthState.Disconnected) { + // No need to call an inner method or persist the state, since the connecting flag is + // handled soley in this layer + changeState(currentState.copy(connecting = false), FxaAuthStateTransition.OAUTH_CANCELLED) + } + + private suspend fun handleInitializeDevice(action: FxaAction.InitializeDevice) { + handleDeviceOperation(action, FxaDeviceOperation.INITIALIZE_DEVICE, action.result) { + withRetry { inner.initializeDevice(action.name, action.deviceType, action.supportedCapabilities) } + } + } + + private suspend fun handleEnsureCapabilities(action: FxaAction.EnsureCapabilities) { + handleDeviceOperation(action, FxaDeviceOperation.ENSURE_CAPABILITIES, action.result) { + withRetry { inner.ensureCapabilities(action.supportedCapabilities) } + } + } + + private suspend fun handleSetDeviceName(action: FxaAction.SetDeviceName) { + handleDeviceOperation(action, FxaDeviceOperation.SET_DEVICE_NAME, action.result) { + withRetry { inner.setDeviceName(action.displayName) } + } + } + + private suspend fun handleSetDevicePushSubscription(action: FxaAction.SetDevicePushSubscription) { + handleDeviceOperation(action, FxaDeviceOperation.SET_DEVICE_PUSH_SUBSCRIPTION, action.result) { + withRetry { inner.setPushSubscription(DevicePushSubscription(action.endpoint, action.publicKey, action.authKey)) } + } + } + + private suspend fun handleSendSingleTab(action: FxaAction.SendSingleTab) { + try { + withRetry { inner.sendSingleTab(action.targetDeviceId, action.title, action.url) } + action.result?.complete(true) + } catch (e: FxaException) { + Log.e(LOG_TAG, "Exception when handling $action", e) + action.result?.complete(false) + } + } + + private suspend fun handleDeviceOperation( + action: FxaAction, + operation: FxaDeviceOperation, + result: CompletableDeferred?, + block: suspend () -> LocalDevice, + ) { + try { + val localDevice = block() + sendEvent(FxaEvent.DeviceOperationComplete(operation, localDevice)) + result?.complete(true) + } catch (e: FxaException) { + Log.e(LOG_TAG, "Exception when handling $action", e) + sendEvent(FxaEvent.DeviceOperationFailed(operation)) + result?.complete(false) + } + } + + private suspend fun handleDisconnect(action: FxaAction.Disconnect) { + if (action.fromAuthIssues) { + inner.disconnectFromAuthIssues() + persistState() + changeState(FxaAuthState.Disconnected(fromAuthIssues = true), FxaAuthStateTransition.AUTH_CHECK_FAILED) + } else { + inner.disconnect() + persistState() + changeState(FxaAuthState.Disconnected(), FxaAuthStateTransition.DISCONNECTED) + } + } + + private suspend fun handleCheckAuthorization(currentState: FxaAuthState.Connected) { + changeState(currentState.copy(authCheckInProgress = true), FxaAuthStateTransition.AUTH_CHECK_STARTED) + val success = try { + val status = withNetworkRetry { inner.checkAuthorizationStatus() } + status.active + } catch (e: FxaException.Authentication) { + // The Rust code should handle this exception, but if it doesn't, let's consider it a + // failed check. + Log.e(LOG_TAG, "Authentication exception when checking authorization status", e) + false + } catch (e: FxaException) { + Log.e(LOG_TAG, "Exception when checking authorization status", e) + // It's not clear what's the Right Thing to do in this case, but considering it a success is + // better than a failure. We don't want users logged out because of network issues. + true + } + if (success) { + persistState() + changeState(currentState.copy(authCheckInProgress = false), FxaAuthStateTransition.AUTH_CHECK_SUCCESS) + } else { + inner.disconnectFromAuthIssues() + persistState() + changeState(FxaAuthState.Disconnected(true), FxaAuthStateTransition.AUTH_CHECK_FAILED) + } + } +} + +internal class RetryLogic { + private var lastNetworkRetry: Long = 0 + private var lastAuthCheck: Long = 0 + + fun shouldRetryAfterNetworkError(): Boolean { + val elasped = (System.currentTimeMillis() - lastNetworkRetry).milliseconds + if (elasped > 30.seconds) { + lastNetworkRetry = System.currentTimeMillis() + return true + } else { + return false + } + } + + fun shouldRecheckAuthStatus(): Boolean { + val elasped = (System.currentTimeMillis() - lastAuthCheck).milliseconds + if (elasped > 60.seconds) { + lastAuthCheck = System.currentTimeMillis() + return true + } else { + return false + } + } + + // For testing + fun fastForward(amount: Duration) { + lastNetworkRetry -= amount.inWholeMilliseconds + lastAuthCheck -= amount.inWholeMilliseconds + } +} + +// Startup a top-level job that keeps processChannel() running until `close()` is called. +internal fun runActionProcessorManager(actionProcessor: FxaActionProcessor, coroutineContext: CoroutineContext) { + CoroutineScope(coroutineContext).launch { + while (true) { + @Suppress("TooGenericExceptionCaught") + try { + actionProcessor.processChannel() + // If processChannel returns, then it's time to quit + break + } catch (e: Exception) { + Log.e(LOG_TAG, "Exception in processChannel", e) + } + } + } +} diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt new file mode 100644 index 0000000000..22123a5a49 --- /dev/null +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FxaClient.kt @@ -0,0 +1,427 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.appservices.fxaclient + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext + +// This allows `adb logcat | grep fxa_client` to pick up all log items from this component +internal const val LOG_TAG = "fxa_client" + +/** + * Firefox account client + */ +class FxaClient private constructor( + private val inner: FirefoxAccount, + private val persistCallback: FxaPersistCallback, + // Note that this creates a reference cycle between FxaAccountManager, FxaClient, and FxaHandler + // that goes through the Rust code and therefore will never be broken. This is okay for now, + // since all of those objects are intended to live forever, but hopefully we can add some sort + // of weak reference support to side-step this. + private val eventHandler: FxaEventHandler, + coroutineContext: CoroutineContext, +) : AutoCloseable { + /** + * CoroutineContext context to run async tasks in. + * + * This is the coroutineContext passed in to the constructor plus a SupervisorJob. The + * SupervisorJob ensures that if one task fails the other still run. + * + * Because we use a single CoroutineContext, [close] can cancel all active jobs. + * + * This is public so applications can use the same context to run their jobs in. For example + * the android-components FxaManager uses it for its startup tasks. + */ + public val coroutineContext = coroutineContext + SupervisorJob() + + /** + * Create a new FxaClient + * + * @param config FxaConfig to initialize the client with + * @param FxaEventHandler Respond to FxA events + * @param coroutineContext CoroutineContext for the client. + */ + constructor(config: FxaConfig, persistCallback: FxaPersistCallback, eventHandler: FxaEventHandler, coroutineContext: CoroutineContext = Dispatchers.IO) : this( + FirefoxAccount(config), + persistCallback, + eventHandler, + coroutineContext, + ) + + companion object { + /** + * Restores a perisisted FxaClient + * + * @param json JSON data sent to FxaEventHandler.persistData + * @param FxaEventHandler Respond to FxA events + * @param coroutineContext CoroutineContext for the client. + * @return [FxaClient] representing the authentication state + */ + fun fromJson(json: String, persistCallback: FxaPersistCallback, eventHandler: FxaEventHandler, coroutineContext: CoroutineContext = Dispatchers.IO): FxaClient { + return FxaClient( + FirefoxAccount.fromJson(json), + persistCallback, + eventHandler, + coroutineContext, + ) + } + } + + private fun persistState() { + @Suppress("TooGenericExceptionCaught") + try { + persistCallback.persist(inner.toJson()) + } catch (e: Exception) { + Log.e(LOG_TAG, "Error saving the FirefoxAccount state.") + } + } + + /** + * Queue an FxaAction for processing and immediately return + * + * Use the FxaEventHandler passed to the constructor to respond to the results of queued + * actions. + * + * Why do this? + * - Actions are processed serially. For example, there's no chance of CompleteOAuthFlow and + * Disconnect being executed at the same time from different threads. + * - Application events are also sent serially. If one action causes an [AUTH_CHECK_STARTED] + * state transition change, then the next causes [FxaAuthState.Disconnected], the + * [FxaEventHandler.onStateChange] callback for the second change won't be called until + * after the callback for the first change returns. This allows applications to ensure that + * their UI reflects the current state. + * - Actions are retried in the face of network errors and checkAuthorizationStatus is called on + * authorization errors. This allows the Rust client to recover when possible, for example + * from expired access tokens when it holds a valid refresh token. + */ + fun queueAction(action: FxaAction) { + actionProcessor.queue(action) + } + + // This handles queueAction for us + private val actionProcessor = FxaActionProcessor(inner, eventHandler, { persistState() }) + init { + runActionProcessorManager(actionProcessor, coroutineContext) + } + + /** + * Get the current authentication state + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + */ + fun getAuthState(): FxaAuthState = FxaAuthState.fromRust(inner.getAuthState()) + + // Wraps method calls that don't change the state, like [getAccessToken] + private suspend fun wrapMethodCall(name: String, methodCall: () -> T): T { + Log.d(LOG_TAG, "Running: $name") + return withContext(coroutineContext) { + try { + methodCall() + } catch (e: FxaException.Authentication) { + queueAction(FxaAction.CheckAuthorization) + throw e + } + } + } + + // Does what wrapMethodCall does and also ensures that the state is perisisted at the end of the + // operation. + private suspend fun wrapMethodCallAndPersist(name: String, methodCall: () -> T): T { + return try { + wrapMethodCall(name, methodCall) + } finally { + // Use a finally block since we want to perisist the state regardless of if the method + // succeeded or not. + persistState() + } + } + + /** + * Fetches the profile object for the current client either from the existing cached account, + * or from the server (requires the client to have access to the profile scope). + * + * @param ignoreCache Fetch the profile information directly from the server + * @return [Profile] representing the user's basic profile info + * @throws FxaException.Unauthorized We couldn't find any suitable access token to make that call. + * The caller should then start the OAuth Flow again with the "profile" scope. + */ + suspend fun getProfile(ignoreCache: Boolean): Profile = wrapMethodCallAndPersist("getProfile") { + inner.getProfile(ignoreCache) + } + + /** + * Convenience method to fetch the profile from a cached account by default, but fall back + * to retrieval from the server. + * + * @return [Profile] representing the user's basic profile info + * @throws FxaException.Unauthorized We couldn't find any suitable access token to make that call. + * The caller should then start the OAuth Flow again with the "profile" scope. + */ + suspend fun getProfile(): Profile = getProfile(false) + + /** + * Fetches the token server endpoint, for authenticating to Firefox Sync via OAuth. + */ + suspend fun getTokenServerEndpointURL(): String = wrapMethodCall("getTokenServerEndpointURL") { + inner.getTokenServerEndpointUrl() + } + + /** + * Get the pairing URL to navigate to on the Auth side (typically a computer). + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + */ + fun getPairingAuthorityURL(): String { + return inner.getPairingAuthorityUrl() + } + + /** + * Fetches the connection success url. + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + */ + fun getConnectionSuccessURL(): String { + return inner.getConnectionSuccessUrl() + } + + /** + * Fetches the user's manage-account url. + * + * @throws FxaException.Unauthorized We couldn't find any suitable access token to identify the user. + * The caller should then start the OAuth Flow again with the "profile" scope. + */ + suspend fun getManageAccountURL(entrypoint: String): String = wrapMethodCall("getManageAccountURL") { + inner.getManageAccountUrl(entrypoint) + } + + /** + * Fetches the user's manage-devices url. + * + * @throws FxaException.Unauthorized We couldn't find any suitable access token to identify the user. + * The caller should then start the OAuth Flow again with the "profile" scope. + */ + suspend fun getManageDevicesURL(entrypoint: String): String = wrapMethodCall("getManageDevicesURL") { + inner.getManageDevicesUrl(entrypoint) + } + + /** + * Tries to fetch an access token for the given scope. + * + * @param scope Single OAuth scope (no spaces) for which the client wants access + * @param ttl time in seconds for which the token will be valid + * @return [AccessTokenInfo] that stores the token, along with its scopes and keys when complete + * @throws FxaException.Unauthorized We couldn't provide an access token + * for this scope. The caller should then start the OAuth Flow again with + * the desired scope. + */ + suspend fun getAccessToken( + scope: String, + ttl: Long? = null, + requireScopedKey: Boolean = false, + ): AccessTokenInfo = wrapMethodCallAndPersist("getAccessToken") { + inner.getAccessToken(scope, ttl, requireScopedKey) + } + + /** + * Tries to return a session token + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + * + * @throws FxaException Will send you an exception if there is no session token set + */ + fun getSessionToken(): String { + return inner.getSessionToken() + } + + /** + * Get the current device id + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + * + * @throws FxaException Will send you an exception if there is no device id set + */ + fun getCurrentDeviceId(): String { + return inner.getCurrentDeviceId() + } + + /** + * Provisions an OAuth code using the session token from state + * + * @param authParams Parameters needed for the authorization request + * This performs network requests, and should not be used on the main thread. + */ + suspend fun authorizeOAuthCode( + authParams: AuthorizationParameters, + ): String = wrapMethodCall("authorizeOAuthCode") { + inner.authorizeCodeUsingSessionToken(authParams) + } + + /** + * This method should be called when a request made with + * an OAuth token failed with an authentication error. + * It clears the internal cache of OAuth access tokens, + * so the caller can try to call [getAccessToken] or [getProfile] + * again. + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + */ + fun clearAccessTokenCache() { + inner.clearAccessTokenCache() + } + + /** + * Saves the current account's authentication state as a JSON string, for persistence in + * the Android KeyStore/shared preferences. The authentication state can be restored using + * [FirefoxAccount.fromJSONString]. + * + * FIXME: https://github.com/mozilla/application-services/issues/5819 + * + * @return String containing the authentication details in JSON format + */ + fun toJsonString(): String { + return inner.toJson() + } + + /** + * Retrieves the list of the connected devices in the current account, including the current one. + */ + suspend fun getDevices(ignoreCache: Boolean = false): Array = wrapMethodCallAndPersist("getDevices") { + inner.getDevices(ignoreCache).toTypedArray() + } + + /** + * Retrieves any pending commands for the current device. + * This should be called semi-regularly as the main method of commands delivery (push) + * can sometimes be unreliable on mobile devices. + * If a persist callback is set and the host application failed to process the + * returned account events, they will never be seen again. + * + * @return A collection of [IncomingDeviceCommand] that should be handled by the caller. + */ + suspend fun pollDeviceCommands(): Array = wrapMethodCallAndPersist("pollDeviceCommands") { + inner.pollDeviceCommands().toTypedArray() + } + + /** + * Retrieves the account event associated with an + * incoming push message payload coming Firefox Accounts. + * Assumes the message that has been decrypted and authenticated by the Push crate. + * + * @return A collection of [AccountEvent] that should be handled by the caller. + */ + suspend fun handlePushMessage(payload: String): AccountEvent = wrapMethodCallAndPersist("handlePushMessage") { + inner.handlePushMessage(payload) + } + + /** + * Gather any telemetry which has been collected internally and return + * the result as a JSON string. + */ + suspend fun gatherTelemetry(): String = wrapMethodCall("gatherTelemetry") { + inner.gatherTelemetry() + } + + @Synchronized + override fun close() { + this.actionProcessor.close() + this.coroutineContext.cancel() + this.inner.destroy() + } + + /** + * Constructs a URL used to begin the OAuth flow for the requested scopes and keys. + * + * Deprecated: Call `queueAction(FxaAction.BeginOAuthFlow(...))` instead. + * + * This performs network requests, and should not be used on the main thread. + * + * @param scopes List of OAuth scopes for which the client wants access + * @param entrypoint to be used for metrics + * @param metricsParams optional parameters used for metrics + * @return String that resolves to the flow URL when complete + */ + @Deprecated("Send beginOAuthFlow to queueAction instead") + fun beginOAuthFlow( + scopes: Array, + entrypoint: String, + metricsParams: MetricsParams = MetricsParams(mapOf()), + ): String { + return this.inner.beginOauthFlow(scopes.toList(), entrypoint, metricsParams) + } + + /** + * Begins the pairing flow. + * + * Deprecated: Call `queueAction(FxaAction.BeginPairingFlow(...))` instead. + * + * This performs network requests, and should not be used on the main thread. + * + * @param pairingUrl the url to initilaize the paring flow with + * @param scopes List of OAuth scopes for which the client wants access + * @param entrypoint to be used for metrics + * @param metricsParams optional parameters used for metrics + * @return String that resoles to the flow URL when complete + */ + @Deprecated("Send beginPairingFlow to queueAction instead") + fun beginPairingFlow( + pairingUrl: String, + scopes: Array, + entrypoint: String, + metricsParams: MetricsParams = MetricsParams(mapOf()), + ): String { + return this.inner.beginPairingFlow(pairingUrl, scopes.toList(), entrypoint, metricsParams) + } + + /** + * Authenticates the current account using the code and state parameters fetched from the + * redirect URL reached after completing the sign in flow triggered by [beginOAuthFlow]. + * + * Deprecated: Call `queueAction(FxaAction.CompleteOAuthFlow(...))` instead. + * + * Modifies the FirefoxAccount state. + * + * This performs network requests, and should not be used on the main thread. + */ + @Deprecated("Send CompleteOAuthFlow to queueAction instead") + fun completeOAuthFlow(code: String, state: String) { + this.inner.completeOauthFlow(code, state) + this.persistState() + } + + // These are used to test error handling in real applications, for example in Firefox Android + // with the secret debug menu + public fun simulateNetworkError() { + Log.w(LOG_TAG, "simulateNetworkError") + actionProcessor.simulateNetworkErrorFlag = true + } + + public fun simulateTemporaryAuthTokenIssue() { + Log.w(LOG_TAG, "simulateTemporaryAuthTokenIssue") + inner.simulateTemporaryAuthTokenIssue() + } + + public fun simulatePermanentAuthTokenIssue() { + Log.w(LOG_TAG, "simulatePermanentAuthTokenIssue") + inner.simulatePermanentAuthTokenIssue() + } +} + +/** + * Implemented by applications to save the Fxa state + */ +interface FxaPersistCallback { + fun persist(data: String) +} + +/** + * Implemented by applications to respond to Fxa events + */ +interface FxaEventHandler { + suspend fun onFxaEvent(event: FxaEvent) +} diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/PersistedFirefoxAccount.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/PersistedFirefoxAccount.kt deleted file mode 100644 index 37b8ab8759..0000000000 --- a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/PersistedFirefoxAccount.kt +++ /dev/null @@ -1,448 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package mozilla.appservices.fxaclient - -import android.util.Log -import mozilla.appservices.sync15.DeviceType - -/** - * PersistedFirefoxAccount represents the authentication state of a client. - * - * This is a thin wrapper around the `FirefoxAccount` object exposed from Rust. - * Its main job is to transparently manage persistence of the account state by - * calling the provided callback at appropriate times. - * - * In future it would be nice to push this logic down into the Rust code, - * once UniFFI's support for callback interfaces is a little more battle-tested. - * - */ -class PersistedFirefoxAccount(inner: FirefoxAccount, persistCallback: PersistCallback?) : AutoCloseable { - private var inner: FirefoxAccount = inner - private var persistCallback: PersistCallback? = persistCallback - - /** - * Create a PersistedFirefoxAccount using the given config. - * - * This does not make network requests, and can be used on the main thread. - * - */ - constructor(config: Config, persistCallback: PersistCallback? = null) : this( - FirefoxAccount(config.intoRustConfig()), - persistCallback, - ) { - // Persist the newly created instance state. - this.tryPersistState() - } - - companion object { - /** - * Restores the account's authentication state from a JSON string produced by - * [PersistedFirefoxAccount.toJSONString]. - * - * This does not make network requests, and can be used on the main thread. - * - * @return [PersistedFirefoxAccount] representing the authentication state - */ - fun fromJSONString(json: String, persistCallback: PersistCallback? = null): PersistedFirefoxAccount { - return PersistedFirefoxAccount(FirefoxAccount.fromJson(json), persistCallback) - } - } - - interface PersistCallback { - fun persist(data: String) - } - - /** - * Registers a PersistCallback that will be called every time the - * FirefoxAccount internal state has mutated. - * The FirefoxAccount instance can be later restored using the - * `fromJSONString` class method. - * It is the responsibility of the consumer to ensure the persisted data - * is saved in a secure location, as it can contain Sync Keys and - * OAuth tokens. - */ - fun registerPersistCallback(persistCallback: PersistCallback) { - this.persistCallback = persistCallback - } - - /** - * Unregisters any previously registered PersistCallback. - */ - fun unregisterPersistCallback() { - this.persistCallback = null - } - - private fun tryPersistState() { - this.persistCallback?.let { - val json: String - try { - json = this.toJSONString() - } catch (e: FxaException) { - Log.e("FirefoxAccount", "Error serializing the FirefoxAccount state.") - return - } - it.persist(json) - } - } - - /** - * Constructs a URL used to begin the OAuth flow for the requested scopes and keys. - * - * This performs network requests, and should not be used on the main thread. - * - * @param scopes List of OAuth scopes for which the client wants access - * @param entrypoint to be used for metrics - * @param metricsParams optional parameters used for metrics - * @return String that resolves to the flow URL when complete - */ - fun beginOAuthFlow( - scopes: Array, - entrypoint: String, - metricsParams: MetricsParams = MetricsParams(mapOf()), - ): String { - return this.inner.beginOauthFlow(scopes.toList(), entrypoint, metricsParams) - } - - /** - * Begins the pairing flow. - * - * This performs network requests, and should not be used on the main thread. - * - * @param pairingUrl the url to initilaize the paring flow with - * @param scopes List of OAuth scopes for which the client wants access - * @param entrypoint to be used for metrics - * @param metricsParams optional parameters used for metrics - * @return String that resoles to the flow URL when complete - */ - fun beginPairingFlow( - pairingUrl: String, - scopes: Array, - entrypoint: String, - metricsParams: MetricsParams = MetricsParams(mapOf()), - ): String { - return this.inner.beginPairingFlow(pairingUrl, scopes.toList(), entrypoint, metricsParams) - } - - /** - * Authenticates the current account using the code and state parameters fetched from the - * redirect URL reached after completing the sign in flow triggered by [beginOAuthFlow]. - * - * Modifies the FirefoxAccount state. - * - * This performs network requests, and should not be used on the main thread. - */ - fun completeOAuthFlow(code: String, state: String) { - this.inner.completeOauthFlow(code, state) - this.tryPersistState() - } - - /** - * Fetches the profile object for the current client either from the existing cached account, - * or from the server (requires the client to have access to the profile scope). - * - * This performs network requests, and should not be used on the main thread. - * - * @param ignoreCache Fetch the profile information directly from the server - * @return [Profile] representing the user's basic profile info - * @throws FxaException.Unauthorized We couldn't find any suitable access token to make that call. - * The caller should then start the OAuth Flow again with the "profile" scope. - */ - fun getProfile(ignoreCache: Boolean): Profile { - try { - return this.inner.getProfile(ignoreCache) - } finally { - this.tryPersistState() - } - } - - /** - * Convenience method to fetch the profile from a cached account by default, but fall back - * to retrieval from the server. - * - * This performs network requests, and should not be used on the main thread. - * - * @return [Profile] representing the user's basic profile info - * @throws FxaException.Unauthorized We couldn't find any suitable access token to make that call. - * The caller should then start the OAuth Flow again with the "profile" scope. - */ - fun getProfile(): Profile { - return getProfile(false) - } - - /** - * Fetches the token server endpoint, for authenticating to Firefox Sync via OAuth. - * - * This performs network requests, and should not be used on the main thread. - */ - fun getTokenServerEndpointURL(): String { - return this.inner.getTokenServerEndpointUrl() - } - - /** - * Get the pairing URL to navigate to on the Auth side (typically a computer). - * - * This does not make network requests, and can be used on the main thread. - */ - fun getPairingAuthorityURL(): String { - return this.inner.getPairingAuthorityUrl() - } - - /** - * Fetches the connection success url. - * - * This does not make network requests, and can be used on the main thread. - */ - fun getConnectionSuccessURL(): String { - return this.inner.getConnectionSuccessUrl() - } - - /** - * Fetches the user's manage-account url. - * - * This performs network requests, and should not be used on the main thread. - * - * @throws FxaException.Unauthorized We couldn't find any suitable access token to identify the user. - * The caller should then start the OAuth Flow again with the "profile" scope. - */ - fun getManageAccountURL(entrypoint: String): String { - return this.inner.getManageAccountUrl(entrypoint) - } - - /** - * Fetches the user's manage-devices url. - * - * This performs network requests, and should not be used on the main thread. - * - * @throws FxaException.Unauthorized We couldn't find any suitable access token to identify the user. - * The caller should then start the OAuth Flow again with the "profile" scope. - */ - fun getManageDevicesURL(entrypoint: String): String { - return this.inner.getManageDevicesUrl(entrypoint) - } - - /** - * Tries to fetch an access token for the given scope. - * - * This performs network requests, and should not be used on the main thread. - * It may modify the persisted account state. - * - * @param scope Single OAuth scope (no spaces) for which the client wants access - * @param ttl time in seconds for which the token will be valid - * @return [AccessTokenInfo] that stores the token, along with its scopes and keys when complete - * @throws FxaException.Unauthorized We couldn't provide an access token - * for this scope. The caller should then start the OAuth Flow again with - * the desired scope. - */ - fun getAccessToken(scope: String, ttl: Long? = null): AccessTokenInfo { - try { - return this.inner.getAccessToken(scope, ttl) - } finally { - this.tryPersistState() - } - } - - fun checkAuthorizationStatus(): AuthorizationInfo { - return this.inner.checkAuthorizationStatus() - } - - /** - * Tries to return a session token - * - * @throws FxaException Will send you an exception if there is no session token set - */ - fun getSessionToken(): String { - return this.inner.getSessionToken() - } - - /** - * Get the current device id - * - * @throws FxaException Will send you an exception if there is no device id set - */ - fun getCurrentDeviceId(): String { - return this.inner.getCurrentDeviceId() - } - - /** - * Provisions an OAuth code using the session token from state - * - * @param authParams Parameters needed for the authorization request - * This performs network requests, and should not be used on the main thread. - */ - fun authorizeOAuthCode( - authParams: AuthorizationParameters, - ): String { - return this.inner.authorizeCodeUsingSessionToken(authParams) - } - - /** - * This method should be called when a request made with - * an OAuth token failed with an authentication error. - * It clears the internal cache of OAuth access tokens, - * so the caller can try to call `getAccessToken` or `getProfile` - * again. - */ - fun clearAccessTokenCache() { - this.inner.clearAccessTokenCache() - } - - /** - * Saves the current account's authentication state as a JSON string, for persistence in - * the Android KeyStore/shared preferences. The authentication state can be restored using - * [FirefoxAccount.fromJSONString]. - * - * This does not make network requests, and can be used on the main thread. - * - * @return String containing the authentication details in JSON format - */ - fun toJSONString(): String { - return this.inner.toJson() - } - - /** - * Update the push subscription details for the current device. - * This needs to be called every time a push subscription is modified or expires. - * - * This performs network requests, and should not be used on the main thread. - * - * @param endpoint Push callback URL - * @param publicKey Public key used to encrypt push payloads - * @param authKey Auth key used to encrypt push payloads - */ - fun setDevicePushSubscription(endpoint: String, publicKey: String, authKey: String) { - try { - return this.inner.setPushSubscription(DevicePushSubscription(endpoint, publicKey, authKey)) - } finally { - this.tryPersistState() - } - } - - /** - * Update the display name (as shown in the FxA device manager, or the Send Tab target list) - * for the current device. - * - * This performs network requests, and should not be used on the main thread. - * - * @param displayName The current device display name - */ - fun setDeviceDisplayName(displayName: String) { - try { - return this.inner.setDeviceName(displayName) - } finally { - this.tryPersistState() - } - } - - /** - * Retrieves the list of the connected devices in the current account, including the current one. - * - * This performs network requests, and should not be used on the main thread. - */ - fun getDevices(ignoreCache: Boolean = false): Array { - return this.inner.getDevices(ignoreCache).toTypedArray() - } - - /** - * Disconnect from the account and optionaly destroy our device record. - * `beginOAuthFlow` will need to be called to reconnect. - * - * This performs network requests, and should not be used on the main thread. - */ - fun disconnect() { - this.inner.disconnect() - this.tryPersistState() - } - - /** - * Retrieves any pending commands for the current device. - * This should be called semi-regularly as the main method of commands delivery (push) - * can sometimes be unreliable on mobile devices. - * If a persist callback is set and the host application failed to process the - * returned account events, they will never be seen again. - * - * This performs network requests, and should not be used on the main thread. - * - * @return A collection of [IncomingDeviceCommand] that should be handled by the caller. - */ - fun pollDeviceCommands(): Array { - try { - return this.inner.pollDeviceCommands().toTypedArray() - } finally { - this.tryPersistState() - } - } - - /** - * Retrieves the account event associated with an - * incoming push message payload coming Firefox Accounts. - * Assumes the message that has been decrypted and authenticated by the Push crate. - * - * This performs network requests, and should not be used on the main thread. - * - * @return A collection of [AccountEvent] that should be handled by the caller. - */ - fun handlePushMessage(payload: String): AccountEvent { - try { - return this.inner.handlePushMessage(payload) - } finally { - this.tryPersistState() - } - } - - /** - * Ensure the current device is registered with the specified name and device type, with - * the required capabilities (at this time only Send Tab). - * This method should be called once per "device lifetime". - * - * This performs network requests, and should not be used on the main thread. - */ - fun initializeDevice(name: String, deviceType: DeviceType, supportedCapabilities: Set) { - this.inner.initializeDevice(name, deviceType, supportedCapabilities.toList()) - this.tryPersistState() - } - - /** - * Ensure that the supported capabilities described earlier in `initializeDevice` are A-OK. - * A set of capabilities to be supported by the Device must also be passed (at this time only - * Send Tab). - * - * As for now there's only the Send Tab capability, so we ensure the command is registered with the server. - * This method should be called at least every time the sync keys change (because Send Tab relies on them). - * - * This performs network requests, and should not be used on the main thread. - */ - fun ensureCapabilities(supportedCapabilities: Set) { - this.inner.ensureCapabilities(supportedCapabilities.toList()) - this.tryPersistState() - } - - /** - * Send a single tab to another device identified by its device ID. - * - * This performs network requests, and should not be used on the main thread. - * - * @param targetDeviceId The target Device ID - * @param title The document title of the tab being sent - * @param url The url of the tab being sent - */ - fun sendSingleTab(targetDeviceId: String, title: String, url: String) { - this.inner.sendSingleTab(targetDeviceId, title, url) - } - - /** - * Gather any telemetry which has been collected internally and return - * the result as a JSON string. - * - * This does not make network requests, and can be used on the main thread. - */ - fun gatherTelemetry(): String { - return this.inner.gatherTelemetry() - } - - @Synchronized - override fun close() { - this.inner.destroy() - } -} diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/StateTypes.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/StateTypes.kt new file mode 100644 index 0000000000..1ac9009274 --- /dev/null +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/StateTypes.kt @@ -0,0 +1,252 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.appservices.fxaclient + +import kotlinx.coroutines.CompletableDeferred +import mozilla.appservices.sync15.DeviceType + +/** + * FxA Action. + * + * The application sends these to [FxaClient.queueAction]. + * + * Note on [CompletableDeferred] result params: + * + * These exist for compatibility with the existing firefox-android codebase, but new code should use + * the `FxaEventHandler` interface to listen for events. + * + * The reason is Deferred result processing is not guaranteed to happen in-order. For example: + * - Thread A sends a SetDeviceName action that fails. + * - Slightly later, thread B sends a SetDeviceName action that succeeds. + * - It's possible that the code to handle thread B's result will execute before the code for thread A. + * - This means that the user may see a connection warning when they shouldn't. + * + * This isn't ideal, but it seems like issues will be rare in practice. The same issues could have + * happened with the previous system, where the android-components code called sync methods, like + * setDeviceName from async wrapper functions. + */ +sealed class FxaAction { + /** + * Begin an OAuth flow + * + * @param scopes OAuth scopes to request + * @param entrypoint OAuth entrypoint + * @param result If present, will be completed with the OAuth URL to navigate users too + */ + data class BeginOAuthFlow( + val scopes: Array, + val entrypoint: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Begin an OAuth flow using a paring code URL + * + * @param pairingUrl the url to initialize the paring flow with + * @param scopes OAuth scopes to request + * @param entrypoint OAuth entrypoint + * @param result If present, will be completed with the OAuth URL to navigate users too + */ + data class BeginPairingFlow( + val pairingUrl: String, + val scopes: Array, + val entrypoint: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Complete an OAuth flow, authenticating the current account. + * + * @param code query parameter from the redirect URL after completing the oauth flow + * @param state query parameter from the redirect URL after completing the oauth flow + */ + data class CompleteOAuthFlow( + val code: String, + val state: String, + ) : FxaAction() + + /** + * Cancel an OAuth flow + */ + object CancelOAuthFlow : FxaAction() + + /** + * Initialize device info on the server + * + * @param name Display name + * @param deviceType Device type + * @param supportedCapabilities Capabilities that the device supports + * @param result If present, will be completed with true for success and false for failure + */ + data class InitializeDevice( + val name: String, + val deviceType: DeviceType, + val supportedCapabilities: List, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Ensure capabilities are registered with the server + * + * @param supportedCapabilities Capabilities that the device supports + * @param result If present, will be completed with true for success and false for failure + */ + data class EnsureCapabilities( + val supportedCapabilities: List, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Update the display name (as shown in the FxA device manager, or the Send Tab target list) + * for the current device. + * + * @param displayName The current device display name + * @param result If present, will be completed with true for success and false for failure + */ + data class SetDeviceName( + val displayName: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Update the push subscription details for the current device. + * This needs to be called every time a push subscription is modified or expires. + * + * @param endpoint Push callback URL + * @param publicKey Public key used to encrypt push payloads + * @param authKey Auth key used to encrypt push payloads + * @param result If present, will be completed with true for success and false for failure + */ + data class SetDevicePushSubscription( + val endpoint: String, + val publicKey: String, + val authKey: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Send a single tab to another device identified by its device ID. + * + * @param targetDeviceId The target Device ID + * @param title The document title of the tab being sent + * @param url The url of the tab being sent + * @param result If present, will be completed with true for success and false for failure + */ + data class SendSingleTab( + val targetDeviceId: String, + val title: String, + val url: String, + val result: CompletableDeferred? = null, + ) : FxaAction() + + /** + * Disconnect from the FxA server and destroy our device record. + * + * @param fromAuthIssues: are we disconnecting because of auth issues? Setting this flag + * changes `FxaEvent.AuthStateChanged` so that the `fromAuthIssues` flag is will set and the + * transition is `AUTH_CHECK_FAILED` + */ + data class Disconnect(val fromAuthIssues: Boolean = false) : FxaAction() + + /** + * Check the FxA authorization status. + */ + object CheckAuthorization : FxaAction() +} + +/** + * Fxa event + * + * These are the results of FxaActions and are sent by the Fxa client to the application via the + * FxaEventHandler interface. + */ +sealed class FxaEvent { + /** + * Called when the auth state changes. Applications should use this to update their UI. + */ + data class AuthStateChanged( + val newState: FxaAuthState, + val transition: FxaAuthStateTransition, + ) : FxaEvent() + + /** + * An action that updates the local device state completed successfully + */ + data class DeviceOperationComplete( + val operation: FxaDeviceOperation, + val localDevice: LocalDevice, + ) : FxaEvent() + + /** + * An action that updates the local device state failed + */ + data class DeviceOperationFailed( + val operation: FxaDeviceOperation, + ) : FxaEvent() + + /** + * Called to begin an oauth flow. The application must navigate the user to the URL to + * start the process. + */ + data class BeginOAuthFlow(val url: String) : FxaEvent() +} + +/** + * Kotlin authorization state class + * + * This is [FxaRustAuthState] with added data that Rust doesn't track yet. + */ +sealed class FxaAuthState { + /** + * Client has disconnected + * + * @property fromAuthIssues client was disconnected because of invalid auth tokens, for + * example because of a password reset on another device + * @property connecting is there an OAuth flow in progress? + */ + data class Disconnected( + val fromAuthIssues: Boolean = false, + val connecting: Boolean = false, + ) : FxaAuthState() + + /** + * Client is currently connected + * + * @property authCheckInProgress Client is checking the auth tokens and may disconnect soon + */ + data class Connected( + val authCheckInProgress: Boolean = false, + ) : FxaAuthState() + + companion object { + fun fromRust(authState: FxaRustAuthState): FxaAuthState { + return when (authState) { + is FxaRustAuthState.Connected -> FxaAuthState.Connected() + is FxaRustAuthState.Disconnected -> { + FxaAuthState.Disconnected(authState.fromAuthIssues) + } + } + } + } +} + +enum class FxaAuthStateTransition { + OAUTH_STARTED, + OAUTH_COMPLETE, + OAUTH_CANCELLED, + OAUTH_FAILED_TO_BEGIN, + OAUTH_FAILED_TO_COMPLETE, + DISCONNECTED, + AUTH_CHECK_STARTED, + AUTH_CHECK_FAILED, + AUTH_CHECK_SUCCESS, +} + +enum class FxaDeviceOperation { + INITIALIZE_DEVICE, + ENSURE_CAPABILITIES, + SET_DEVICE_NAME, + SET_DEVICE_PUSH_SUBSCRIPTION, +} diff --git a/components/fxa-client/android/src/main/resources/io/mockk/settings.properties b/components/fxa-client/android/src/main/resources/io/mockk/settings.properties new file mode 100644 index 0000000000..b8ba928e75 --- /dev/null +++ b/components/fxa-client/android/src/main/resources/io/mockk/settings.properties @@ -0,0 +1 @@ +stackTracesOnVerify=false diff --git a/components/fxa-client/android/src/test/java/android/util/Log.kt b/components/fxa-client/android/src/test/java/android/util/Log.kt new file mode 100644 index 0000000000..65ff25ea98 --- /dev/null +++ b/components/fxa-client/android/src/test/java/android/util/Log.kt @@ -0,0 +1,25 @@ +@file:JvmName("Log") + +// This file exists to make the unit tests happy + +package android.util + +fun d(tag: String, msg: String): Int { + println("DEBUG: $tag: $msg") + return 0 +} + +fun e(tag: String, msg: String): Int { + println("ERROR: $tag: $msg") + return 0 +} + +fun e(tag: String, msg: String, throwable: Throwable): Int { + println("ERROR: $tag: $msg $throwable") + return 0 +} + +fun w(tag: String, msg: String): Int { + println("WARN: $tag: $msg") + return 0 +} diff --git a/components/fxa-client/android/src/test/java/mozilla/appservices/fxaclient/FxaActionProcessorTest.kt b/components/fxa-client/android/src/test/java/mozilla/appservices/fxaclient/FxaActionProcessorTest.kt new file mode 100644 index 0000000000..ac5b502583 --- /dev/null +++ b/components/fxa-client/android/src/test/java/mozilla/appservices/fxaclient/FxaActionProcessorTest.kt @@ -0,0 +1,1177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.appservices.fxaclient + +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import mozilla.appservices.sync15.DeviceType +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +val testLocalDevice = LocalDevice( + id = "device-id", + displayName = "My Phone", + deviceType = DeviceType.MOBILE, + capabilities = listOf(DeviceCapability.SEND_TAB), + pushSubscription = DevicePushSubscription("endpoint", "public-key", "auth-key"), + pushEndpointExpired = false, +) + +val testException = FxaException.Other("Test error") +val networkException = FxaException.Network("Test network error") +val authException = FxaException.Authentication("Test auth error") +val oauthStateException = FxaException.Other("Test oauth state error") + +val beginOAuthFlowAction = FxaAction.BeginOAuthFlow(arrayOf("scope1"), "test-entrypoint") +val beginPairingFlowAction = FxaAction.BeginPairingFlow("http:://example.com/pairing", arrayOf("scope1"), "test-entrypoint") +val completeAuthFlowAction = FxaAction.CompleteOAuthFlow(code = "test-code", state = "test-state") +val completeAuthFlowInvalidAction = FxaAction.CompleteOAuthFlow(code = "test-code", state = "bad-state") +val initializeDeviceAction = FxaAction.InitializeDevice("My Phone", DeviceType.MOBILE, listOf(DeviceCapability.SEND_TAB)) +val ensureCapabilitiesAction = FxaAction.EnsureCapabilities(listOf(DeviceCapability.SEND_TAB)) +val setDeviceNameAction = FxaAction.SetDeviceName("My Phone") +val setDevicePushSubscriptionAction = FxaAction.SetDevicePushSubscription("endpoint", "public-key", "auth-key") +val sendSingleTabAction = FxaAction.SendSingleTab("my-other-device", "My page", "http://example.com/sent-tab") + +internal data class Mocks( + val firefoxAccount: FirefoxAccount, + val eventHandler: FxaEventHandler, + val persistState: () -> Unit, + val actionProcessor: FxaActionProcessor, +) { + // Verify the effects of an action + // + // verifyBlock should verify all mock interactions, than return the expect new state of the + // actionProcessor. Pass in null (AKA NoEffects) to verify that there were no mock interactions + // and the state didn't change + suspend fun verifyAction(action: FxaAction, verifyBlock: ActionVerifier) { + if (verifyBlock == NeverHappens) { + return + } + val initialState = actionProcessor.currentState + actionProcessor.processAction(action) + when (verifyBlock) { + NoEffects -> assertEquals(initialState, actionProcessor.currentState) + else -> { + val expectedState = verifyBlock(firefoxAccount, eventHandler, persistState) + assertEquals(expectedState, actionProcessor.currentState) + } + } + confirmVerified(firefoxAccount, eventHandler, persistState) + clearMocks(firefoxAccount, eventHandler, persistState, answers = false, recordedCalls = true, verificationMarks = true) + } + + companion object { + fun create(initialState: FxaAuthState, throwing: Boolean = false): Mocks { + val firefoxAccount = mockk(relaxed = true).apply { + if (!throwing) { + every { getAuthState() } returns FxaRustAuthState.Disconnected(fromAuthIssues = false) + every { beginOauthFlow(any(), any(), any()) } returns "http://example.com/oauth-flow-start" + every { beginPairingFlow(any(), any(), any(), any()) } returns "http://example.com/pairing-flow-start" + every { initializeDevice(any(), any(), any()) } returns testLocalDevice + every { ensureCapabilities(any()) } returns testLocalDevice + every { setDeviceName(any()) } returns testLocalDevice + every { setPushSubscription(any()) } returns testLocalDevice + every { checkAuthorizationStatus() } returns AuthorizationInfo(active = true) + } else { + every { beginOauthFlow(any(), any(), any()) } throws testException + every { beginPairingFlow(any(), any(), any(), any()) } throws testException + every { completeOauthFlow(any(), any()) } throws testException + every { disconnect() } throws testException + every { initializeDevice(any(), any(), any()) } throws testException + every { ensureCapabilities(any()) } throws testException + every { setDeviceName(any()) } throws testException + every { setPushSubscription(any()) } throws testException + every { checkAuthorizationStatus() } throws testException + every { sendSingleTab(any(), any(), any()) } throws testException + } + } + val eventHandler = mockk(relaxed = true) + val persistState = mockk<() -> Unit>(relaxed = true, name = "tryPersistState") + val actionProcessor = FxaActionProcessor(firefoxAccount, eventHandler, persistState, initialState) + return Mocks(firefoxAccount, eventHandler, persistState, actionProcessor) + } + + // Check the effects processAction() calls for each possible state + // + // This checks each combination of: + // - connected / disconnected + // - Rust returns Ok() / Err() + // + // Note: this doesn't differentiate based on the FxaAuthState fields values, like + // `fromAuthIssues`. FxaActionProcessor sets these fields, but otherwise ignores them. + @Suppress("LongParameterList") + internal suspend fun verifyAction( + action: FxaAction, + whenDisconnected: ActionVerifier, + whenDisconnectedIfThrows: ActionVerifier, + whenConnected: ActionVerifier, + whenConnectedIfThrows: ActionVerifier, + initialStateDisconnected: FxaAuthState = FxaAuthState.Disconnected(), + initialStateConnected: FxaAuthState = FxaAuthState.Connected(), + ) { + create(initialStateDisconnected, false).verifyAction(action, whenDisconnected) + create(initialStateDisconnected, true).verifyAction(action, whenDisconnectedIfThrows) + create(initialStateConnected, false).verifyAction(action, whenConnected) + create(initialStateConnected, true).verifyAction(action, whenConnectedIfThrows) + } + } +} + +typealias ActionVerifier = suspend (FirefoxAccount, FxaEventHandler, () -> Unit) -> FxaAuthState + +// Used when an action should have no effects at all +val NoEffects: ActionVerifier = { _, _, _ -> FxaAuthState.Disconnected() } + +// Used when an action should never happen, for example FxaAction.Disconnect should never throw, so +// we use this rather than making a verifier to test an impossible pathe +val NeverHappens: ActionVerifier = { _, _, _ -> FxaAuthState.Disconnected() } + +/** + * This is the main unit test for the FxaActionProcessor. The goal here is to take every action and + * verify what happens it's processed when: + * * The client is connected / disconnected + * * The Rust client throws or doesn't throw an error + * + * "verify" means: + * * the correct calls were made to the Rust client + * * the correct events were emitted + * * the state was persisted (or not) + * * all of the above happened in the expected order + * * the new state of the FxaActionProcessor is correct + * + * This leads to 4 tests per action, which is a lot, but [Mocks.verifyAction] keeps things + * relatively simple. We could test more combinations, like what happens when different errors are + * thrown or test different field values for [FxaAuthState.Connected] and + * [FxaAuthState.Disconnected]. However, these distinctions usually don't matter, and we don't + * genally test all the combinations (although some individual tests do tests some of the + * combinations). + * + */ +class FxaActionProcessorTest { + @Test + fun `FxaActionProcessor handles BeginOAuthFlow`() = runTest { + Mocks.verifyAction( + beginOAuthFlowAction, + whenDisconnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.beginOauthFlow(listOf("scope1"), "test-entrypoint", any()) + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = true), + transition = FxaAuthStateTransition.OAUTH_STARTED, + ), + ) + eventHandler.onFxaEvent( + FxaEvent.BeginOAuthFlow( + "http://example.com/oauth-flow-start", + ), + ) + } + FxaAuthState.Disconnected(connecting = true) + }, + whenDisconnectedIfThrows = { inner, eventHandler, persistState -> + coVerifySequence { + inner.beginOauthFlow(listOf("scope1"), "test-entrypoint", any()) + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(), + transition = FxaAuthStateTransition.OAUTH_FAILED_TO_BEGIN, + ), + ) + } + FxaAuthState.Disconnected() + }, + whenConnected = NoEffects, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles BeginPairingFlow`() = runTest { + Mocks.verifyAction( + beginPairingFlowAction, + whenDisconnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.beginPairingFlow("http:://example.com/pairing", listOf("scope1"), "test-entrypoint", any()) + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = true), + transition = FxaAuthStateTransition.OAUTH_STARTED, + ), + ) + eventHandler.onFxaEvent( + FxaEvent.BeginOAuthFlow( + "http://example.com/pairing-flow-start", + ), + ) + } + FxaAuthState.Disconnected(connecting = true) + }, + whenDisconnectedIfThrows = { inner, eventHandler, persistState -> + coVerifySequence { + inner.beginPairingFlow("http:://example.com/pairing", listOf("scope1"), "test-entrypoint", any()) + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = false), + transition = FxaAuthStateTransition.OAUTH_FAILED_TO_BEGIN, + ), + ) + } + FxaAuthState.Disconnected(connecting = false) + }, + whenConnected = NoEffects, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles CompleteOauthFlow`() = runTest { + Mocks.verifyAction( + completeAuthFlowAction, + initialStateDisconnected = FxaAuthState.Disconnected(connecting = true), + whenDisconnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.completeOauthFlow("test-code", "test-state") + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.OAUTH_COMPLETE, + ), + ) + } + FxaAuthState.Connected() + }, + whenDisconnectedIfThrows = { inner, eventHandler, persistState -> + coVerifySequence { + inner.completeOauthFlow("test-code", "test-state") + persistState() + // `connecting` should still be true in case another oauth flow is also in + // progress. In order to unset it, the application needs to send + // CancelOAuthFlow + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = true), + transition = FxaAuthStateTransition.OAUTH_FAILED_TO_COMPLETE, + ), + ) + } + FxaAuthState.Disconnected(connecting = true) + }, + whenConnected = NoEffects, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles a failed oauth complete, then a successful one`() = runTest { + val mocks = Mocks.create(FxaAuthState.Disconnected(connecting = true)) + every { mocks.firefoxAccount.completeOauthFlow(any(), any()) } throws oauthStateException + + mocks.verifyAction(completeAuthFlowInvalidAction) { inner, eventHandler, persistState -> + coVerifySequence { + inner.completeOauthFlow("test-code", "bad-state") + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = true), + transition = FxaAuthStateTransition.OAUTH_FAILED_TO_COMPLETE, + ), + ) + } + FxaAuthState.Disconnected(connecting = true) + } + + every { mocks.firefoxAccount.completeOauthFlow(any(), any()) } just runs + mocks.verifyAction(completeAuthFlowAction) { inner, eventHandler, persistState -> + coVerifySequence { + inner.completeOauthFlow("test-code", "test-state") + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.OAUTH_COMPLETE, + ), + ) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor handles CancelOauthFlow`() = runTest { + Mocks.verifyAction( + FxaAction.CancelOAuthFlow, + initialStateDisconnected = FxaAuthState.Disconnected(connecting = true), + whenDisconnected = { _, eventHandler, _ -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(connecting = false), + transition = FxaAuthStateTransition.OAUTH_CANCELLED, + ), + ) + } + FxaAuthState.Disconnected(connecting = false) + }, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = NoEffects, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles Disconnect`() = runTest { + Mocks.verifyAction( + FxaAction.Disconnect(), + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.disconnect() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(), + transition = FxaAuthStateTransition.DISCONNECTED, + ), + ) + } + FxaAuthState.Disconnected() + }, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles Disconnect(fromAuthIssues=true)`() = runTest { + Mocks.verifyAction( + FxaAction.Disconnect(fromAuthIssues = true), + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, persistState -> + coVerifySequence { + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + }, + whenConnectedIfThrows = NeverHappens, + ) + } + + @Test + fun `FxaActionProcessor handles CheckAuthorization`() = runTest { + Mocks.verifyAction( + FxaAction.CheckAuthorization, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, persistState -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + } + FxaAuthState.Connected() + }, + // If the auth check throws FxaException.Other, then then we currently consider that a + // success, rather than kicking the user out of the account. Other failure models are + // tested in the test cases below + whenConnectedIfThrows = { inner, eventHandler, persistState -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor disconnects if checkAuthorizationStatus returns active=false`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { mocks.firefoxAccount.checkAuthorizationStatus() } returns AuthorizationInfo(active = false) + mocks.verifyAction(FxaAction.CheckAuthorization) { inner, eventHandler, persistState -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor disconnects if checkAuthorizationStatus throwns an auth exception`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { mocks.firefoxAccount.checkAuthorizationStatus() } throws authException + mocks.verifyAction(FxaAction.CheckAuthorization) { inner, eventHandler, persistState -> + coVerifySequence { + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor handles InitializeDevice`() = runTest { + Mocks.verifyAction( + initializeDeviceAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, _ -> + coVerifySequence { + inner.initializeDevice("My Phone", DeviceType.MOBILE, listOf(DeviceCapability.SEND_TAB)) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.INITIALIZE_DEVICE, testLocalDevice)) + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, eventHandler, _ -> + coVerifySequence { + inner.initializeDevice("My Phone", DeviceType.MOBILE, listOf(DeviceCapability.SEND_TAB)) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.INITIALIZE_DEVICE)) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor handles EnsureCapabilities`() = runTest { + Mocks.verifyAction( + ensureCapabilitiesAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, _ -> + coVerifySequence { + inner.ensureCapabilities(listOf(DeviceCapability.SEND_TAB)) + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.ENSURE_CAPABILITIES, testLocalDevice), + ) + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, eventHandler, _ -> + coVerifySequence { + inner.ensureCapabilities(listOf(DeviceCapability.SEND_TAB)) + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationFailed(FxaDeviceOperation.ENSURE_CAPABILITIES), + ) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor handles SetDeviceName`() = runTest { + Mocks.verifyAction( + setDeviceNameAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, _ -> + coVerifySequence { + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, eventHandler, _ -> + coVerifySequence { + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME), + ) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor handles SetDevicePushSubscription`() = runTest { + Mocks.verifyAction( + setDevicePushSubscriptionAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, eventHandler, _ -> + coVerifySequence { + inner.setPushSubscription( + DevicePushSubscription("endpoint", "public-key", "auth-key"), + ) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_PUSH_SUBSCRIPTION, testLocalDevice)) + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, eventHandler, _ -> + coVerifySequence { + inner.setPushSubscription( + DevicePushSubscription("endpoint", "public-key", "auth-key"), + ) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_PUSH_SUBSCRIPTION)) + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor handles SendSingleTab`() = runTest { + Mocks.verifyAction( + sendSingleTabAction, + whenDisconnected = NoEffects, + whenDisconnectedIfThrows = NeverHappens, + whenConnected = { inner, _, _ -> + coVerifySequence { + inner.sendSingleTab("my-other-device", "My page", "http://example.com/sent-tab") + } + FxaAuthState.Connected() + }, + whenConnectedIfThrows = { inner, _, _ -> + coVerifySequence { + inner.sendSingleTab("my-other-device", "My page", "http://example.com/sent-tab") + // Should we notify clients if this fails? There doesn't seem like there's much + // they can do about it. + } + FxaAuthState.Connected() + }, + ) + } + + @Test + fun `FxaActionProcessor sends OAuth results to the deferred`() = runTest { + val mocks = Mocks.create(FxaAuthState.Disconnected()) + + CompletableDeferred().let { + mocks.actionProcessor.processAction(beginOAuthFlowAction.copy(result = it)) + assertEquals(it.await(), "http://example.com/oauth-flow-start") + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(beginPairingFlowAction.copy(result = it)) + assertEquals(it.await(), "http://example.com/pairing-flow-start") + } + + every { mocks.firefoxAccount.beginOauthFlow(any(), any(), any()) } throws testException + every { mocks.firefoxAccount.beginPairingFlow(any(), any(), any(), any()) } throws testException + + CompletableDeferred().let { + mocks.actionProcessor.processAction(beginOAuthFlowAction.copy(result = it)) + assertEquals(it.await(), null) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(beginPairingFlowAction.copy(result = it)) + assertEquals(it.await(), null) + } + } + + @Test + fun `FxaActionProcessor sends Device operation results to the deferred`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + + CompletableDeferred().let { + mocks.actionProcessor.processAction(initializeDeviceAction.copy(result = it)) + assertEquals(it.await(), true) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(ensureCapabilitiesAction.copy(result = it)) + assertEquals(it.await(), true) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(setDeviceNameAction.copy(result = it)) + assertEquals(it.await(), true) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(setDevicePushSubscriptionAction.copy(result = it)) + assertEquals(it.await(), true) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(sendSingleTabAction.copy(result = it)) + assertEquals(it.await(), true) + } + + mocks.firefoxAccount.apply { + every { initializeDevice(any(), any(), any()) } throws testException + every { ensureCapabilities(any()) } throws testException + every { setDeviceName(any()) } throws testException + every { setPushSubscription(any()) } throws testException + every { sendSingleTab(any(), any(), any()) } throws testException + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(initializeDeviceAction.copy(result = it)) + assertEquals(it.await(), false) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(ensureCapabilitiesAction.copy(result = it)) + assertEquals(it.await(), false) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(setDeviceNameAction.copy(result = it)) + assertEquals(it.await(), false) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(setDevicePushSubscriptionAction.copy(result = it)) + assertEquals(it.await(), false) + } + + CompletableDeferred().let { + mocks.actionProcessor.processAction(sendSingleTabAction.copy(result = it)) + assertEquals(it.await(), false) + } + } + + @Test + fun `FxaActionProcessor catches errors when sending events`() = runTest { + val eventHandler = mockk().apply { + coEvery { onFxaEvent(any()) } answers { + throw testException + } + } + FxaActionProcessor(mockk(), eventHandler, mockk(), initialState = FxaAuthState.Connected()).sendEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + // Check that the handler was called and threw an exception, but sendEvent caught it + coVerify { + eventHandler.onFxaEvent(any()) + } + } + + @Test + fun `FxaActionProcessor has a manager job that restarts when processChannel throws`() = runTest { + var firstTime = true + val actionProcessor = mockk(relaxed = true) + coEvery { actionProcessor.processChannel() } answers { + if (firstTime) { + // First time around we throw + firstTime = false + @Suppress("TooGenericExceptionThrown") + throw Exception("Test errror") + } else { + // Second time around we quit gracefully + } + } + // Run the manager job, it should restart `processChannel` when it throws the first time, + // and return when it successfully returns + val testDispatcher = StandardTestDispatcher() + runActionProcessorManager(actionProcessor, testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + coVerify(exactly = 2) { actionProcessor.processChannel() } + } +} + +class FxaRetryTest { + @Test + fun `FxaActionProcessor retries after network errors`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This throws FxaException.Network, we should retry + inner.setDeviceName("My Phone") + // This time it work + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor fails after 2 network errors`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { mocks.firefoxAccount.setDeviceName(any()) } throws networkException + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This throws FxaException.Network, we should retry + inner.setDeviceName("My Phone") + // This throws again, so the operation fails + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME)) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor fails after multiple network errors in a short time`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This fails with FxaException.Network, we should retry + inner.setDeviceName("My Phone") + // This time it works + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + } + + mocks.actionProcessor.retryLogic.fastForward(29.seconds) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This throws again and the timeout period is still active, we should fail + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME), + ) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor retrys network errors again after a timeout period`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // This fails with FxaException.Network, we should retry + inner.setDeviceName("My Phone") + // This time it works + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + } + + mocks.actionProcessor.retryLogic.fastForward(31.seconds) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws networkException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, _ -> + coVerifySequence { + // Timeout period over, we should retry this time + inner.setDeviceName("My Phone") + // This time it works + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice), + ) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor calls checkAuthorizationStatus after auth errors`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerifySequence { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor fails after 2 auth errors`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerifySequence { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. but this throws again, + inner.setDeviceName("My Phone") + // ..so save the state, transition to disconnected, and make the operation fail + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME)) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor fails if the auth check fails`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + every { mocks.firefoxAccount.checkAuthorizationStatus() } returns AuthorizationInfo(active = false) + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check fails + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + // .. so the operation fails + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME)) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor fails after multiple auth errors in a short time`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + + mocks.actionProcessor.retryLogic.fastForward(59.seconds) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws, + inner.setDeviceName("My Phone") + // ..so save the state, transition to disconnected, and make the operation fail + inner.disconnectFromAuthIssues() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Disconnected(fromAuthIssues = true), + transition = FxaAuthStateTransition.AUTH_CHECK_FAILED, + ), + ) + eventHandler.onFxaEvent(FxaEvent.DeviceOperationFailed(FxaDeviceOperation.SET_DEVICE_NAME)) + } + FxaAuthState.Disconnected(fromAuthIssues = true) + } + } + + @Test + fun `FxaActionProcessor checks authorization again after timeout period passes`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + + mocks.actionProcessor.retryLogic.fastForward(61.seconds) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // Timeout period over, we should recheck the auth status this time + inner.setDeviceName("My Phone") + // The auth check works + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor retries after an auth + network exception`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + every { + mocks.firefoxAccount.checkAuthorizationStatus() + } throws networkException andThen AuthorizationInfo(active = true) + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + // This throws a network error, we should retry + inner.checkAuthorizationStatus() + // This time it works + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + } + + @Test + fun `FxaActionProcessor retries after a network + auth exception`() = runTest { + val mocks = Mocks.create(FxaAuthState.Connected()) + every { + mocks.firefoxAccount.setDeviceName(any()) + } throws authException andThen testLocalDevice + + every { + mocks.firefoxAccount.checkAuthorizationStatus() + } throws networkException andThen AuthorizationInfo(active = true) + + mocks.verifyAction(setDeviceNameAction) { inner, eventHandler, persistState -> + coVerify { + // This throws FxaException.Network should try again + inner.setDeviceName("My Phone") + // This throws FxaException.Authentication, we should recheck the auth status + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(authCheckInProgress = true), + transition = FxaAuthStateTransition.AUTH_CHECK_STARTED, + ), + ) + // This works + inner.checkAuthorizationStatus() + persistState() + eventHandler.onFxaEvent( + FxaEvent.AuthStateChanged( + newState = FxaAuthState.Connected(), + transition = FxaAuthStateTransition.AUTH_CHECK_SUCCESS, + ), + ) + // .. continue on + inner.setDeviceName("My Phone") + eventHandler.onFxaEvent(FxaEvent.DeviceOperationComplete(FxaDeviceOperation.SET_DEVICE_NAME, testLocalDevice)) + } + FxaAuthState.Connected() + } + } +} diff --git a/components/fxa-client/src/auth.rs b/components/fxa-client/src/auth.rs index 75141ba3fc..6c68461cc6 100644 --- a/components/fxa-client/src/auth.rs +++ b/components/fxa-client/src/auth.rs @@ -173,6 +173,16 @@ impl FirefoxAccount { pub fn disconnect_from_auth_issues(&self) { self.internal.lock().disconnect(true) } + + /// Used by the application to test auth token issues + pub fn simulate_temporary_auth_token_issue(&self) { + self.internal.lock().simulate_temporary_auth_token_issue() + } + + /// Used by the application to test auth token issues + pub fn simulate_permanent_auth_token_issue(&self) { + self.internal.lock().simulate_permanent_auth_token_issue() + } } /// Information about the authorization state of the application. diff --git a/components/fxa-client/src/fxa_client.udl b/components/fxa-client/src/fxa_client.udl index 66c7c1d0eb..ddc49a8bf1 100644 --- a/components/fxa-client/src/fxa_client.udl +++ b/components/fxa-client/src/fxa_client.udl @@ -670,6 +670,12 @@ interface FirefoxAccount { // [Throws=FxaError] string gather_telemetry(); + + // Used by the application to test auth token issues + void simulate_temporary_auth_token_issue(); + + // Used by the application to test auth token issues + void simulate_permanent_auth_token_issue(); }; dictionary FxaConfig { diff --git a/components/fxa-client/src/internal/mod.rs b/components/fxa-client/src/internal/mod.rs index 62e5fbf3a2..3ba97b55d7 100644 --- a/components/fxa-client/src/internal/mod.rs +++ b/components/fxa-client/src/internal/mod.rs @@ -225,6 +225,14 @@ impl FirefoxAccount { self.clear_devices_and_attached_clients_cache(); self.telemetry = FxaTelemetry::new(); } + + pub fn simulate_temporary_auth_token_issue(&mut self) { + self.state.simulate_temporary_auth_token_issue() + } + + pub fn simulate_permanent_auth_token_issue(&mut self) { + self.state.simulate_permanent_auth_token_issue() + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/components/fxa-client/src/internal/state_manager.rs b/components/fxa-client/src/internal/state_manager.rs index ef8204812f..d128c16734 100644 --- a/components/fxa-client/src/internal/state_manager.rs +++ b/components/fxa-client/src/internal/state_manager.rs @@ -191,6 +191,19 @@ impl StateManager { self.persisted_state.access_token_cache.clear(); self.persisted_state.server_local_device_info = None; } + + pub fn simulate_temporary_auth_token_issue(&mut self) { + for (_, access_token) in self.persisted_state.access_token_cache.iter_mut() { + access_token.token = "invalid-data".to_owned() + } + } + + /// Used by the application to test auth token issues + pub fn simulate_permanent_auth_token_issue(&mut self) { + self.persisted_state.session_token = None; + self.persisted_state.refresh_token = None; + self.persisted_state.access_token_cache.clear(); + } } #[cfg(test)]