Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -24,6 +26,7 @@ class MainApplication : Application(), ReactApplication {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(ShareIntentPackage())
add(InternxtAuthCredentialsPackage())
}

override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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) {
val manager = authManager ?: run {
promise.reject("E_AUTH_UNAVAILABLE", "Encrypted credential storage unavailable")
return
}
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"),
)
if (!manager.saveCredentials(creds)) {
promise.reject("E_SAVE_CREDENTIALS", "Failed to persist credentials")
return
}
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) {
val manager = authManager ?: run {
promise.reject("E_AUTH_UNAVAILABLE", "Encrypted credential storage unavailable")
return
}
try {
if (!manager.clear()) {
promise.reject("E_CLEAR_CREDENTIALS", "Failed to clear credentials")
return
}
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"
}
}
Original file line number Diff line number Diff line change
@@ -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<NativeModule> =
listOf(InternxtAuthCredentialsModule(context))

override fun createViewManagers(context: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,36 @@ 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 {
val ctx = context ?: return false
authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false
return true
}

override fun queryRoots(projection: Array<String>?): 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
Expand Down Expand Up @@ -50,8 +64,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.internxt.cloud.documents.auth

import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.internxt.cloud.BuildConfig
import com.internxt.cloud.documents.api.AuthConfig
import java.io.IOException
import java.security.GeneralSecurityException

class InternxtAuthManager(private val prefs: SharedPreferences) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this AuthManager in docuemtns instead of the other auth directory?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because only the file picker reads these credentials. The auth/ folder is just the JS <-> Android bridge, it writes, but doesn't own the data.


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): Boolean =
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)
.commit()

fun clear(): Boolean = prefs.edit().clear().commit()

companion object {
private const val TAG = "InternxtAuthManager"
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? {
return try {
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,
)
InternxtAuthManager(prefs)
} catch (e: GeneralSecurityException) {
Log.e(TAG, "Keystore unavailable, SAF auth disabled", e)
null
} catch (e: IOException) {
Log.e(TAG, "Could not open encrypted prefs, SAF auth disabled", e)
null
}
}
}
}
Loading
Loading