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:
yishai 2026-01-26 16:18:51 +02:00
parent 72fea5e305
commit b5c0a66fb7
5 changed files with 152 additions and 2 deletions

View File

@ -19,6 +19,7 @@ Status: unreleased.
- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN. - 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: 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) - 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. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. - Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.

View File

@ -51,6 +51,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualHost: StateFlow<String> = runtime.manualHost val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls val manualTls: StateFlow<Boolean> = runtime.manualTls
val manualToken: StateFlow<String> = runtime.manualToken
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
@ -104,6 +105,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualTls(value) runtime.setManualTls(value)
} }
fun setManualToken(value: String) {
runtime.setManualToken(value)
}
fun clearManualToken() {
runtime.clearManualToken()
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value) runtime.setCanvasDebugStatusEnabled(value)
} }

View File

@ -242,7 +242,7 @@ class NodeRuntime(context: Context) {
private fun updateStatus() { private fun updateStatus() {
_isConnected.value = operatorConnected _isConnected.value = operatorConnected
_statusText.value = val rawStatus =
when { when {
operatorConnected && nodeConnected -> "Connected" operatorConnected && nodeConnected -> "Connected"
operatorConnected && !nodeConnected -> "Connected (node offline)" operatorConnected && !nodeConnected -> "Connected (node offline)"
@ -250,6 +250,7 @@ class NodeRuntime(context: Context) {
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
else -> nodeStatusText else -> nodeStatusText
} }
_statusText.value = normalizeGatewayError(rawStatus)
} }
private fun resolveMainSessionKey(): String { private fun resolveMainSessionKey(): String {
@ -257,6 +258,48 @@ class NodeRuntime(context: Context) {
return if (trimmed.isEmpty()) "main" else trimmed 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() { private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty() val current = canvas.currentUrl()?.trim().orEmpty()
@ -284,6 +327,7 @@ class NodeRuntime(context: Context) {
val manualHost: StateFlow<String> = prefs.manualHost val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls val manualTls: StateFlow<Boolean> = prefs.manualTls
val manualToken: StateFlow<String> = prefs.manualToken
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
@ -428,6 +472,14 @@ class NodeRuntime(context: Context) {
prefs.setManualTls(value) prefs.setManualTls(value)
} }
fun setManualToken(value: String) {
prefs.setManualToken(value)
}
fun clearManualToken() {
prefs.clearManualToken()
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value) prefs.setCanvasDebugStatusEnabled(value)
} }
@ -604,7 +656,19 @@ class NodeRuntime(context: Context) {
_statusText.value = "Failed: invalid manual host/port" _statusText.value = "Failed: invalid manual host/port"
return 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() { fun disconnect() {

View File

@ -74,6 +74,10 @@ class SecurePrefs(context: Context) {
MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true)) MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true))
val manualTls: StateFlow<Boolean> = _manualTls val manualTls: StateFlow<Boolean> = _manualTls
private val _manualToken =
MutableStateFlow(readStringWithMigration("gateway.manual.token", null, ""))
val manualToken: StateFlow<String> = _manualToken
private val _lastDiscoveredStableId = private val _lastDiscoveredStableId =
MutableStateFlow( MutableStateFlow(
readStringWithMigration( readStringWithMigration(
@ -150,6 +154,21 @@ class SecurePrefs(context: Context) {
_manualTls.value = value _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) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value _canvasDebugStatusEnabled.value = value

View File

@ -31,11 +31,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons 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.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore 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.Button
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField 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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -82,6 +89,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualHost by viewModel.manualHost.collectAsState() val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState() val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.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 canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState() val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState() val serverName by viewModel.serverName.collectAsState()
@ -403,6 +412,54 @@ fun SettingsSheet(viewModel: MainViewModel) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled, 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( ListItem(
headlineContent = { Text("Require TLS") }, headlineContent = { Text("Require TLS") },
supportingContent = { Text("Pin the gateway certificate on first connect.") }, supportingContent = { Text("Pin the gateway certificate on first connect.") },