mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-11 17:03:33 -04:00
Fix first time login authorization header overwrite bug (#520)
This commit is contained in:
@@ -16,6 +16,11 @@ import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableType
|
||||
import com.facebook.react.module.annotations.ReactModule
|
||||
import com.facebook.react.turbomodule.core.interfaces.TurboModule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.aliasvault.app.vaultstore.VaultStore
|
||||
import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider
|
||||
import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider
|
||||
@@ -23,11 +28,6 @@ import net.aliasvault.app.webapi.WebApiService
|
||||
import net.aliasvault.nativevaultmanager.NativeVaultManagerSpec
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The native vault manager that manages the vault store and all input/output operations on it.
|
||||
@@ -986,7 +986,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
body: String?,
|
||||
headers: String,
|
||||
requiresAuth: Boolean,
|
||||
promise: Promise
|
||||
promise: Promise,
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement when WebApiService is complete
|
||||
@@ -1004,7 +1004,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
endpoint = endpoint,
|
||||
body = body,
|
||||
headers = headersMap,
|
||||
requiresAuth = requiresAuth
|
||||
requiresAuth = requiresAuth,
|
||||
)
|
||||
|
||||
// Build response JSON
|
||||
@@ -1042,8 +1042,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
@ReactMethod
|
||||
override fun setUsername(username: String, promise: Promise) {
|
||||
try {
|
||||
// TODO: Implement username storage in VaultStore (similar to iOS)
|
||||
// vaultStore.setUsername(username)
|
||||
vaultStore.setUsername(username)
|
||||
promise.resolve(null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error setting username", e)
|
||||
@@ -1058,10 +1057,8 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
@ReactMethod
|
||||
override fun getUsername(promise: Promise) {
|
||||
try {
|
||||
// TODO: Implement username retrieval from VaultStore (similar to iOS)
|
||||
// val username = vaultStore.getUsername()
|
||||
// promise.resolve(username)
|
||||
promise.resolve(null)
|
||||
val username = vaultStore.getUsername()
|
||||
promise.resolve(username)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting username", e)
|
||||
promise.reject("ERR_GET_USERNAME", "Failed to get username: ${e.message}", e)
|
||||
@@ -1075,8 +1072,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
@ReactMethod
|
||||
override fun clearUsername(promise: Promise) {
|
||||
try {
|
||||
// TODO: Implement username clearing in VaultStore (similar to iOS)
|
||||
// vaultStore.clearUsername()
|
||||
vaultStore.clearUsername()
|
||||
promise.resolve(null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing username", e)
|
||||
@@ -1094,8 +1090,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
@ReactMethod
|
||||
override fun setOfflineMode(isOffline: Boolean, promise: Promise) {
|
||||
try {
|
||||
// TODO: Implement offline mode storage in VaultStore (similar to iOS)
|
||||
// vaultStore.setOfflineMode(isOffline)
|
||||
vaultStore.setOfflineMode(isOffline)
|
||||
promise.resolve(null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error setting offline mode", e)
|
||||
@@ -1110,10 +1105,8 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
@ReactMethod
|
||||
override fun getOfflineMode(promise: Promise) {
|
||||
try {
|
||||
// TODO: Implement offline mode retrieval from VaultStore (similar to iOS)
|
||||
// val isOffline = vaultStore.getOfflineMode()
|
||||
// promise.resolve(isOffline)
|
||||
promise.resolve(false)
|
||||
val isOffline = vaultStore.getOfflineMode()
|
||||
promise.resolve(isOffline)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting offline mode", e)
|
||||
promise.reject("ERR_GET_OFFLINE_MODE", "Failed to get offline mode: ${e.message}", e)
|
||||
@@ -1123,30 +1116,52 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
// MARK: - Vault Sync and Mutate
|
||||
|
||||
/**
|
||||
* Sync vault with server
|
||||
* Returns true if new vault was downloaded
|
||||
* @param promise The promise to resolve
|
||||
* Check if a new vault version is available on the server
|
||||
* @param promise The promise to resolve with object containing isNewVersionAvailable and newRevision
|
||||
*/
|
||||
@ReactMethod
|
||||
override fun syncVault(promise: Promise) {
|
||||
override fun isNewVaultVersionAvailable(promise: Promise) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// TODO: Implement vault sync in VaultStore (similar to iOS)
|
||||
// This should:
|
||||
// 1. Call WebApiService to get Auth/status
|
||||
// 2. Compare vault revisions
|
||||
// 3. Download vault if server has newer version
|
||||
// 4. Store encrypted vault and update revision
|
||||
// 5. Return true if new vault downloaded, false otherwise
|
||||
//
|
||||
// val hasNewVault = vaultStore.syncVault(webApiService)
|
||||
val result = vaultStore.isNewVaultVersionAvailable(webApiService)
|
||||
val resultMap = Arguments.createMap()
|
||||
resultMap.putBoolean("isNewVersionAvailable", result["isNewVersionAvailable"] as Boolean)
|
||||
val newRevision = result["newRevision"] as? Int
|
||||
if (newRevision != null) {
|
||||
resultMap.putInt("newRevision", newRevision)
|
||||
} else {
|
||||
resultMap.putNull("newRevision")
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
promise.resolve(false)
|
||||
promise.resolve(resultMap)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Log.e(TAG, "Error syncing vault", e)
|
||||
promise.reject("ERR_SYNC_VAULT", "Failed to sync vault: ${e.message}", e)
|
||||
Log.e(TAG, "Error checking vault version", e)
|
||||
promise.reject("ERR_CHECK_VAULT_VERSION", "Failed to check vault version: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and store the vault from the server
|
||||
* @param newRevision The new revision number to download
|
||||
* @param promise The promise to resolve
|
||||
*/
|
||||
@ReactMethod
|
||||
override fun downloadVault(newRevision: Double, promise: Promise) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val success = vaultStore.downloadVault(webApiService, newRevision.toInt())
|
||||
withContext(Dispatchers.Main) {
|
||||
promise.resolve(success)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Log.e(TAG, "Error downloading vault", e)
|
||||
promise.reject("ERR_DOWNLOAD_VAULT", "Failed to download vault: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1160,16 +1175,9 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
|
||||
override fun mutateVault(promise: Promise) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// TODO: Implement vault mutate in VaultStore (similar to iOS)
|
||||
// This should:
|
||||
// 1. Prepare vault for upload (assemble metadata)
|
||||
// 2. POST vault to server
|
||||
// 3. Update local revision number
|
||||
// 4. Clear offline mode on success
|
||||
//
|
||||
// vaultStore.mutateVault(webApiService)
|
||||
val success = vaultStore.mutateVault(webApiService)
|
||||
withContext(Dispatchers.Main) {
|
||||
promise.resolve(null)
|
||||
promise.resolve(success)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -1026,4 +1026,486 @@ class VaultStore(
|
||||
val invisible = "[\\uFEFF\\u200B\\u00A0\\u202A-\\u202E\\u2060\\u180E]"
|
||||
return this.replace(Regex("^($invisible)+|($invisible)+$"), "").trim()
|
||||
}
|
||||
|
||||
// MARK: - Username Management
|
||||
|
||||
/**
|
||||
* Set the username.
|
||||
* @param username The username to store
|
||||
*/
|
||||
fun setUsername(username: String) {
|
||||
storageProvider.setUsername(username)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the username.
|
||||
* @return The username or null if not set
|
||||
*/
|
||||
fun getUsername(): String? {
|
||||
return storageProvider.getUsername()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the username.
|
||||
*/
|
||||
fun clearUsername() {
|
||||
storageProvider.clearUsername()
|
||||
}
|
||||
|
||||
// MARK: - Offline Mode Management
|
||||
|
||||
/**
|
||||
* Set offline mode flag.
|
||||
* @param isOffline Whether the app is in offline mode
|
||||
*/
|
||||
fun setOfflineMode(isOffline: Boolean) {
|
||||
storageProvider.setOfflineMode(isOffline)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offline mode flag.
|
||||
* @return True if app is in offline mode, false otherwise
|
||||
*/
|
||||
fun getOfflineMode(): Boolean {
|
||||
return storageProvider.getOfflineMode()
|
||||
}
|
||||
|
||||
// MARK: - Vault Sync Methods
|
||||
|
||||
/**
|
||||
* Check if a new vault version is available on the server.
|
||||
* Returns a map with isNewVersionAvailable and newRevision keys.
|
||||
*
|
||||
* @param webApiService The WebApiService to use for the request
|
||||
* @return Map with "isNewVersionAvailable" (Boolean) and "newRevision" (Int?) keys
|
||||
*/
|
||||
suspend fun isNewVaultVersionAvailable(webApiService: net.aliasvault.app.webapi.WebApiService): Map<String, Any?> {
|
||||
val status = fetchAndValidateStatus(webApiService)
|
||||
setOfflineMode(false)
|
||||
|
||||
val currentRevision = getVaultRevisionNumber()
|
||||
return if (status.vaultRevision > currentRevision) {
|
||||
mapOf(
|
||||
"isNewVersionAvailable" to true,
|
||||
"newRevision" to status.vaultRevision,
|
||||
)
|
||||
} else {
|
||||
mapOf(
|
||||
"isNewVersionAvailable" to false,
|
||||
"newRevision" to null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and store the vault from the server.
|
||||
* This method assumes a version check has already been performed.
|
||||
*
|
||||
* @param webApiService The WebApiService to use for the request
|
||||
* @param newRevision The new revision number to download
|
||||
* @return True if successful
|
||||
*/
|
||||
suspend fun downloadVault(webApiService: net.aliasvault.app.webapi.WebApiService, newRevision: Int): Boolean {
|
||||
try {
|
||||
downloadAndStoreVault(webApiService, newRevision)
|
||||
setOfflineMode(false)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error downloading vault", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and validate server status.
|
||||
*/
|
||||
private suspend fun fetchAndValidateStatus(webApiService: net.aliasvault.app.webapi.WebApiService): StatusResponse {
|
||||
val statusResponse = try {
|
||||
webApiService.executeRequest(
|
||||
method = "GET",
|
||||
endpoint = "Auth/status",
|
||||
body = null,
|
||||
headers = emptyMap(),
|
||||
requiresAuth = true,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Network error: ${e.message}", e)
|
||||
}
|
||||
|
||||
// Check response status
|
||||
if (statusResponse.statusCode != 200) {
|
||||
if (statusResponse.statusCode == 401) {
|
||||
throw Exception("Session expired")
|
||||
}
|
||||
setOfflineMode(true)
|
||||
throw Exception("Server unavailable: ${statusResponse.statusCode}")
|
||||
}
|
||||
|
||||
val status = try {
|
||||
val json = org.json.JSONObject(statusResponse.body)
|
||||
StatusResponse(
|
||||
clientVersionSupported = json.getBoolean("clientVersionSupported"),
|
||||
serverVersion = json.getString("serverVersion"),
|
||||
vaultRevision = json.getInt("vaultRevision"),
|
||||
srpSalt = json.getString("srpSalt"),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode status response", e)
|
||||
throw Exception("Failed to decode status response: ${e.message}")
|
||||
}
|
||||
|
||||
if (!status.clientVersionSupported) {
|
||||
throw Exception("Client version not supported")
|
||||
}
|
||||
|
||||
validateSrpSalt(status.srpSalt)
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SRP salt hasn't changed (password change detection).
|
||||
*/
|
||||
private fun validateSrpSalt(srpSalt: String) {
|
||||
val keyDerivationParams = storageProvider.getKeyDerivationParams()
|
||||
if (keyDerivationParams.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val json = org.json.JSONObject(keyDerivationParams)
|
||||
val salt = json.optString("salt", "")
|
||||
if (srpSalt.isNotEmpty() && srpSalt != salt) {
|
||||
throw Exception("Password changed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e.message == "Password changed") throw e
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download vault from server and store it locally.
|
||||
*/
|
||||
private suspend fun downloadAndStoreVault(webApiService: net.aliasvault.app.webapi.WebApiService, newRevision: Int) {
|
||||
val vaultResponse = try {
|
||||
webApiService.executeRequest(
|
||||
method = "GET",
|
||||
endpoint = "Vault",
|
||||
body = null,
|
||||
headers = emptyMap(),
|
||||
requiresAuth = true,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Network error: ${e.message}", e)
|
||||
}
|
||||
|
||||
if (vaultResponse.statusCode != 200) {
|
||||
if (vaultResponse.statusCode == 401) {
|
||||
throw Exception("Session expired")
|
||||
}
|
||||
throw Exception("Server unavailable: ${vaultResponse.statusCode}")
|
||||
}
|
||||
|
||||
val vault = parseVaultResponse(vaultResponse.body)
|
||||
validateVaultStatus(vault.status)
|
||||
storeEncryptedDatabase(vault.vault.blob)
|
||||
setVaultRevisionNumber(newRevision)
|
||||
|
||||
if (isVaultUnlocked()) {
|
||||
unlockVault()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse vault response from JSON.
|
||||
*/
|
||||
private fun parseVaultResponse(body: String): VaultResponse {
|
||||
return try {
|
||||
val json = org.json.JSONObject(body)
|
||||
val vaultJson = json.getJSONObject("vault")
|
||||
|
||||
val emailList = mutableListOf<String>()
|
||||
val emailArray = vaultJson.getJSONArray("emailAddressList")
|
||||
for (i in 0 until emailArray.length()) {
|
||||
emailList.add(emailArray.getString(i))
|
||||
}
|
||||
|
||||
val privateList = mutableListOf<String>()
|
||||
val privateArray = vaultJson.getJSONArray("privateEmailDomainList")
|
||||
for (i in 0 until privateArray.length()) {
|
||||
privateList.add(privateArray.getString(i))
|
||||
}
|
||||
|
||||
val publicList = mutableListOf<String>()
|
||||
val publicArray = vaultJson.getJSONArray("publicEmailDomainList")
|
||||
for (i in 0 until publicArray.length()) {
|
||||
publicList.add(publicArray.getString(i))
|
||||
}
|
||||
|
||||
VaultResponse(
|
||||
status = json.getInt("status"),
|
||||
vault = VaultData(
|
||||
username = vaultJson.getString("username"),
|
||||
blob = vaultJson.getString("blob"),
|
||||
version = vaultJson.getString("version"),
|
||||
currentRevisionNumber = vaultJson.getInt("currentRevisionNumber"),
|
||||
encryptionPublicKey = vaultJson.getString("encryptionPublicKey"),
|
||||
credentialsCount = vaultJson.getInt("credentialsCount"),
|
||||
emailAddressList = emailList,
|
||||
privateEmailDomainList = privateList,
|
||||
publicEmailDomainList = publicList,
|
||||
createdAt = vaultJson.getString("createdAt"),
|
||||
updatedAt = vaultJson.getString("updatedAt"),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode vault response", e)
|
||||
throw Exception("Failed to decode vault response: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate vault response status.
|
||||
*/
|
||||
private fun validateVaultStatus(status: Int) {
|
||||
when (status) {
|
||||
0 -> return
|
||||
1 -> throw Exception("Vault merge required")
|
||||
2 -> throw Exception("Vault outdated")
|
||||
else -> throw Exception("Unknown vault status: $status")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vault Mutate Methods
|
||||
|
||||
/**
|
||||
* Prepare the vault for upload by assembling all metadata.
|
||||
* Returns a VaultUpload object ready to be sent to the server.
|
||||
*/
|
||||
private fun prepareVault(): VaultUpload {
|
||||
val currentRevision = getVaultRevisionNumber()
|
||||
|
||||
val encryptedDb = getEncryptedDatabase()
|
||||
|
||||
val username = getUsername()
|
||||
?: throw Exception("Username not found")
|
||||
|
||||
if (!isVaultUnlocked()) {
|
||||
throw Exception("Vault must be unlocked to prepare for upload")
|
||||
}
|
||||
|
||||
// Get all credentials
|
||||
val credentials = getAllCredentials()
|
||||
|
||||
// Get private email domains from metadata
|
||||
val metadata = getVaultMetadataObject()
|
||||
val privateEmailDomains = metadata?.privateEmailDomains ?: emptyList()
|
||||
|
||||
// Extract private email addresses from credentials
|
||||
val privateEmailAddresses = credentials
|
||||
.mapNotNull { it.alias?.email }
|
||||
.filter { email ->
|
||||
privateEmailDomains.any { domain ->
|
||||
email.lowercase().endsWith("@${domain.lowercase()}")
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
|
||||
// Get database version
|
||||
val dbVersion = getDatabaseVersion()
|
||||
|
||||
// Get app version
|
||||
val version = try {
|
||||
val context = storageProvider as? net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider
|
||||
val pm = context?.javaClass?.getDeclaredField("context")?.get(context) as? android.content.Context
|
||||
pm?.packageManager?.getPackageInfo(pm.packageName, 0)?.versionName ?: "0.0.0"
|
||||
} catch (e: Exception) {
|
||||
"0.0.0"
|
||||
}
|
||||
val baseVersion = version.split("-").firstOrNull() ?: "0.0.0"
|
||||
val client = "android-$baseVersion"
|
||||
|
||||
// Format dates in ISO 8601 format
|
||||
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US)
|
||||
dateFormat.timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
val now = dateFormat.format(java.util.Date())
|
||||
|
||||
return VaultUpload(
|
||||
blob = encryptedDb,
|
||||
createdAt = now,
|
||||
credentialsCount = credentials.size,
|
||||
currentRevisionNumber = currentRevision,
|
||||
emailAddressList = privateEmailAddresses,
|
||||
privateEmailDomainList = emptyList(), // Empty on purpose
|
||||
publicEmailDomainList = emptyList(), // Empty on purpose
|
||||
encryptionPublicKey = "", // Empty on purpose
|
||||
updatedAt = now,
|
||||
username = username,
|
||||
version = dbVersion,
|
||||
client = client,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a vault mutation operation.
|
||||
* This method uploads the vault to the server and updates the local revision number.
|
||||
*
|
||||
* @param webApiService The WebApiService to use for the request
|
||||
* @return True if successful
|
||||
*/
|
||||
suspend fun mutateVault(webApiService: net.aliasvault.app.webapi.WebApiService): Boolean {
|
||||
try {
|
||||
// Prepare vault for upload
|
||||
val vault = prepareVault()
|
||||
|
||||
// Convert to JSON
|
||||
val json = org.json.JSONObject()
|
||||
json.put("blob", vault.blob)
|
||||
json.put("createdAt", vault.createdAt)
|
||||
json.put("credentialsCount", vault.credentialsCount)
|
||||
json.put("currentRevisionNumber", vault.currentRevisionNumber)
|
||||
json.put("emailAddressList", org.json.JSONArray(vault.emailAddressList))
|
||||
json.put("privateEmailDomainList", org.json.JSONArray(vault.privateEmailDomainList))
|
||||
json.put("publicEmailDomainList", org.json.JSONArray(vault.publicEmailDomainList))
|
||||
json.put("encryptionPublicKey", vault.encryptionPublicKey)
|
||||
json.put("updatedAt", vault.updatedAt)
|
||||
json.put("username", vault.username)
|
||||
json.put("version", vault.version)
|
||||
json.put("client", vault.client)
|
||||
|
||||
// Upload to server
|
||||
val response = webApiService.executeRequest(
|
||||
method = "POST",
|
||||
endpoint = "Vault",
|
||||
body = json.toString(),
|
||||
headers = mapOf("Content-Type" to "application/json"),
|
||||
requiresAuth = true,
|
||||
)
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
Log.e(TAG, "Server rejected vault upload with status ${response.statusCode}")
|
||||
throw Exception("Server returned error: ${response.statusCode}")
|
||||
}
|
||||
|
||||
// Parse response
|
||||
val vaultResponse = try {
|
||||
val responseJson = org.json.JSONObject(response.body)
|
||||
VaultPostResponse(
|
||||
status = responseJson.getInt("status"),
|
||||
newRevisionNumber = responseJson.getInt("newRevisionNumber"),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse vault upload response", e)
|
||||
throw Exception("Failed to parse vault upload response: ${e.message}")
|
||||
}
|
||||
|
||||
// Check vault response status
|
||||
when (vaultResponse.status) {
|
||||
0 -> {
|
||||
// Success - update local revision number
|
||||
setVaultRevisionNumber(vaultResponse.newRevisionNumber)
|
||||
setOfflineMode(false)
|
||||
return true
|
||||
}
|
||||
1 -> throw Exception("Vault merge required")
|
||||
2 -> throw Exception("Vault is outdated, please sync first")
|
||||
else -> throw Exception("Failed to upload vault")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error mutating vault", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database version from the __EFMigrationsHistory table.
|
||||
*/
|
||||
private fun getDatabaseVersion(): String {
|
||||
val query = "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId DESC LIMIT 1"
|
||||
val results = executeQuery(query, emptyArray())
|
||||
|
||||
if (results.isEmpty()) {
|
||||
Log.d(TAG, "No migrations found in database, returning default version")
|
||||
return "0.0.0"
|
||||
}
|
||||
|
||||
val migrationId = results[0]["MigrationId"] as? String
|
||||
if (migrationId == null) {
|
||||
return "0.0.0"
|
||||
}
|
||||
|
||||
// Extract version using regex - matches patterns like "_1.4.1-"
|
||||
val versionRegex = Regex("_(\\d+\\.\\d+\\.\\d+)-")
|
||||
val match = versionRegex.find(migrationId)
|
||||
|
||||
return if (match != null && match.groupValues.size > 1) {
|
||||
match.groupValues[1]
|
||||
} else {
|
||||
Log.d(TAG, "Could not extract version from migration ID '$migrationId', returning default")
|
||||
"0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Models for Sync/Mutate
|
||||
|
||||
/**
|
||||
* Status response from Auth/status endpoint.
|
||||
*/
|
||||
private data class StatusResponse(
|
||||
val clientVersionSupported: Boolean,
|
||||
val serverVersion: String,
|
||||
val vaultRevision: Int,
|
||||
val srpSalt: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vault data from API.
|
||||
*/
|
||||
private data class VaultData(
|
||||
val username: String,
|
||||
val blob: String,
|
||||
val version: String,
|
||||
val currentRevisionNumber: Int,
|
||||
val encryptionPublicKey: String,
|
||||
val credentialsCount: Int,
|
||||
val emailAddressList: List<String>,
|
||||
val privateEmailDomainList: List<String>,
|
||||
val publicEmailDomainList: List<String>,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vault response from Vault GET endpoint.
|
||||
*/
|
||||
private data class VaultResponse(
|
||||
val status: Int,
|
||||
val vault: VaultData,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vault upload model that matches the API contract.
|
||||
*/
|
||||
private data class VaultUpload(
|
||||
val blob: String,
|
||||
val createdAt: String,
|
||||
val credentialsCount: Int,
|
||||
val currentRevisionNumber: Int,
|
||||
val emailAddressList: List<String>,
|
||||
val privateEmailDomainList: List<String>,
|
||||
val publicEmailDomainList: List<String>,
|
||||
val encryptionPublicKey: String,
|
||||
val updatedAt: String,
|
||||
val username: String,
|
||||
val version: String,
|
||||
val client: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Vault POST response from API.
|
||||
*/
|
||||
private data class VaultPostResponse(
|
||||
val status: Int,
|
||||
val newRevisionNumber: Int,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package net.aliasvault.app.vaultstore.passkey
|
||||
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
@@ -42,7 +39,7 @@ object PasskeyAuthenticator {
|
||||
/** AliasVault AAGUID: a11a5vau-9f32-4b8c-8c5d-2f7d13e8c942 */
|
||||
private val AAGUID = byteArrayOf(
|
||||
0xa1.toByte(), 0x1a, 0x5f, 0xaa.toByte(), 0x9f.toByte(), 0x32, 0x4b, 0x8c.toByte(),
|
||||
0x8c.toByte(), 0x5d, 0x2f, 0x7d, 0x13, 0xe8.toByte(), 0xc9.toByte(), 0x42
|
||||
0x8c.toByte(), 0x5d, 0x2f, 0x7d, 0x13, 0xe8.toByte(), 0xc9.toByte(), 0x42,
|
||||
)
|
||||
|
||||
// MARK: - Public API
|
||||
@@ -62,7 +59,7 @@ object PasskeyAuthenticator {
|
||||
userDisplayName: String?,
|
||||
uvPerformed: Boolean = false,
|
||||
enablePrf: Boolean = false,
|
||||
prfInputs: PrfInputs? = null
|
||||
prfInputs: PrfInputs? = null,
|
||||
): PasskeyCreationResult {
|
||||
// 1. Generate ES256 key pair
|
||||
val keyPairGenerator = KeyPairGenerator.getInstance("EC")
|
||||
@@ -90,7 +87,7 @@ object PasskeyAuthenticator {
|
||||
// 6. Build attested credential data
|
||||
val credIdLength = byteArrayOf(
|
||||
((credentialId.size shr 8) and 0xFF).toByte(),
|
||||
(credentialId.size and 0xFF).toByte()
|
||||
(credentialId.size and 0xFF).toByte(),
|
||||
)
|
||||
val attestedCredData = AAGUID + credIdLength + credentialId + coseKey
|
||||
|
||||
@@ -130,7 +127,7 @@ object PasskeyAuthenticator {
|
||||
userName = userName,
|
||||
userDisplayName = userDisplayName,
|
||||
prfSecret = prfSecret,
|
||||
prfResults = prfResults
|
||||
prfResults = prfResults,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,7 +145,7 @@ object PasskeyAuthenticator {
|
||||
userId: ByteArray?,
|
||||
uvPerformed: Boolean = false,
|
||||
prfInputs: PrfInputs? = null,
|
||||
prfSecret: ByteArray? = null
|
||||
prfSecret: ByteArray? = null,
|
||||
): PasskeyAssertionResult {
|
||||
// 1. RP ID hash
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
@@ -195,7 +192,7 @@ object PasskeyAuthenticator {
|
||||
authenticatorData = authenticatorData,
|
||||
signature = derSignature,
|
||||
userHandle = userId,
|
||||
prfResults = prfResults
|
||||
prfResults = prfResults,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -254,13 +251,13 @@ object PasskeyAuthenticator {
|
||||
val yBytes = w.affineY.toByteArray().dropLeadingZeros().padTo32Bytes()
|
||||
|
||||
return byteArrayOf(
|
||||
0xA5.toByte(), // map(5)
|
||||
0x01, 0x02, // 1: 2 (kty: EC2)
|
||||
0x03, 0x26, // 3: -7 (alg: ES256)
|
||||
0x20, 0x01, // -1: 1 (crv: P-256)
|
||||
0x21, 0x58, 0x20 // -2: bytes(32) for x
|
||||
0xA5.toByte(), // map(5)
|
||||
0x01, 0x02, // 1: 2 (kty: EC2)
|
||||
0x03, 0x26, // 3: -7 (alg: ES256)
|
||||
0x20, 0x01, // -1: 1 (crv: P-256)
|
||||
0x21, 0x58, 0x20, // -2: bytes(32) for x
|
||||
) + xBytes + byteArrayOf(
|
||||
0x22, 0x58, 0x20 // -3: bytes(32) for y
|
||||
0x22, 0x58, 0x20, // -3: bytes(32) for y
|
||||
) + yBytes
|
||||
}
|
||||
|
||||
@@ -270,12 +267,12 @@ object PasskeyAuthenticator {
|
||||
*/
|
||||
private fun buildAttestationObjectNone(authenticatorData: ByteArray): ByteArray {
|
||||
return byteArrayOf(
|
||||
0xA3.toByte() // map(3)
|
||||
0xA3.toByte(), // map(3)
|
||||
) +
|
||||
cborText("fmt") +
|
||||
cborText("none") +
|
||||
cborText("attStmt") +
|
||||
byteArrayOf(0xA0.toByte()) + // map(0) - empty attStmt
|
||||
byteArrayOf(0xA0.toByte()) + // map(0) - empty attStmt
|
||||
cborText("authData") +
|
||||
cborBytes(authenticatorData)
|
||||
}
|
||||
@@ -291,7 +288,7 @@ object PasskeyAuthenticator {
|
||||
else -> byteArrayOf(
|
||||
0x79,
|
||||
((bytes.size shr 8) and 0xFF).toByte(),
|
||||
(bytes.size and 0xFF).toByte()
|
||||
(bytes.size and 0xFF).toByte(),
|
||||
) + bytes
|
||||
}
|
||||
}
|
||||
@@ -306,7 +303,7 @@ object PasskeyAuthenticator {
|
||||
else -> byteArrayOf(
|
||||
0x59,
|
||||
((bytes.size shr 8) and 0xFF).toByte(),
|
||||
(bytes.size and 0xFF).toByte()
|
||||
(bytes.size and 0xFF).toByte(),
|
||||
) + bytes
|
||||
}
|
||||
}
|
||||
@@ -354,14 +351,14 @@ object PasskeyAuthenticator {
|
||||
data class PasskeyCreationResult(
|
||||
val credentialId: ByteArray,
|
||||
val attestationObject: ByteArray,
|
||||
val publicKey: ByteArray, // JWK format
|
||||
val publicKey: ByteArray, // JWK format
|
||||
val privateKey: ByteArray, // JWK format
|
||||
val rpId: String,
|
||||
val userId: ByteArray?,
|
||||
val userName: String?,
|
||||
val userDisplayName: String?,
|
||||
val prfSecret: ByteArray?,
|
||||
val prfResults: PrfResults?
|
||||
val prfResults: PrfResults?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -409,7 +406,7 @@ object PasskeyAuthenticator {
|
||||
val authenticatorData: ByteArray,
|
||||
val signature: ByteArray,
|
||||
val userHandle: ByteArray?,
|
||||
val prfResults: PrfResults?
|
||||
val prfResults: PrfResults?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -441,7 +438,7 @@ object PasskeyAuthenticator {
|
||||
|
||||
data class PrfInputs(
|
||||
val first: ByteArray?,
|
||||
val second: ByteArray?
|
||||
val second: ByteArray?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -470,7 +467,7 @@ object PasskeyAuthenticator {
|
||||
|
||||
data class PrfResults(
|
||||
val first: ByteArray,
|
||||
val second: ByteArray?
|
||||
val second: ByteArray?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package net.aliasvault.app.vaultstore.passkey
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* PasskeyHelper
|
||||
* -------------------------
|
||||
@@ -60,4 +56,16 @@ object PasskeyHelper {
|
||||
}.uppercase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert byte array to Base64URL encoding
|
||||
* Base64URL uses - instead of + and _ instead of /, and omits padding
|
||||
*/
|
||||
@JvmStatic
|
||||
fun bytesToBase64url(bytes: ByteArray): String {
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
return base64
|
||||
.replace('+', '-')
|
||||
.replace('/', '_')
|
||||
.trimEnd('=')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,4 +78,35 @@ class AndroidStorageProvider(private val context: Context) : StorageProvider {
|
||||
encryptedDatabaseFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setUsername(username: String) {
|
||||
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit {
|
||||
putString("username", username)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getUsername(): String? {
|
||||
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
|
||||
return sharedPreferences.getString("username", null)
|
||||
}
|
||||
|
||||
override fun clearUsername() {
|
||||
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit {
|
||||
remove("username")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setOfflineMode(isOffline: Boolean) {
|
||||
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit {
|
||||
putBoolean("offline_mode", isOffline)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getOfflineMode(): Boolean {
|
||||
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
|
||||
return sharedPreferences.getBoolean("offline_mode", false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,4 +71,33 @@ interface StorageProvider {
|
||||
* Clear all data from the storage provider.
|
||||
*/
|
||||
fun clearStorage()
|
||||
|
||||
/**
|
||||
* Set the username.
|
||||
* @param username The username to store
|
||||
*/
|
||||
fun setUsername(username: String)
|
||||
|
||||
/**
|
||||
* Get the username.
|
||||
* @return The username or null if not set
|
||||
*/
|
||||
fun getUsername(): String?
|
||||
|
||||
/**
|
||||
* Clear the username.
|
||||
*/
|
||||
fun clearUsername()
|
||||
|
||||
/**
|
||||
* Set offline mode flag.
|
||||
* @param isOffline Whether the app is in offline mode
|
||||
*/
|
||||
fun setOfflineMode(isOffline: Boolean)
|
||||
|
||||
/**
|
||||
* Get offline mode flag.
|
||||
* @return True if app is in offline mode, false otherwise
|
||||
*/
|
||||
fun getOfflineMode(): Boolean
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ class TestStorageProvider : StorageProvider {
|
||||
private var tempKeyDerivationParams = String()
|
||||
private var tempAuthMethods = "[]"
|
||||
private var tempAutoLockTimeout = defaultAutoLockTimeout
|
||||
private var username: String? = null
|
||||
private var offlineMode: Boolean = false
|
||||
|
||||
override fun getEncryptedDatabaseFile(): File = tempFile
|
||||
|
||||
@@ -59,4 +61,24 @@ class TestStorageProvider : StorageProvider {
|
||||
tempAuthMethods = "[]"
|
||||
tempAutoLockTimeout = defaultAutoLockTimeout
|
||||
}
|
||||
|
||||
override fun setUsername(username: String) {
|
||||
this.username = username
|
||||
}
|
||||
|
||||
override fun getUsername(): String? {
|
||||
return username
|
||||
}
|
||||
|
||||
override fun clearUsername() {
|
||||
username = null
|
||||
}
|
||||
|
||||
override fun setOfflineMode(isOffline: Boolean) {
|
||||
offlineMode = isOffline
|
||||
}
|
||||
|
||||
override fun getOfflineMode(): Boolean {
|
||||
return offlineMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package net.aliasvault.app.webapi
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Response object from a WebAPI request containing status code, body, and headers
|
||||
@@ -9,14 +18,12 @@ import android.util.Log
|
||||
data class WebApiResponse(
|
||||
val statusCode: Int,
|
||||
val body: String,
|
||||
val headers: Map<String, String>
|
||||
val headers: Map<String, String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Native Kotlin WebAPI service for making HTTP requests to the AliasVault server.
|
||||
* This service handles authentication, token refresh, and all HTTP operations.
|
||||
*
|
||||
* TODO: Implement all methods following the iOS Swift implementation pattern.
|
||||
*/
|
||||
class WebApiService(private val context: Context) {
|
||||
companion object {
|
||||
@@ -25,26 +32,25 @@ class WebApiService(private val context: Context) {
|
||||
private const val ACCESS_TOKEN_KEY = "accessToken"
|
||||
private const val REFRESH_TOKEN_KEY = "refreshToken"
|
||||
private const val DEFAULT_API_URL = "https://app.aliasvault.net/api"
|
||||
private const val SHARED_PREFS_NAME = "aliasvault"
|
||||
}
|
||||
|
||||
private val sharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// MARK: - Configuration Management
|
||||
|
||||
/**
|
||||
* Set the API URL
|
||||
*
|
||||
* TODO: Implement using SharedPreferences to store the API URL
|
||||
*/
|
||||
fun setApiUrl(url: String) {
|
||||
TODO("Implement setApiUrl: Store URL in SharedPreferences")
|
||||
sharedPreferences.edit().putString(API_URL_KEY, url).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API URL
|
||||
*
|
||||
* TODO: Implement using SharedPreferences to retrieve the API URL
|
||||
*/
|
||||
fun getApiUrl(): String {
|
||||
TODO("Implement getApiUrl: Retrieve URL from SharedPreferences or return DEFAULT_API_URL")
|
||||
return sharedPreferences.getString(API_URL_KEY, DEFAULT_API_URL) ?: DEFAULT_API_URL
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,128 +66,295 @@ class WebApiService(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Set both access and refresh tokens
|
||||
*
|
||||
* TODO: Implement using SharedPreferences to store both tokens
|
||||
*/
|
||||
fun setAuthTokens(accessToken: String, refreshToken: String) {
|
||||
TODO("Implement setAuthTokens: Store both tokens in SharedPreferences")
|
||||
sharedPreferences.edit()
|
||||
.putString(ACCESS_TOKEN_KEY, accessToken)
|
||||
.putString(REFRESH_TOKEN_KEY, refreshToken)
|
||||
.commit() // Use commit() instead of apply() to ensure synchronous write
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token
|
||||
*
|
||||
* TODO: Implement using SharedPreferences to retrieve the access token
|
||||
*/
|
||||
fun getAccessToken(): String? {
|
||||
TODO("Implement getAccessToken: Retrieve access token from SharedPreferences")
|
||||
return sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the refresh token
|
||||
*
|
||||
* TODO: Implement using SharedPreferences to retrieve the refresh token
|
||||
*/
|
||||
fun getRefreshToken(): String? {
|
||||
TODO("Implement getRefreshToken: Retrieve refresh token from SharedPreferences")
|
||||
private fun getRefreshToken(): String? {
|
||||
return sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear both access and refresh tokens
|
||||
*
|
||||
* TODO: Implement using SharedPreferences to remove both tokens
|
||||
*/
|
||||
fun clearAuthTokens() {
|
||||
TODO("Implement clearAuthTokens: Remove both tokens from SharedPreferences")
|
||||
sharedPreferences.edit()
|
||||
.remove(ACCESS_TOKEN_KEY)
|
||||
.remove(REFRESH_TOKEN_KEY)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// MARK: - HTTP Request Execution
|
||||
|
||||
/**
|
||||
* Execute a WebAPI request with support for authentication and token refresh
|
||||
*
|
||||
* TODO: Implement using OkHttp or HttpURLConnection to execute HTTP requests.
|
||||
* This should:
|
||||
* 1. Add Authorization header if requiresAuth is true
|
||||
* 2. Add X-AliasVault-Client header with app version
|
||||
* 3. Execute the request
|
||||
* 4. Handle 401 responses by calling refreshAccessToken() and retrying
|
||||
* 5. Return WebApiResponse with statusCode, body, and headers
|
||||
*
|
||||
* Reference the iOS Swift implementation in VaultStoreKit/WebApiService.swift
|
||||
*/
|
||||
suspend fun executeRequest(
|
||||
method: String,
|
||||
endpoint: String,
|
||||
body: String?,
|
||||
headers: Map<String, String>,
|
||||
requiresAuth: Boolean
|
||||
): WebApiResponse {
|
||||
TODO("Implement executeRequest: Use OkHttp/HttpURLConnection to execute HTTP request with auth support")
|
||||
requiresAuth: Boolean,
|
||||
): WebApiResponse = withContext(Dispatchers.IO) {
|
||||
val requestHeaders = headers.toMutableMap()
|
||||
|
||||
// Add authorization header if authentication is required AND not already provided
|
||||
if (requiresAuth && !requestHeaders.containsKey("Authorization")) {
|
||||
getAccessToken()?.let { accessToken ->
|
||||
requestHeaders["Authorization"] = "Bearer $accessToken"
|
||||
Log.d(TAG, "Added Authorization header from stored token")
|
||||
}
|
||||
} else if (requiresAuth && requestHeaders.containsKey("Authorization")) {
|
||||
Log.d(TAG, "Using provided Authorization header instead of stored token")
|
||||
}
|
||||
|
||||
// Add client version header
|
||||
requestHeaders["X-AliasVault-Client"] = getClientVersionHeader()
|
||||
|
||||
// Execute the request
|
||||
val response = executeRawRequest(
|
||||
method = method,
|
||||
endpoint = endpoint,
|
||||
body = body,
|
||||
headers = requestHeaders,
|
||||
)
|
||||
|
||||
// Handle 401 Unauthorized - attempt token refresh
|
||||
if (response.statusCode == 401 && requiresAuth) {
|
||||
Log.d(TAG, "Received 401, attempting token refresh")
|
||||
|
||||
val newToken = refreshAccessToken()
|
||||
if (newToken != null) {
|
||||
// Retry the request with the new token
|
||||
val retryHeaders = headers.toMutableMap()
|
||||
retryHeaders["Authorization"] = "Bearer $newToken"
|
||||
retryHeaders["X-AliasVault-Client"] = getClientVersionHeader()
|
||||
|
||||
val retryResponse = executeRawRequest(
|
||||
method = method,
|
||||
endpoint = endpoint,
|
||||
body = body,
|
||||
headers = retryHeaders,
|
||||
)
|
||||
|
||||
return@withContext retryResponse
|
||||
} else {
|
||||
Log.w(TAG, "Token refresh failed, returning 401")
|
||||
// Token refresh failed, return 401 response
|
||||
return@withContext response
|
||||
}
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw HTTP request without token refresh logic
|
||||
*
|
||||
* TODO: Implement the actual HTTP request execution using OkHttp or HttpURLConnection.
|
||||
* This should:
|
||||
* 1. Build the full URL from baseUrl + endpoint
|
||||
* 2. Create request with method, headers, and body
|
||||
* 3. Execute synchronously or with coroutines
|
||||
* 4. Parse response and return WebApiResponse
|
||||
*
|
||||
* Reference the iOS Swift implementation for the expected behavior.
|
||||
*/
|
||||
private suspend fun executeRawRequest(
|
||||
method: String,
|
||||
endpoint: String,
|
||||
body: String?,
|
||||
headers: Map<String, String>
|
||||
): WebApiResponse {
|
||||
TODO("Implement executeRawRequest: Execute HTTP request and return WebApiResponse")
|
||||
headers: Map<String, String>,
|
||||
): WebApiResponse = withContext(Dispatchers.IO) {
|
||||
val baseUrl = getBaseUrl()
|
||||
val urlString = "$baseUrl$endpoint"
|
||||
|
||||
Log.d(TAG, "Executing $method request to $urlString")
|
||||
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
val url = URL(urlString)
|
||||
connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = method.uppercase()
|
||||
connection.connectTimeout = 30000 // 30 seconds
|
||||
connection.readTimeout = 30000 // 30 seconds
|
||||
connection.doInput = true
|
||||
|
||||
// Set headers
|
||||
for ((key, value) in headers) {
|
||||
connection.setRequestProperty(key, value)
|
||||
}
|
||||
|
||||
// Set body if present
|
||||
if (body != null && (method.uppercase() == "POST" || method.uppercase() == "PUT" || method.uppercase() == "PATCH")) {
|
||||
connection.doOutput = true
|
||||
OutputStreamWriter(connection.outputStream).use { writer ->
|
||||
writer.write(body)
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Get response code
|
||||
val statusCode = connection.responseCode
|
||||
|
||||
// Read response body
|
||||
val responseBody = try {
|
||||
if (statusCode in 200..299) {
|
||||
BufferedReader(InputStreamReader(connection.inputStream)).use { reader ->
|
||||
reader.readText()
|
||||
}
|
||||
} else {
|
||||
BufferedReader(InputStreamReader(connection.errorStream ?: connection.inputStream)).use { reader ->
|
||||
reader.readText()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading response body", e)
|
||||
""
|
||||
}
|
||||
|
||||
// Extract headers
|
||||
val responseHeaders = mutableMapOf<String, String>()
|
||||
for ((key, values) in connection.headerFields) {
|
||||
if (key != null && values.isNotEmpty()) {
|
||||
responseHeaders[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Response status: $statusCode")
|
||||
|
||||
WebApiResponse(
|
||||
statusCode = statusCode,
|
||||
body = responseBody,
|
||||
headers = responseHeaders,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error executing request", e)
|
||||
throw e
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using the refresh token
|
||||
*
|
||||
* TODO: Implement token refresh logic:
|
||||
* 1. Get current access and refresh tokens
|
||||
* 2. Create JSON body with both tokens
|
||||
* 3. POST to Auth/refresh endpoint
|
||||
* 4. Parse response to get new tokens
|
||||
* 5. Store new tokens using setAuthTokens()
|
||||
* 6. Return new access token or null on failure
|
||||
*
|
||||
* Reference the iOS Swift implementation for the expected behavior.
|
||||
*/
|
||||
private suspend fun refreshAccessToken(): String? {
|
||||
TODO("Implement refreshAccessToken: Refresh tokens and return new access token")
|
||||
private suspend fun refreshAccessToken(): String? = withContext(Dispatchers.IO) {
|
||||
val refreshToken = getRefreshToken()
|
||||
val accessToken = getAccessToken()
|
||||
|
||||
if (refreshToken == null || accessToken == null) {
|
||||
Log.w(TAG, "No tokens available for refresh")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare refresh request body
|
||||
val refreshBody = JSONObject()
|
||||
refreshBody.put("token", accessToken)
|
||||
refreshBody.put("refreshToken", refreshToken)
|
||||
|
||||
val headers = mutableMapOf(
|
||||
"Content-Type" to "application/json",
|
||||
"X-Ignore-Failure" to "true",
|
||||
)
|
||||
headers["X-AliasVault-Client"] = getClientVersionHeader()
|
||||
|
||||
val response = executeRawRequest(
|
||||
method = "POST",
|
||||
endpoint = "Auth/refresh",
|
||||
body = refreshBody.toString(),
|
||||
headers = headers,
|
||||
)
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
Log.w(TAG, "Token refresh failed with status ${response.statusCode}")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
// Parse the response JSON
|
||||
val json = JSONObject(response.body)
|
||||
val newToken = if (json.has("token")) json.getString("token") else null
|
||||
val newRefreshToken = if (json.has("refreshToken")) json.getString("refreshToken") else null
|
||||
|
||||
if (newToken == null || newRefreshToken == null) {
|
||||
Log.w(TAG, "Token refresh response missing tokens")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
// Update stored tokens
|
||||
setAuthTokens(accessToken = newToken, refreshToken = newRefreshToken)
|
||||
|
||||
Log.d(TAG, "Token refresh successful")
|
||||
newToken
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Token refresh failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/**
|
||||
* Get the client version header value
|
||||
*
|
||||
* TODO: Implement to return "android-{version}" where version is from BuildConfig or PackageInfo
|
||||
*/
|
||||
private fun getClientVersionHeader(): String {
|
||||
TODO("Implement getClientVersionHeader: Return android-{version} header value")
|
||||
return try {
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val version = packageInfo.versionName ?: "0.0.0"
|
||||
val baseVersion = version.split("-").firstOrNull() ?: "0.0.0"
|
||||
"android-$baseVersion"
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.e(TAG, "Error getting package version", e)
|
||||
"android-0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token Revocation
|
||||
|
||||
/**
|
||||
* Revoke tokens via WebAPI (called when logging out)
|
||||
*
|
||||
* TODO: Implement token revocation logic:
|
||||
* 1. Get current access and refresh tokens
|
||||
* 2. Create JSON body with both tokens
|
||||
* 3. POST to Auth/revoke endpoint
|
||||
* 4. Always clear tokens at the end, even if revoke fails
|
||||
*
|
||||
* Reference the iOS Swift implementation for the expected behavior.
|
||||
*/
|
||||
suspend fun revokeTokens() {
|
||||
TODO("Implement revokeTokens: Revoke tokens via WebAPI and clear them from storage")
|
||||
suspend fun revokeTokens() = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Get tokens to revoke
|
||||
val refreshToken = getRefreshToken()
|
||||
val accessToken = getAccessToken()
|
||||
|
||||
if (refreshToken == null || accessToken == null) {
|
||||
// No tokens to revoke
|
||||
clearAuthTokens()
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// Prepare revoke request body
|
||||
val revokeBody = JSONObject()
|
||||
revokeBody.put("token", accessToken)
|
||||
revokeBody.put("refreshToken", refreshToken)
|
||||
|
||||
// Execute revoke request
|
||||
val response = executeRequest(
|
||||
method = "POST",
|
||||
endpoint = "Auth/revoke",
|
||||
body = revokeBody.toString(),
|
||||
headers = mapOf("Content-Type" to "application/json"),
|
||||
requiresAuth = false,
|
||||
)
|
||||
|
||||
// Log if revoke failed, but always clear tokens
|
||||
if (response.statusCode != 200) {
|
||||
Log.w(TAG, "Token revoke failed with status ${response.statusCode}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Token revoke error", e)
|
||||
}
|
||||
|
||||
// Always clear tokens, even if revoke fails
|
||||
clearAuthTokens()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +95,14 @@ public class WebApiService {
|
||||
) async throws -> WebApiResponse {
|
||||
var requestHeaders = headers
|
||||
|
||||
// Add authorization header if authentication is required
|
||||
if requiresAuth, let accessToken = getAccessToken() {
|
||||
requestHeaders["Authorization"] = "Bearer \(accessToken)"
|
||||
// Add authorization header if authentication is required AND not already provided
|
||||
if requiresAuth {
|
||||
if requestHeaders["Authorization"] == nil, let accessToken = getAccessToken() {
|
||||
requestHeaders["Authorization"] = "Bearer \(accessToken)"
|
||||
print("WebApiService: Added Authorization header from stored token")
|
||||
} else if requestHeaders["Authorization"] != nil {
|
||||
print("WebApiService: Using provided Authorization header instead of stored token")
|
||||
}
|
||||
}
|
||||
|
||||
// Add client version header
|
||||
|
||||
@@ -71,6 +71,7 @@ export class WebApiService {
|
||||
}
|
||||
|
||||
// Execute request through native layer with auth
|
||||
// Note: Native layer handles 401 responses and token refresh automatically
|
||||
const responseJson = await NativeVaultManager.executeWebApiRequest(
|
||||
method,
|
||||
endpoint,
|
||||
@@ -81,7 +82,7 @@ export class WebApiService {
|
||||
|
||||
const response: NativeWebApiResponse = JSON.parse(responseJson);
|
||||
|
||||
// If native layer returns 401, the session is truly expired
|
||||
// If native layer returns 401 session is truly expired
|
||||
// The native layer has already tried to refresh the token, so this is a final failure
|
||||
if (response.statusCode === 401) {
|
||||
logoutEventEmitter.emit('auth.errors.sessionExpired');
|
||||
|
||||
Reference in New Issue
Block a user