Android: add manual token authentication support
- Add manual token field to gateway settings with password masking and show/hide toggle - Implement token resolution priority: manual > global > null - Add error normalization to prevent token leakage in error messages - Store manual token in EncryptedSharedPreferences (AES256_GCM) - Add clear button to revert to global token - Modify connectManual() to use resolved token (manual if set, else global) Files modified: - SecurePrefs.kt: Storage layer with encrypted token persistence - NodeRuntime.kt: Token resolution and error normalization logic - MainViewModel.kt: ViewModel layer exposing token state - SettingsSheet.kt: UI layer with token field, masking, and controls - CHANGELOG.md: Document feature addition
This commit is contained in:
parent
72fea5e305
commit
b5c0a66fb7
@ -19,6 +19,7 @@ Status: unreleased.
|
||||
- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
|
||||
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
|
||||
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
|
||||
- Android: add manual token authentication support to gateway settings with encrypted storage and error normalization.
|
||||
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
||||
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
|
||||
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
|
||||
|
||||
@ -51,6 +51,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val manualTls: StateFlow<Boolean> = runtime.manualTls
|
||||
val manualToken: StateFlow<String> = runtime.manualToken
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
@ -104,6 +105,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setManualToken(value: String) {
|
||||
runtime.setManualToken(value)
|
||||
}
|
||||
|
||||
fun clearManualToken() {
|
||||
runtime.clearManualToken()
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
@ -242,7 +242,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private fun updateStatus() {
|
||||
_isConnected.value = operatorConnected
|
||||
_statusText.value =
|
||||
val rawStatus =
|
||||
when {
|
||||
operatorConnected && nodeConnected -> "Connected"
|
||||
operatorConnected && !nodeConnected -> "Connected (node offline)"
|
||||
@ -250,6 +250,7 @@ class NodeRuntime(context: Context) {
|
||||
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
|
||||
else -> nodeStatusText
|
||||
}
|
||||
_statusText.value = normalizeGatewayError(rawStatus)
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
@ -257,6 +258,48 @@ class NodeRuntime(context: Context) {
|
||||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
}
|
||||
|
||||
private fun resolveToken(): String? {
|
||||
val manual = prefs.manualToken.value.trim()
|
||||
return if (manual.isNotEmpty()) manual else prefs.loadGatewayToken()
|
||||
}
|
||||
|
||||
private fun normalizeGatewayError(message: String): String {
|
||||
if (message == "Connected" || message.startsWith("Connected (")) {
|
||||
return message
|
||||
}
|
||||
|
||||
val lower = message.lowercase()
|
||||
|
||||
if (("token" in lower || "auth" in lower || "unauthorized" in lower) &&
|
||||
("invalid" in lower || "failed" in lower || "required" in lower || "missing" in lower)) {
|
||||
return "Authentication failed. Check token in Settings."
|
||||
}
|
||||
|
||||
if (message.startsWith("Gateway closed:")) {
|
||||
val reason = message.substringAfter("Gateway closed:").trim()
|
||||
return if (reason.length > 100 || reason.contains("token=") || reason.contains("auth=")) {
|
||||
"Connection closed by gateway"
|
||||
} else {
|
||||
"Gateway closed: $reason"
|
||||
}
|
||||
}
|
||||
|
||||
if (message.startsWith("Gateway error:")) {
|
||||
val error = message.substringAfter("Gateway error:").trim()
|
||||
return if (error.length > 100) {
|
||||
"Connection error"
|
||||
} else {
|
||||
"Gateway error: $error"
|
||||
}
|
||||
}
|
||||
|
||||
return if (message.length > 150) {
|
||||
"Connection failed"
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
@ -284,6 +327,7 @@ class NodeRuntime(context: Context) {
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
||||
val manualToken: StateFlow<String> = prefs.manualToken
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
@ -428,6 +472,14 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setManualToken(value: String) {
|
||||
prefs.setManualToken(value)
|
||||
}
|
||||
|
||||
fun clearManualToken() {
|
||||
prefs.clearManualToken()
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
@ -604,7 +656,19 @@ class NodeRuntime(context: Context) {
|
||||
_statusText.value = "Failed: invalid manual host/port"
|
||||
return
|
||||
}
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
|
||||
val endpoint = GatewayEndpoint.manual(host = host, port = port)
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
|
||||
val token = resolveToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
|
||||
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
|
||||
@ -74,6 +74,10 @@ class SecurePrefs(context: Context) {
|
||||
MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true))
|
||||
val manualTls: StateFlow<Boolean> = _manualTls
|
||||
|
||||
private val _manualToken =
|
||||
MutableStateFlow(readStringWithMigration("gateway.manual.token", null, ""))
|
||||
val manualToken: StateFlow<String> = _manualToken
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(
|
||||
readStringWithMigration(
|
||||
@ -150,6 +154,21 @@ class SecurePrefs(context: Context) {
|
||||
_manualTls.value = value
|
||||
}
|
||||
|
||||
fun setManualToken(value: String) {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
clearManualToken()
|
||||
} else {
|
||||
prefs.edit { putString("gateway.manual.token", trimmed) }
|
||||
_manualToken.value = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
fun clearManualToken() {
|
||||
prefs.edit { remove("gateway.manual.token") }
|
||||
_manualToken.value = ""
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
|
||||
@ -31,11 +31,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@ -55,6 +59,9 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@ -82,6 +89,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
val manualTls by viewModel.manualTls.collectAsState()
|
||||
val manualToken by viewModel.manualToken.collectAsState()
|
||||
var tokenVisible by remember { mutableStateOf(false) }
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
@ -403,6 +412,54 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = manualToken,
|
||||
onValueChange = viewModel::setManualToken,
|
||||
label = { Text("Token (optional)") },
|
||||
supportingText = {
|
||||
Text(
|
||||
if (manualToken.trim().isNotEmpty()) {
|
||||
"Using manual token"
|
||||
} else {
|
||||
"Leave blank to use global token"
|
||||
}
|
||||
)
|
||||
},
|
||||
visualTransformation = if (tokenVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
trailingIcon = {
|
||||
Row {
|
||||
if (manualToken.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.clearManualToken()
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear token")
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { tokenVisible = !tokenVisible }
|
||||
) {
|
||||
Icon(
|
||||
if (tokenVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (tokenVisible) "Hide token" else "Show token"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
singleLine = true,
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Require TLS") },
|
||||
supportingContent = { Text("Pin the gateway certificate on first connect.") },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user