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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
<!--
end::forDocSiteGettingStarted[]
-->
Expand Down
9 changes: 5 additions & 4 deletions app/src/androidTest/java/io/fusionauth/sdk/FullEnd2EndTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand Down
15 changes: 9 additions & 6 deletions app/src/main/java/io/fusionauth/sdk/LoginActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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

Expand All @@ -41,15 +42,17 @@
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")

Check warning on line 51 in app/src/main/java/io/fusionauth/sdk/LoginActivity.kt

View workflow job for this annotation

GitHub Actions / MobSF Scan

[mobsf] reported by reviewdog 🐶 The App logs information. Sensitive information should never be logged. Raw Output: {"level":"note","locations":[{"physicalLocation":{"artifactLocation":{"uri":"app/src/main/java/io/fusionauth/sdk/LoginActivity.kt"},"region":{"endColumn":2051,"endLine":51,"snippet":{"text":"Log.i"},"startColumn":2046,"startLine":51}}}],"message":{"text":"The App logs information. Sensitive information should never be logged."},"properties":{},"ruleId":"android_kotlin_logging","ruleIndex":2}

Check notice

Code scanning / mobsfscan

The App logs information. Sensitive information should never be logged. Note

The App logs information. Sensitive information should never be logged.
startActivity(Intent(this@LoginActivity, TokenActivity::class.java))
finish()
return@launch
}
}

setContentView(R.layout.activity_login)
Expand Down
36 changes: 19 additions & 17 deletions app/src/main/java/io/fusionauth/sdk/TokenActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
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
Expand Down Expand Up @@ -58,7 +59,7 @@
if (!AuthorizationManager.isInitialized()) {
AuthorizationManager.initialize(
AuthorizationConfiguration.fromResources(this, R.raw.fusionauth_config),
SharedPreferencesStorage(this)
DataStoreStorage(this)
)
}

Expand All @@ -75,21 +76,20 @@
}

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())

Check warning on line 87 in app/src/main/java/io/fusionauth/sdk/TokenActivity.kt

View workflow job for this annotation

GitHub Actions / MobSF Scan

[mobsf] reported by reviewdog 🐶 The App logs information. Sensitive information should never be logged. Raw Output: {"level":"note","locations":[{"physicalLocation":{"artifactLocation":{"uri":"app/src/main/java/io/fusionauth/sdk/TokenActivity.kt"},"region":{"endColumn":3393,"endLine":87,"snippet":{"text":"Log.i"},"startColumn":3388,"startLine":87}}}],"message":{"text":"The App logs information. Sensitive information should never be logged."},"properties":{},"ruleId":"android_kotlin_logging","ruleIndex":2}

Check notice

Code scanning / mobsfscan

The App logs information. Sensitive information should never be logged. Note

The App logs information. Sensitive information should never be logged.

Check failure

Code scanning / CodeQL

Insertion of sensitive information into log files High

This
potentially sensitive information
is written to a log file.
This
potentially sensitive information
is written to a log file.
This
potentially sensitive information
is written to a log file.

Copilot Autofix

AI about 16 hours ago

To fix this issue, we must ensure that sensitive data such as access tokens, ID tokens, and refresh tokens are not written to any log output. The problematic log statement is Log.i(TAG, authState.toString()) on line 87 of TokenActivity.kt. Rather than logging the entire object, log a generic message indicating success or relevant non-sensitive details (e.g., that the authorization state was received or exchanged), without including actual tokens or authentication credentials. No changes are needed elsewhere; tokens should not be logged anywhere.

  • Change line 87 to something like: Log.i(TAG, "Authorization state successfully obtained")
  • If you want to assist debugging, you can include non-sensitive fields (other than tokens), but based on the given code, it's safest to just log a status message.
Suggested changeset 1
app/src/main/java/io/fusionauth/sdk/TokenActivity.kt

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/app/src/main/java/io/fusionauth/sdk/TokenActivity.kt b/app/src/main/java/io/fusionauth/sdk/TokenActivity.kt
--- a/app/src/main/java/io/fusionauth/sdk/TokenActivity.kt
+++ b/app/src/main/java/io/fusionauth/sdk/TokenActivity.kt
@@ -84,7 +84,7 @@
                 try {
                     val authState: FusionAuthState = AuthorizationManager.oAuth(this@TokenActivity)
                         .handleRedirect(intent)
-                    Log.i(TAG, authState.toString())
+                    Log.i(TAG, "Authorization state successfully obtained")
                     fetchUserInfoAndDisplayAuthorized()
                 } catch (ex: AuthorizationException) {
                     Log.e(TAG, "Failed to exchange authorization code", ex)
EOF
@@ -84,7 +84,7 @@
try {
val authState: FusionAuthState = AuthorizationManager.oAuth(this@TokenActivity)
.handleRedirect(intent)
Log.i(TAG, authState.toString())
Log.i(TAG, "Authorization state successfully obtained")
fetchUserInfoAndDisplayAuthorized()
} catch (ex: AuthorizationException) {
Log.e(TAG, "Failed to exchange authorization code", ex)
Copilot is powered by AI and may make mistakes. Always verify output.
fetchUserInfoAndDisplayAuthorized()
} catch (ex: AuthorizationException) {
Log.e(TAG, "Failed to exchange authorization code", ex)

Check warning on line 90 in app/src/main/java/io/fusionauth/sdk/TokenActivity.kt

View workflow job for this annotation

GitHub Actions / MobSF Scan

[mobsf] reported by reviewdog 🐶 The App logs information. Sensitive information should never be logged. Raw Output: {"level":"note","locations":[{"physicalLocation":{"artifactLocation":{"uri":"app/src/main/java/io/fusionauth/sdk/TokenActivity.kt"},"region":{"endColumn":3557,"endLine":90,"snippet":{"text":"Log.e"},"startColumn":3552,"startLine":90}}}],"message":{"text":"The App logs information. Sensitive information should never be logged."},"properties":{},"ruleId":"android_kotlin_logging","ruleIndex":2}

Check notice

Code scanning / mobsfscan

The App logs information. Sensitive information should never be logged. Note

The App logs information. Sensitive information should never be logged.
displayNotAuthorized("Authorization failed")
}
}
}
}
Expand Down Expand Up @@ -129,7 +129,7 @@
}

@MainThread
private fun displayAuthorized() {
private suspend fun displayAuthorized() {
findViewById<View>(R.id.authorized).visibility = View.VISIBLE
findViewById<View>(R.id.not_authorized).visibility = View.GONE
findViewById<View>(R.id.loading_container).visibility = View.GONE
Expand Down Expand Up @@ -214,7 +214,7 @@
showSnackbar("Failed to parse user info")
}

runOnUiThread { this@TokenActivity.displayAuthorized() }
this@TokenActivity.displayAuthorized()
}
}

Expand Down Expand Up @@ -265,7 +265,9 @@

@MainThread
private fun signOut() {
AuthorizationManager.clearState()
lifecycleScope.launch {
AuthorizationManager.clearState()
}

val mainIntent = Intent(this, LoginActivity::class.java)
mainIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
Expand Down
1 change: 1 addition & 0 deletions library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ object AuthorizationManager {
*
* @return true if the user is authenticated, false otherwise
*/
fun isAuthenticated(): Boolean {
suspend fun isAuthenticated(): Boolean {
return !isAccessTokenExpired()
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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" }
Expand All @@ -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()
}

Expand Down
6 changes: 3 additions & 3 deletions library/src/main/java/io/fusionauth/mobilesdk/TokenManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenResponse> { 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
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Preferences> 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)
}
}
}
Loading
Loading