From ddf34a2d30abfea40e7b65dbd0d6962e37dab1d3 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 17 Oct 2025 14:39:19 +0200 Subject: [PATCH] Fix first time login authorization header overwrite bug (#520) --- .../nativevaultmanager/NativeVaultManager.kt | 100 ++-- .../aliasvault/app/vaultstore/VaultStore.kt | 482 ++++++++++++++++++ .../passkey/PasskeyAuthenticator.kt | 45 +- .../app/vaultstore/passkey/PasskeyHelper.kt | 16 +- .../storageprovider/AndroidStorageProvider.kt | 31 ++ .../storageprovider/StorageProvider.kt | 29 ++ .../storageprovider/TestStorageProvider.kt | 22 + .../aliasvault/app/webapi/WebApiService.kt | 317 +++++++++--- .../Services/WebApiService.swift | 11 +- apps/mobile-app/utils/WebApiService.ts | 3 +- 10 files changed, 906 insertions(+), 150 deletions(-) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index 509e0b32d..c473cf9e7 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -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) { diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt index 8b1561d0c..4c466602b 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/VaultStore.kt @@ -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 { + 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() + val emailArray = vaultJson.getJSONArray("emailAddressList") + for (i in 0 until emailArray.length()) { + emailList.add(emailArray.getString(i)) + } + + val privateList = mutableListOf() + val privateArray = vaultJson.getJSONArray("privateEmailDomainList") + for (i in 0 until privateArray.length()) { + privateList.add(privateArray.getString(i)) + } + + val publicList = mutableListOf() + 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, + val privateEmailDomainList: List, + val publicEmailDomainList: List, + 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, + val privateEmailDomainList: List, + val publicEmailDomainList: List, + 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, + ) } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt index b13f614f2..2780611a2 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt @@ -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 diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt index 0a5d3d75f..7e8fc22c1 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt @@ -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('=') + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt index fbc21b1c2..2bd9160df 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/AndroidStorageProvider.kt @@ -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) + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt index 41b5f40d1..05a41ae89 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/StorageProvider.kt @@ -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 } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/TestStorageProvider.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/TestStorageProvider.kt index e2fa891e5..0a72362f0 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/TestStorageProvider.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/storageprovider/TestStorageProvider.kt @@ -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 + } } diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt index 4d2fdb7aa..79aa19f91 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/webapi/WebApiService.kt @@ -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 + val headers: Map, ) /** * 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, - 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 - ): WebApiResponse { - TODO("Implement executeRawRequest: Execute HTTP request and return WebApiResponse") + headers: Map, + ): 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() + 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() } } diff --git a/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift b/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift index f1a32f035..29422465a 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Services/WebApiService.swift @@ -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 diff --git a/apps/mobile-app/utils/WebApiService.ts b/apps/mobile-app/utils/WebApiService.ts index 093110752..51af2837e 100644 --- a/apps/mobile-app/utils/WebApiService.ts +++ b/apps/mobile-app/utils/WebApiService.ts @@ -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');