diff --git a/android/app/src/main/java/com/internxt/cloud/MainApplication.kt b/android/app/src/main/java/com/internxt/cloud/MainApplication.kt index 522fcce5a..9a3426c08 100644 --- a/android/app/src/main/java/com/internxt/cloud/MainApplication.kt +++ b/android/app/src/main/java/com/internxt/cloud/MainApplication.kt @@ -13,6 +13,8 @@ import com.facebook.react.common.ReleaseLevel import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost +import com.internxt.cloud.auth.InternxtAuthCredentialsPackage + import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper @@ -24,6 +26,7 @@ class MainApplication : Application(), ReactApplication { override fun getPackages(): List = PackageList(this).packages.apply { add(ShareIntentPackage()) + add(InternxtAuthCredentialsPackage()) } override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" diff --git a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt new file mode 100644 index 000000000..a183dee4b --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt @@ -0,0 +1,71 @@ +package com.internxt.cloud.auth + +import android.provider.DocumentsContract +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.internxt.cloud.documents.InternxtDocumentsProvider +import com.internxt.cloud.documents.auth.InternxtAuthManager + +class InternxtAuthCredentialsModule(private val ctx: ReactApplicationContext) : + ReactContextBaseJavaModule(ctx) { + + private val authManager by lazy { InternxtAuthManager.create(ctx.applicationContext) } + + override fun getName() = MODULE_NAME + + @ReactMethod + fun setCredentials(map: ReadableMap, promise: Promise) { + try { + val creds = InternxtAuthManager.Credentials( + bearerToken = map.requireString("bearerToken"), + userId = map.requireString("userId"), + bridgeUser = map.requireString("bridgeUser"), + rootFolderUuid = map.requireString("rootFolderUuid"), + email = map.optString("email"), + driveBaseUrl = map.requireString("driveBaseUrl"), + bridgeBaseUrl = map.requireString("bridgeBaseUrl"), + desktopToken = map.optString("desktopToken"), + ) + authManager.saveCredentials(creds) + notifyRootsChanged() + promise.resolve(null) + } catch (e: IllegalArgumentException) { + promise.reject("E_MISSING_FIELD", e.message, e) + } catch (e: Exception) { + promise.reject("E_SAVE_CREDENTIALS", e.message, e) + } + } + + @ReactMethod + fun clearCredentials(promise: Promise) { + try { + authManager.clear() + notifyRootsChanged() + promise.resolve(null) + } catch (e: Exception) { + promise.reject("E_CLEAR_CREDENTIALS", e.message, e) + } + } + + private fun notifyRootsChanged() { + ctx.contentResolver.notifyChange( + DocumentsContract.buildRootsUri(InternxtDocumentsProvider.AUTHORITY), + null, + ) + } + + private fun ReadableMap.nonBlankString(key: String): String? = + if (hasKey(key) && !isNull(key)) getString(key)?.takeIf { it.isNotBlank() } else null + + private fun ReadableMap.requireString(key: String): String = + nonBlankString(key) ?: throw IllegalArgumentException("Missing or blank credential field: $key") + + private fun ReadableMap.optString(key: String): String? = nonBlankString(key) + + companion object { + const val MODULE_NAME = "InternxtAuthCredentialsModule" + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsPackage.kt b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsPackage.kt new file mode 100644 index 000000000..c7bc6e68a --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsPackage.kt @@ -0,0 +1,14 @@ +package com.internxt.cloud.auth + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class InternxtAuthCredentialsPackage : ReactPackage { + override fun createNativeModules(context: ReactApplicationContext): List = + listOf(InternxtAuthCredentialsModule(context)) + + override fun createViewManagers(context: ReactApplicationContext): List> = + emptyList() +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt index 668493ac6..4019448a4 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt @@ -4,22 +4,35 @@ import android.database.Cursor import android.database.MatrixCursor import android.os.CancellationSignal import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract import android.provider.DocumentsContract.Document import android.provider.DocumentsContract.Root import android.provider.DocumentsProvider import com.internxt.cloud.R +import com.internxt.cloud.documents.auth.InternxtAuthManager class InternxtDocumentsProvider : DocumentsProvider() { - override fun onCreate(): Boolean = true + private lateinit var authManager: InternxtAuthManager + + override fun onCreate(): Boolean { + authManager = InternxtAuthManager.create(context!!.applicationContext) + return true + } override fun queryRoots(projection: Array?): Cursor { val cursor = MatrixCursor(resolveRootProjection(projection)) + val ctx = context ?: return cursor + cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildRootsUri(AUTHORITY)) + + val rootUuid = authManager.authenticatedRootUuid() ?: return cursor + cursor.newRow().apply { add(Root.COLUMN_ROOT_ID, ROOT_ID) - add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID) - add(Root.COLUMN_TITLE, context?.getString(R.string.documents_provider_label)) - add(Root.COLUMN_FLAGS, 0) + add(Root.COLUMN_DOCUMENT_ID, rootUuid) + add(Root.COLUMN_TITLE, ctx.getString(R.string.documents_provider_label)) + authManager.userEmail()?.let { add(Root.COLUMN_SUMMARY, it) } + add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD) add(Root.COLUMN_ICON, R.mipmap.ic_launcher) } return cursor @@ -50,8 +63,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { companion object { const val AUTHORITY = "com.internxt.cloud.documents" - private const val ROOT_ID = "root" - private const val ROOT_DOCUMENT_ID = "root" + private const val ROOT_ID = "internxt-root" private val DEFAULT_ROOT_PROJECTION = arrayOf( Root.COLUMN_ROOT_ID, diff --git a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt new file mode 100644 index 000000000..9d4856c35 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt @@ -0,0 +1,101 @@ +package com.internxt.cloud.documents.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.internxt.cloud.BuildConfig +import com.internxt.cloud.documents.api.AuthConfig + +class InternxtAuthManager(private val prefs: SharedPreferences) { + + data class Credentials( + val bearerToken: String, + val userId: String, + val bridgeUser: String, + val rootFolderUuid: String, + val email: String?, + val driveBaseUrl: String, + val bridgeBaseUrl: String, + val desktopToken: String?, + ) + + fun isLoggedIn(): Boolean = + REQUIRED_KEYS.all { !prefs.getString(it, null).isNullOrBlank() } + + fun rootFolderUuid(): String? = prefs.getString(KEY_ROOT_FOLDER_UUID, null)?.takeIf { it.isNotBlank() } + + fun authenticatedRootUuid(): String? = if (isLoggedIn()) rootFolderUuid() else null + + fun userEmail(): String? = prefs.getString(KEY_EMAIL, null)?.takeIf { it.isNotBlank() } + + fun loadAuthConfig(): AuthConfig? { + if (!isLoggedIn()) return null + return AuthConfig( + driveBaseUrl = required(KEY_DRIVE_BASE_URL), + bridgeBaseUrl = required(KEY_BRIDGE_BASE_URL), + bearerToken = required(KEY_BEARER_TOKEN), + bridgeUser = required(KEY_BRIDGE_USER), + userId = required(KEY_USER_ID), + clientName = BuildConfig.INTERNXT_CLIENT_NAME, + clientVersion = BuildConfig.INTERNXT_CLIENT_VERSION, + desktopToken = prefs.getString(KEY_DESKTOP_TOKEN, null)?.takeIf { it.isNotBlank() }, + ) + } + + private fun required(key: String): String = + prefs.getString(key, null) ?: error("$key missing after isLoggedIn() returned true") + + fun saveCredentials(creds: Credentials) { + prefs.edit() + .putString(KEY_BEARER_TOKEN, creds.bearerToken) + .putString(KEY_USER_ID, creds.userId) + .putString(KEY_BRIDGE_USER, creds.bridgeUser) + .putString(KEY_ROOT_FOLDER_UUID, creds.rootFolderUuid) + .putString(KEY_EMAIL, creds.email) + .putString(KEY_DRIVE_BASE_URL, creds.driveBaseUrl) + .putString(KEY_BRIDGE_BASE_URL, creds.bridgeBaseUrl) + .putString(KEY_DESKTOP_TOKEN, creds.desktopToken) + .apply() + } + + fun clear() { + prefs.edit().clear().apply() + } + + companion object { + private const val PREFS_FILE = "internxt_documents_auth" + + private const val KEY_BEARER_TOKEN = "bearerToken" + private const val KEY_USER_ID = "userId" + private const val KEY_BRIDGE_USER = "bridgeUser" + private const val KEY_ROOT_FOLDER_UUID = "rootFolderUuid" + private const val KEY_EMAIL = "email" + private const val KEY_DRIVE_BASE_URL = "driveBaseUrl" + private const val KEY_BRIDGE_BASE_URL = "bridgeBaseUrl" + private const val KEY_DESKTOP_TOKEN = "desktopToken" + + private val REQUIRED_KEYS = listOf( + KEY_BEARER_TOKEN, + KEY_USER_ID, + KEY_BRIDGE_USER, + KEY_ROOT_FOLDER_UUID, + KEY_DRIVE_BASE_URL, + KEY_BRIDGE_BASE_URL, + ) + + fun create(context: Context): InternxtAuthManager { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val prefs = EncryptedSharedPreferences.create( + context, + PREFS_FILE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + return InternxtAuthManager(prefs) + } + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt new file mode 100644 index 000000000..a64a68cb5 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt @@ -0,0 +1,168 @@ +package com.internxt.cloud.documents.auth + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class InternxtAuthManagerTest { + + private lateinit var prefs: FakeSharedPreferences + private lateinit var manager: InternxtAuthManager + + companion object { + private const val USER_EMAIL = "user@example.com" + private val FULL_CREDS = InternxtAuthManager.Credentials( + bearerToken = "bearer-xyz", + userId = "user-1", + bridgeUser = USER_EMAIL, + rootFolderUuid = "root-uuid", + email = USER_EMAIL, + driveBaseUrl = "https://drive.test/api", + bridgeBaseUrl = "https://bridge.test", + desktopToken = "desktop-tok", + ) + } + + @Before + fun setUp() { + prefs = FakeSharedPreferences() + manager = InternxtAuthManager(prefs) + } + + @Test + fun isLoggedInFalseWhenPrefsEmpty() { + assertFalse(manager.isLoggedIn()) + assertNull(manager.rootFolderUuid()) + assertNull(manager.userEmail()) + assertNull(manager.loadAuthConfig()) + } + + @Test + fun isLoggedInTrueAfterSavingFullCredentials() { + manager.saveCredentials(FULL_CREDS) + + assertTrue(manager.isLoggedIn()) + assertEquals("root-uuid", manager.rootFolderUuid()) + assertEquals(USER_EMAIL, manager.userEmail()) + } + + @Test + fun loadAuthConfigReturnsExpectedFields() { + manager.saveCredentials(FULL_CREDS) + + val config = manager.loadAuthConfig()!! + assertEquals("https://drive.test/api", config.driveBaseUrl) + assertEquals("https://bridge.test", config.bridgeBaseUrl) + assertEquals("bearer-xyz", config.bearerToken) + assertEquals(USER_EMAIL, config.bridgeUser) + assertEquals("user-1", config.userId) + assertEquals("desktop-tok", config.desktopToken) + } + + @Test + fun loadAuthConfigOmitsDesktopTokenWhenBlank() { + manager.saveCredentials(FULL_CREDS.copy(desktopToken = null)) + + val config = manager.loadAuthConfig()!! + assertNull(config.desktopToken) + } + + @Test + fun isLoggedInFalseWhenAnyRequiredFieldMissing() { + val requiredMissing = listOf( + FULL_CREDS.copy(bearerToken = ""), + FULL_CREDS.copy(userId = ""), + FULL_CREDS.copy(bridgeUser = ""), + FULL_CREDS.copy(rootFolderUuid = ""), + FULL_CREDS.copy(driveBaseUrl = ""), + FULL_CREDS.copy(bridgeBaseUrl = ""), + ) + for (creds in requiredMissing) { + prefs = FakeSharedPreferences() + manager = InternxtAuthManager(prefs) + manager.saveCredentials(creds) + + assertFalse("should be logged out when field blank: $creds", manager.isLoggedIn()) + assertNull(manager.loadAuthConfig()) + } + } + + @Test + fun clearRemovesAllCredentials() { + manager.saveCredentials(FULL_CREDS) + assertTrue(manager.isLoggedIn()) + + manager.clear() + + assertFalse(manager.isLoggedIn()) + assertNull(manager.rootFolderUuid()) + assertNull(manager.userEmail()) + assertNull(manager.loadAuthConfig()) + } + + @Test + fun savingOverwritesPreviousCredentials() { + manager.saveCredentials(FULL_CREDS) + manager.saveCredentials(FULL_CREDS.copy(bearerToken = "new-token", rootFolderUuid = "new-root")) + + assertEquals("new-token", manager.loadAuthConfig()!!.bearerToken) + assertEquals("new-root", manager.rootFolderUuid()) + } +} + +private class FakeSharedPreferences : SharedPreferences { + private val store = HashMap() + + override fun getAll(): MutableMap = HashMap(store) + override fun getString(key: String?, defValue: String?): String? = + store[key] as? String ?: defValue + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + @Suppress("UNCHECKED_CAST") (store[key] as? MutableSet) ?: defValues + + override fun getInt(key: String?, defValue: Int): Int = (store[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (store[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = (store[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = (store[key] as? Boolean) ?: defValue + override fun contains(key: String?): Boolean = store.containsKey(key) + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + + override fun edit(): SharedPreferences.Editor = FakeEditor(store) + + private class FakeEditor(private val store: HashMap) : SharedPreferences.Editor { + private val pending = HashMap() + private val removed = HashSet() + private var clearPending = false + + override fun putString(key: String, value: String?) = apply { pending[key] = value } + override fun putStringSet(key: String, values: MutableSet?) = apply { pending[key] = values } + override fun putInt(key: String, value: Int) = apply { pending[key] = value } + override fun putLong(key: String, value: Long) = apply { pending[key] = value } + override fun putFloat(key: String, value: Float) = apply { pending[key] = value } + override fun putBoolean(key: String, value: Boolean) = apply { pending[key] = value } + override fun remove(key: String) = apply { removed.add(key) } + override fun clear() = apply { clearPending = true } + + override fun commit(): Boolean { + applyChanges() + return true + } + + override fun apply() { + applyChanges() + } + + private fun applyChanges() { + if (clearPending) store.clear() + for (k in removed) store.remove(k) + for ((k, v) in pending) { + if (v == null) store.remove(k) else store[k] = v + } + } + } +} diff --git a/src/services/native/InternxtAuthCredentialsModule.ts b/src/services/native/InternxtAuthCredentialsModule.ts new file mode 100644 index 000000000..ae296f24d --- /dev/null +++ b/src/services/native/InternxtAuthCredentialsModule.ts @@ -0,0 +1,30 @@ +import { NativeModules, Platform } from 'react-native'; + +export interface InternxtAuthCredentials { + bearerToken: string; + userId: string; + bridgeUser: string; + rootFolderUuid: string; + email?: string | null; + driveBaseUrl: string; + bridgeBaseUrl: string; + desktopToken?: string | null; +} + +interface NativeBridge { + setCredentials(creds: InternxtAuthCredentials): Promise; + clearCredentials(): Promise; +} + +const bridge: NativeBridge | undefined = + Platform.OS === 'android' ? NativeModules.InternxtAuthCredentialsModule : undefined; + +export async function setCredentials(creds: InternxtAuthCredentials): Promise { + if (!bridge) return; + await bridge.setCredentials(creds); +} + +export async function clearCredentials(): Promise { + if (!bridge) return; + await bridge.clearCredentials(); +} diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index 72603a2b1..838d7d02b 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -8,8 +8,10 @@ import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; import errorService from 'src/services/ErrorService'; import { RootState } from '../..'; import strings from '../../../../assets/lang/strings'; +import appService from '../../../services/AppService'; import asyncStorageService from '../../../services/AsyncStorageService'; import authService from '../../../services/AuthService'; +import { clearCredentials, setCredentials } from '../../../services/native/InternxtAuthCredentialsModule'; import notificationsService from '../../../services/NotificationsService'; import { default as userService } from '../../../services/UserService'; import { AsyncStorageKey, NotificationType } from '../../../types'; @@ -33,6 +35,22 @@ const initialState: AuthState = { sessionPassword: undefined, }; +async function syncNativeCredentials(token: string, user: UserSettings): Promise { + try { + await setCredentials({ + bearerToken: token, + userId: user.userId, + bridgeUser: user.bridgeUser, + rootFolderUuid: user.rootFolderUuid || user.rootFolderId, + email: user.email, + driveBaseUrl: appService.constants.DRIVE_NEW_API_URL, + bridgeBaseUrl: appService.constants.BRIDGE_URL, + }); + } catch (err) { + errorService.reportError(err); + } +} + export const initializeThunk = createAsyncThunk( 'auth/initialize', async (_, { dispatch }) => { @@ -116,6 +134,9 @@ export const signInThunk = createAsyncThunk< await asyncStorageService.saveItem(AsyncStorageKey.User, JSON.stringify(userToSave)); // Reset this, in case we logged out during the pull process await asyncStorageService.deleteItem(AsyncStorageKey.LastPhotoPulledDate); + + await syncNativeCredentials(payload.token, userToSave); + dispatch( authActions.setSignInData({ token: payload.token, @@ -155,6 +176,8 @@ export const refreshTokensThunk = createAsyncThunk