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.
|
- 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.
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.") },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user