diff --git a/README.md b/README.md index 69f5b03d..68ed3036 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ The `fusionauth_config.json` file should be placed in the `res/raw` directory an By default, the SDK uses the `MemoryStorage` for storing tokens. This means that tokens will be lost when the app is closed. -To persist tokens, you can use the `SharedPreferencesStorage` or implement your own `TokenStorage`. +To persist tokens, you can use the `DataStoreStorage` or implement your own `TokenStorage`. diff --git a/app/src/androidTest/java/io/fusionauth/sdk/FullEnd2EndTest.kt b/app/src/androidTest/java/io/fusionauth/sdk/FullEnd2EndTest.kt index e1b2fc43..f5e86d9d 100644 --- a/app/src/androidTest/java/io/fusionauth/sdk/FullEnd2EndTest.kt +++ b/app/src/androidTest/java/io/fusionauth/sdk/FullEnd2EndTest.kt @@ -15,6 +15,7 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import io.fusionauth.mobilesdk.AuthorizationManager +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule @@ -85,13 +86,13 @@ internal class FullEnd2EndTest { logger.info("Token activity displayed") // Check refresh token functionality - val expirationTime = AuthorizationManager.getAccessTokenExpirationTime()!! + val expirationTime = runBlocking { AuthorizationManager.getAccessTokenExpirationTime()!! } logger.info("Check refresh token") onView(withId(R.id.refresh_token)) .check(matches(isDisplayed())) .perform(click()) - val newExpirationTime = AuthorizationManager.getAccessTokenExpirationTime()!! + val newExpirationTime = runBlocking { AuthorizationManager.getAccessTokenExpirationTime()!! } logger.info("Token was refreshed (${expirationTime} to ${newExpirationTime})") check(newExpirationTime > expirationTime) { "Token was not refreshed" } @@ -193,13 +194,13 @@ internal class FullEnd2EndTest { logger.info("Token activity displayed for user in reset configuration tenant") // Check refresh token functionality - val expirationTime = AuthorizationManager.getAccessTokenExpirationTime()!! + val expirationTime = runBlocking { AuthorizationManager.getAccessTokenExpirationTime()!! } logger.info("Check refresh token") onView(withId(R.id.refresh_token)) .check(matches(isDisplayed())) .perform(click()) - val newExpirationTime = AuthorizationManager.getAccessTokenExpirationTime()!! + val newExpirationTime = runBlocking { AuthorizationManager.getAccessTokenExpirationTime()!! } logger.info("Token was refreshed (${expirationTime} to ${newExpirationTime})") check(newExpirationTime > expirationTime) { "Token was not refreshed" } diff --git a/app/src/main/java/io/fusionauth/sdk/LoginActivity.kt b/app/src/main/java/io/fusionauth/sdk/LoginActivity.kt index db1d1329..4b0da94c 100644 --- a/app/src/main/java/io/fusionauth/sdk/LoginActivity.kt +++ b/app/src/main/java/io/fusionauth/sdk/LoginActivity.kt @@ -26,6 +26,7 @@ import io.fusionauth.mobilesdk.AuthorizationConfiguration import io.fusionauth.mobilesdk.AuthorizationManager import io.fusionauth.mobilesdk.oauth.OAuthAuthorizeOptions import io.fusionauth.mobilesdk.exceptions.AuthorizationException +import io.fusionauth.mobilesdk.storage.DataStoreStorage import io.fusionauth.mobilesdk.storage.SharedPreferencesStorage import kotlinx.coroutines.launch @@ -41,15 +42,17 @@ class LoginActivity : AppCompatActivity() { if (!AuthorizationManager.isInitialized()) { AuthorizationManager.initialize( AuthorizationConfiguration.fromResources(this, R.raw.fusionauth_config), - SharedPreferencesStorage(this) + DataStoreStorage(this) ) } - if (AuthorizationManager.isAuthenticated()) { - Log.i(TAG, "User is already authenticated, proceeding to token activity") - startActivity(Intent(this, TokenActivity::class.java)) - finish() - return + lifecycleScope.launch { + if (AuthorizationManager.isAuthenticated()) { + Log.i(TAG, "User is already authenticated, proceeding to token activity") + startActivity(Intent(this@LoginActivity, TokenActivity::class.java)) + finish() + return@launch + } } setContentView(R.layout.activity_login) diff --git a/app/src/main/java/io/fusionauth/sdk/TokenActivity.kt b/app/src/main/java/io/fusionauth/sdk/TokenActivity.kt index bcfb20f6..9056c981 100644 --- a/app/src/main/java/io/fusionauth/sdk/TokenActivity.kt +++ b/app/src/main/java/io/fusionauth/sdk/TokenActivity.kt @@ -30,6 +30,7 @@ import io.fusionauth.mobilesdk.AuthorizationManager import io.fusionauth.mobilesdk.FusionAuthState import io.fusionauth.mobilesdk.UserInfo import io.fusionauth.mobilesdk.exceptions.AuthorizationException +import io.fusionauth.mobilesdk.storage.DataStoreStorage import io.fusionauth.mobilesdk.storage.SharedPreferencesStorage import kotlinx.coroutines.launch import org.json.JSONException @@ -58,7 +59,7 @@ class TokenActivity : AppCompatActivity() { if (!AuthorizationManager.isInitialized()) { AuthorizationManager.initialize( AuthorizationConfiguration.fromResources(this, R.raw.fusionauth_config), - SharedPreferencesStorage(this) + DataStoreStorage(this) ) } @@ -75,21 +76,20 @@ class TokenActivity : AppCompatActivity() { } Logger.getLogger(TAG).info("Checking for authorization response") - if (AuthorizationManager.isAuthenticated()) { - fetchUserInfoAndDisplayAuthorized(/*authState.getAccessToken()*/) - return - } - lifecycleScope.launch { - displayLoading("Exchanging authorization code") - try { - val authState: FusionAuthState = AuthorizationManager.oAuth(this@TokenActivity) - .handleRedirect(intent) - Log.i(TAG, authState.toString()) + if (AuthorizationManager.isAuthenticated()) { fetchUserInfoAndDisplayAuthorized() - } catch (ex: AuthorizationException) { - Log.e(TAG, "Failed to exchange authorization code", ex) - displayNotAuthorized("Authorization failed") + } else { + displayLoading("Exchanging authorization code") + try { + val authState: FusionAuthState = AuthorizationManager.oAuth(this@TokenActivity) + .handleRedirect(intent) + Log.i(TAG, authState.toString()) + fetchUserInfoAndDisplayAuthorized() + } catch (ex: AuthorizationException) { + Log.e(TAG, "Failed to exchange authorization code", ex) + displayNotAuthorized("Authorization failed") + } } } } @@ -129,7 +129,7 @@ class TokenActivity : AppCompatActivity() { } @MainThread - private fun displayAuthorized() { + private suspend fun displayAuthorized() { findViewById(R.id.authorized).visibility = View.VISIBLE findViewById(R.id.not_authorized).visibility = View.GONE findViewById(R.id.loading_container).visibility = View.GONE @@ -214,7 +214,7 @@ class TokenActivity : AppCompatActivity() { showSnackbar("Failed to parse user info") } - runOnUiThread { this@TokenActivity.displayAuthorized() } + this@TokenActivity.displayAuthorized() } } @@ -265,7 +265,9 @@ class TokenActivity : AppCompatActivity() { @MainThread private fun signOut() { - AuthorizationManager.clearState() + lifecycleScope.launch { + AuthorizationManager.clearState() + } val mainIntent = Intent(this, LoginActivity::class.java) mainIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) diff --git a/library/build.gradle.kts b/library/build.gradle.kts index c51b10a0..45637c7d 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -176,6 +176,7 @@ dependencies { implementation("androidx.security:security-crypto-ktx:1.1.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("androidx.datastore:datastore-preferences:1.2.0") testImplementation("junit:junit:4.13.2") testImplementation("org.mockito:mockito-core:5.21.0") testImplementation("org.mockito.kotlin:mockito-kotlin:6.1.0") diff --git a/library/src/main/java/io/fusionauth/mobilesdk/AuthorizationManager.kt b/library/src/main/java/io/fusionauth/mobilesdk/AuthorizationManager.kt index 62bffa1b..7f0acc76 100644 --- a/library/src/main/java/io/fusionauth/mobilesdk/AuthorizationManager.kt +++ b/library/src/main/java/io/fusionauth/mobilesdk/AuthorizationManager.kt @@ -61,7 +61,7 @@ object AuthorizationManager { * * @return true if the user is authenticated, false otherwise */ - fun isAuthenticated(): Boolean { + suspend fun isAuthenticated(): Boolean { return !isAccessTokenExpired() } @@ -107,7 +107,7 @@ object AuthorizationManager { * * @return The access token string or null if not available. */ - fun getAccessToken(): String? { + suspend fun getAccessToken(): String? { return tokenManager.getAuthState()?.accessToken } @@ -117,7 +117,7 @@ object AuthorizationManager { * @return The expiration time of the access token, or null if the token manager is not set or the access token is * not available. */ - fun getAccessTokenExpirationTime(): Long? { + suspend fun getAccessTokenExpirationTime(): Long? { return tokenManager.getAuthState()?.accessTokenExpirationTime } @@ -126,7 +126,7 @@ object AuthorizationManager { * * @return true if the access token is expired, false otherwise. */ - fun isAccessTokenExpired(): Boolean { + suspend fun isAccessTokenExpired(): Boolean { return getAccessTokenExpirationTime()?.let { it < System.currentTimeMillis() } ?: true @@ -137,7 +137,7 @@ object AuthorizationManager { * * @return The ID token string, or null if the user is not authenticated. */ - fun getIdToken(): String? { + suspend fun getIdToken(): String? { return tokenManager.getAuthState()?.idToken } @@ -154,7 +154,7 @@ object AuthorizationManager { * @return The parsed ID token, or null if it cannot be parsed. */ @OptIn(ExperimentalEncodingApi::class) - fun getParsedIdToken(): IdToken? { + suspend fun getParsedIdToken(): IdToken? { return tokenManager.getAuthState()?.idToken?.let { val parts = it.split(".") require(parts.size == JWT_PARTS) { "Invalid JWT token" } @@ -167,7 +167,7 @@ object AuthorizationManager { * * This method clears the authorization state by removing the "authState" key from the storage. */ - fun clearState() { + suspend fun clearState() { tokenManager.clearAuthState() } diff --git a/library/src/main/java/io/fusionauth/mobilesdk/TokenManager.kt b/library/src/main/java/io/fusionauth/mobilesdk/TokenManager.kt index 5ed90209..503c8950 100644 --- a/library/src/main/java/io/fusionauth/mobilesdk/TokenManager.kt +++ b/library/src/main/java/io/fusionauth/mobilesdk/TokenManager.kt @@ -39,7 +39,7 @@ class TokenManager { * @throws StorageException if an error occurs while decoding the authorization state. */ @Suppress("TooGenericExceptionCaught") - fun getAuthState(): FusionAuthState? { + suspend fun getAuthState(): FusionAuthState? { if (this.authState.get() != null) { return this.authState.get() } @@ -62,7 +62,7 @@ class TokenManager { * @param authState The authorization state to be saved. * @throws NullPointerException if `storage` is null. */ - fun saveAuthState(authState: FusionAuthState) { + suspend fun saveAuthState(authState: FusionAuthState) { if (this.storage == null) throw StorageException.notSet() this.authState.set(authState) @@ -74,7 +74,7 @@ class TokenManager { * * @throws StorageException if the storage implementation is not set. */ - fun clearAuthState() { + suspend fun clearAuthState() { if (this.storage == null) throw StorageException.notSet() this.authState.set(null) diff --git a/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthAuthorizationService.kt b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthAuthorizationService.kt index 3a5f2f91..4a670afa 100644 --- a/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthAuthorizationService.kt +++ b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthAuthorizationService.kt @@ -477,44 +477,43 @@ class OAuthAuthorizationService internal constructor( */ private suspend fun freshAccessTokenInternal(): String? { val config = getConfiguration() + val authService = getAuthorizationService() - return suspendCoroutine { - val authService = getAuthorizationService() + val tm = tokenManager ?: throw AuthorizationException("TokenManager not available or not configured.") - val refreshToken = tokenManager?.getAuthState()?.refreshToken - if (refreshToken == null) { - it.resumeWithException(AuthorizationException("No refresh token available")) - return@suspendCoroutine - } + val authState = tm.getAuthState() + ?: throw AuthorizationException("Not authenticated.") + val refreshToken = authState.refreshToken + ?: throw AuthorizationException("No refresh token available") + + val response = suspendCoroutine { continuation -> authService.performTokenRequest( - TokenRequest.Builder( - config, - clientId - ) + TokenRequest.Builder(config, clientId) .setGrantType(GrantTypeValues.REFRESH_TOKEN) .setRefreshToken(refreshToken) .build(), appAuthState.clientAuthentication ) { response, exception -> + appAuthState.update(response, exception) if (response != null) { - val authState = tokenManager?.getAuthState() - if (authState != null) { - val newAuthState = authState.copy( - accessToken = response.accessToken, - accessTokenExpirationTime = response.accessTokenExpirationTime, - idToken = response.idToken, - refreshToken = response.refreshToken, - ) - tokenManager?.saveAuthState(newAuthState) - } - it.resume(response.accessToken) + continuation.resume(response) } else { - it.resumeWithException(exception?.let { ex -> AuthorizationException(ex) } + continuation.resumeWithException(exception?.let { AuthorizationException(it) } ?: AuthorizationException("Unknown error")) } } } + + val newAuthState = authState.copy( + accessToken = response.accessToken, + accessTokenExpirationTime = response.accessTokenExpirationTime, + idToken = response.idToken, + refreshToken = response.refreshToken ?: authState.refreshToken, + ) + tm.saveAuthState(newAuthState) + + return response.accessToken } /** diff --git a/library/src/main/java/io/fusionauth/mobilesdk/storage/DataStoreStorage.kt b/library/src/main/java/io/fusionauth/mobilesdk/storage/DataStoreStorage.kt new file mode 100644 index 00000000..69336844 --- /dev/null +++ b/library/src/main/java/io/fusionauth/mobilesdk/storage/DataStoreStorage.kt @@ -0,0 +1,61 @@ +package io.fusionauth.mobilesdk.storage + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first + +// Define the DataStore instance as an extension on Context. +// The name "fusionauth_settings" is the file name where preferences will be stored. +private val Context.dataStore: DataStore by preferencesDataStore(name = "fusionauth_settings") + +/** + * An implementation of the [Storage] interface that uses Android's Jetpack DataStore for persistence. + * This class is designed to be a modern replacement for SharedPreferences-based storage. + * + * @param context The application context, used to get the DataStore instance. + */ +class DataStoreStorage(private val context: Context) : Storage { + + /** + * Retrieves the value associated with the given key from DataStore. + * This operation is performed asynchronously. + * + * @param key The key for which to retrieve the value. + * @return The value associated with the key, or null if the key is not found. + */ + override suspend fun get(key: String): String? { + val prefKey = stringPreferencesKey(key) + return context.dataStore.data.first()[prefKey] + } + + /** + * Sets the value for the given key in DataStore. The content is converted to a String. + * This operation is performed asynchronously. + * + * @param key The key for which to set the value. + * @param content The value to be set. It will be stored as a String. + */ + override suspend fun set(key: String, content: Any) { + val prefKey = stringPreferencesKey(key) + context.dataStore.edit { settings -> + settings[prefKey] = content.toString() + } + } + + /** + * Removes the value associated with the given key from DataStore. + * This operation is performed asynchronously. + * + * @param key The key for which to remove the value. + */ + override suspend fun remove(key: String) { + val prefKey = stringPreferencesKey(key) + context.dataStore.edit { settings -> + settings.remove(prefKey) + } + } +} diff --git a/library/src/main/java/io/fusionauth/mobilesdk/storage/MemoryStorage.kt b/library/src/main/java/io/fusionauth/mobilesdk/storage/MemoryStorage.kt index c0efe1a4..9aa9772c 100644 --- a/library/src/main/java/io/fusionauth/mobilesdk/storage/MemoryStorage.kt +++ b/library/src/main/java/io/fusionauth/mobilesdk/storage/MemoryStorage.kt @@ -12,7 +12,7 @@ class MemoryStorage : Storage { * @param key the key whose associated value is to be retrieved * @return the value associated with the specified key, or null if the key is not found */ - override fun get(key: String): String? { + override suspend fun get(key: String): String? { return storageMap[key]?.toString() } @@ -22,7 +22,7 @@ class MemoryStorage : Storage { * @param key the key to set * @param content the content to associate with the key */ - override fun set(key: String, content: Any) { + override suspend fun set(key: String, content: Any) { storageMap[key] = content } @@ -31,7 +31,7 @@ class MemoryStorage : Storage { * * @param key the key of the key-value pair to remove */ - override fun remove(key: String) { + override suspend fun remove(key: String) { storageMap.remove(key) } } diff --git a/library/src/main/java/io/fusionauth/mobilesdk/storage/SharedPreferencesStorage.kt b/library/src/main/java/io/fusionauth/mobilesdk/storage/SharedPreferencesStorage.kt index 5ec2d4d6..c14c31fd 100644 --- a/library/src/main/java/io/fusionauth/mobilesdk/storage/SharedPreferencesStorage.kt +++ b/library/src/main/java/io/fusionauth/mobilesdk/storage/SharedPreferencesStorage.kt @@ -13,6 +13,11 @@ import androidx.core.content.edit * @param context The context used to access the application's SharedPreferences. * @param fileName The name of the SharedPreferences file. Default value is "_fusionauth_mobile_sdk". */ +@Deprecated( + message = "Use DataStoreStorage instead", + replaceWith = ReplaceWith("DataStoreStorage"), + level = DeprecationLevel.WARNING +) class SharedPreferencesStorage(context: Context, fileName: String = "_fusionauth_mobile_sdk") : Storage { private val sharedPreferences: SharedPreferences @@ -38,7 +43,7 @@ class SharedPreferencesStorage(context: Context, fileName: String = "_fusionauth * @param key The key used to retrieve the value. * @return The value associated with the key, or null if the key does not exist. */ - override fun get(key: String): String? { + override suspend fun get(key: String): String? { return this.sharedPreferences.getString(key, null) } @@ -48,7 +53,7 @@ class SharedPreferencesStorage(context: Context, fileName: String = "_fusionauth * @param key The key to associate with the value. * @param content The value to be stored. It can be of any type. */ - override fun set( + override suspend fun set( key: String, content: Any, ) { @@ -60,7 +65,7 @@ class SharedPreferencesStorage(context: Context, fileName: String = "_fusionauth * * @param key The key of the value to be removed. */ - override fun remove(key: String) { + override suspend fun remove(key: String) { this.sharedPreferences.edit { remove(key) } } } diff --git a/library/src/main/java/io/fusionauth/mobilesdk/storage/Storage.kt b/library/src/main/java/io/fusionauth/mobilesdk/storage/Storage.kt index 0a9c0ab8..846bd1ec 100644 --- a/library/src/main/java/io/fusionauth/mobilesdk/storage/Storage.kt +++ b/library/src/main/java/io/fusionauth/mobilesdk/storage/Storage.kt @@ -11,7 +11,7 @@ interface Storage { * @param key The key for which to retrieve the value. * @return The value associated with the key, or null if the key is not found in the storage. */ - fun get(key: String): String? + suspend fun get(key: String): String? /** * Sets the value associated with the given key in the storage. @@ -19,7 +19,7 @@ interface Storage { * @param key The key for which to set the value. * @param content The value to be set for the key. */ - fun set( + suspend fun set( key: String, content: Any, ) @@ -29,6 +29,6 @@ interface Storage { * * @param key The key for which to remove the value. */ - fun remove(key: String) + suspend fun remove(key: String) }