From 20b136b84e20381400d011658dec92272e82c10e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 15:24:52 +0000 Subject: [PATCH 1/4] Add OAuth device sign-in to the mobile tunnel client The app can now pair with the gateway via SealGate's OAuth 2.0 device- authorization flow (RFC 8628, with PKCE) instead of only a pasted API key - the same flow the desktop daemon uses. Tapping "Sign in with SealGate" shows a user code, opens the dashboard's device-approval page, polls until approved, and stores the scoped `ewc_` credential plus the backend-issued device id. - DeviceAuthClient: Android-free device-flow client (PKCE, code request, token poll with slow_down/interval handling) so its logic is JVM-unit-testable. - GatewayUrls: derive the HTTPS API origin from the ws/wss gateway URL. - TunnelConfig/TunnelSettings/DeviceIdentityStore/TunnelService: carry the backend-issued device id so the tunnel's X-SealGate-Device-Id matches the id bound to the credential; persist the client installation id to rotate the same device row on re-auth. - MainActivity + layout + strings: "Sign in with SealGate" button and approval dialog; API key remains an advanced fallback. Manifest so the browser launch resolves on Android 11+. - Tests: PKCE format, gateway-URL derivation, poll-action decisions, device-id config behaviour. Backend side (mobile client profile) ships in edison-watch; see dev-docs/architecture/mobile-hardware-gateway-design.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8 --- README.md | 17 +- app/src/main/AndroidManifest.xml | 10 + .../java/ai/sealgate/stdiod/MainActivity.kt | 140 ++++++++- .../java/ai/sealgate/stdiod/TunnelConfig.kt | 14 +- .../java/ai/sealgate/stdiod/TunnelService.kt | 4 +- .../java/ai/sealgate/stdiod/TunnelSettings.kt | 48 ++- .../stdiod/tunnel/DeviceAuthClient.kt | 279 ++++++++++++++++++ .../stdiod/tunnel/DeviceIdentityStore.kt | 36 ++- app/src/main/res/layout/activity_main.xml | 26 ++ .../main/res/layout/dialog_device_sign_in.xml | 68 +++++ app/src/main/res/values/strings.xml | 15 +- .../ai/sealgate/stdiod/TunnelConfigTest.kt | 16 + .../stdiod/tunnel/DeviceAuthClientTest.kt | 100 +++++++ 13 files changed, 752 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt create mode 100644 app/src/main/res/layout/dialog_device_sign_in.xml create mode 100644 app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt diff --git a/README.md b/README.md index fbb9273..9bceca6 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,20 @@ per-file limit, and 32 MiB total virtual filesystem limit. ./gradlew testDebugUnitTest # run JVM unit tests ./gradlew installDebug # install on a connected device/emulator ``` -2. Run the app, fill in the gateway WebSocket URL and your SealGate API key - (from the dashboard), and tap **Start tunnel**. Settings persist across - restarts; the ongoing notification shows the live connection state. +2. Run the app, confirm (or edit) the gateway WebSocket URL, and tap + **Sign in with SealGate**. The app runs the OAuth 2.0 device-authorization + flow (RFC 8628, with PKCE): it shows a short code and opens the dashboard's + device page, where you approve the phone with one click. On approval the app + receives a scoped `ewc_` tunnel credential (never a human API key) bound to a + backend-issued device id, stores it, and starts the tunnel. Settings persist + across restarts; the ongoing notification shows the live connection state. + + Pasting a SealGate API key under **Or connect with an API key** and tapping + **Connect** still works as an alternative to signing in. + + Sign-in reuses the shared device-auth flow the desktop daemon uses, under a + dedicated `mobile` client id; the backend side lives in `edison-watch` + (`src/api/v1/routes/device_auth.py`, `dev-docs/architecture/mobile-hardware-gateway-design.md`). While the tunnel is running, pull down from the top of the app screen to close the current socket and reconnect immediately with the saved settings. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8355278..a8dd5a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,16 @@ android:name="android.hardware.usb.host" android:required="false" /> + + + + + + + + (R.id.signInUri).text = grant.verificationUri + view.findViewById(R.id.signInCode).text = grant.userCode + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.sign_in_dialog_title) + .setView(view) + .setPositiveButton(R.string.action_open_dashboard, null) + .setNegativeButton(R.string.action_cancel) { _, _ -> signInJob?.cancel() } + .setOnCancelListener { signInJob?.cancel() } + .create() + // Keep the dialog open when "Open dashboard" is tapped so the user can + // return and watch it flip to connected. + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { + openUri(grant.verificationUriComplete) + } + } + dialog.show() + return dialog + } + + private fun showSignInError(message: String?) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.sign_in_failed_title) + .setMessage(message ?: getString(R.string.sign_in_failed_title)) + .setPositiveButton(R.string.action_close, null) + .show() + } + + private fun openUri(uri: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + this, + getString(R.string.sign_in_no_browser, uri), + Toast.LENGTH_LONG, + ).show() + } + } + companion object { private const val REFRESH_INDICATOR_MILLIS = 650L } diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt index eda65a2..75733cf 100644 --- a/app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt +++ b/app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt @@ -10,8 +10,19 @@ package ai.sealgate.stdiod data class TunnelConfig( /** Gateway WebSocket endpoint, e.g. `wss://gateway.sealgate.ai/tunnel`. */ val gatewayUrl: String, - /** Bearer token used to authenticate the tunnel with the gateway. */ + /** + * Bearer token used to authenticate the tunnel with the gateway. Either a + * SealGate API key or, after OAuth sign-in, a scoped `ewc_` client + * credential. + */ val authToken: String, + /** + * Backend-issued device id (`ewd_...`) from OAuth sign-in. The gateway + * requires the tunnel's `X-SealGate-Device-Id` to equal the id bound to an + * `ewc_` credential, so it must be sent verbatim. Null in API-key mode, + * where the locally minted device id is used instead. + */ + val deviceId: String? = null, ) { /** True when the config is complete enough to attempt a connection. */ fun isValid(): Boolean = @@ -21,5 +32,6 @@ data class TunnelConfig( companion object { const val EXTRA_GATEWAY_URL = "ai.sealgate.stdiod.extra.GATEWAY_URL" const val EXTRA_AUTH_TOKEN = "ai.sealgate.stdiod.extra.AUTH_TOKEN" + const val EXTRA_DEVICE_ID = "ai.sealgate.stdiod.extra.DEVICE_ID" } } diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt index 66fd45a..ec591e2 100644 --- a/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt +++ b/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt @@ -82,6 +82,7 @@ class TunnelService : LifecycleService() { TunnelConfig( gatewayUrl = it.getStringExtra(TunnelConfig.EXTRA_GATEWAY_URL).orEmpty(), authToken = it.getStringExtra(TunnelConfig.EXTRA_AUTH_TOKEN).orEmpty(), + deviceId = it.getStringExtra(TunnelConfig.EXTRA_DEVICE_ID)?.ifBlank { null }, ) } @@ -119,7 +120,7 @@ class TunnelService : LifecycleService() { private fun startClient(config: TunnelConfig) { Log.i(TAG, "Tunnel starting -> ${config.gatewayUrl}") - val identity = DeviceIdentityStore.load(this, BuildConfig.VERSION_NAME) + val identity = DeviceIdentityStore.load(this, BuildConfig.VERSION_NAME, config.deviceId) val capabilityModules = buildList { add(DeviceInfoModule(AndroidDeviceInfo)) add(BatteryModule(AndroidBatterySource(this@TunnelService))) @@ -329,6 +330,7 @@ class TunnelService : LifecycleService() { val intent = Intent(context, TunnelService::class.java).apply { putExtra(TunnelConfig.EXTRA_GATEWAY_URL, config.gatewayUrl) putExtra(TunnelConfig.EXTRA_AUTH_TOKEN, config.authToken) + putExtra(TunnelConfig.EXTRA_DEVICE_ID, config.deviceId) } context.startForegroundService(intent) } diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt index d3c4c88..322f263 100644 --- a/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt +++ b/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt @@ -4,9 +4,15 @@ import android.content.Context /** * Persisted connection settings, so the tunnel can be configured from the - * screen instead of by editing code. SharedPreferences is enough for two - * strings; the API key never leaves the device except as the tunnel's + * screen instead of by editing code. SharedPreferences is enough for a few + * strings; the credential never leaves the device except as the tunnel's * bearer header. + * + * Two auth modes share this store: a pasted API key, or an OAuth `ewc_` client + * credential from device sign-in. OAuth additionally persists the + * backend-issued device id (sent as the tunnel's device id) and the client + * installation id (passed back on re-authentication so the same device row is + * rotated instead of a new one being created). */ object TunnelSettings { @@ -23,14 +29,50 @@ object TunnelSettings { return TunnelConfig( gatewayUrl = prefs.getString(KEY_GATEWAY_URL, null) ?: DEFAULT_GATEWAY_URL, authToken = prefs.getString(KEY_AUTH_TOKEN, null).orEmpty(), + deviceId = prefs.getString(KEY_DEVICE_ID, null)?.ifBlank { null }, ) } + /** The client installation id from the last OAuth sign-in, if any. */ + fun clientInstallationId(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_CLIENT_INSTALLATION_ID, null) + ?.ifBlank { null } + fun save(context: Context, config: TunnelConfig) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit() .putString(KEY_GATEWAY_URL, config.gatewayUrl) .putString(KEY_AUTH_TOKEN, config.authToken) + .apply { + if (config.deviceId.isNullOrBlank()) { + remove(KEY_DEVICE_ID) + } else { + putString(KEY_DEVICE_ID, config.deviceId) + } + } + .remove(LEGACY_KEY_BASH_MODE) + .apply() + } + + /** + * Persist the outcome of an OAuth sign-in: the gateway used, the `ewc_` + * credential, the backend device id, and the client installation id for + * later re-authentication. + */ + fun saveOAuthResult( + context: Context, + gatewayUrl: String, + accessToken: String, + deviceId: String, + clientInstallationId: String, + ) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_GATEWAY_URL, gatewayUrl) + .putString(KEY_AUTH_TOKEN, accessToken) + .putString(KEY_DEVICE_ID, deviceId) + .putString(KEY_CLIENT_INSTALLATION_ID, clientInstallationId) .remove(LEGACY_KEY_BASH_MODE) .apply() } @@ -38,5 +80,7 @@ object TunnelSettings { private const val PREFS = "tunnel_settings" private const val KEY_GATEWAY_URL = "gateway_url" private const val KEY_AUTH_TOKEN = "auth_token" + private const val KEY_DEVICE_ID = "device_id" + private const val KEY_CLIENT_INSTALLATION_ID = "client_installation_id" private const val LEGACY_KEY_BASH_MODE = "bash_mode" } diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt new file mode 100644 index 0000000..e50a48d --- /dev/null +++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt @@ -0,0 +1,279 @@ +package ai.sealgate.stdiod.tunnel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +/** + * Client for SealGate's OAuth 2.0 Device Authorization Grant (RFC 8628, with + * PKCE), the same flow the desktop daemon uses. The phone requests a short + * user code, the human approves it in the dashboard, and the phone polls until + * it receives a scoped `ewc_` tunnel credential bound to a backend-minted + * device id. See `src/api/v1/routes/device_auth.py` in edison-watch and + * `dev-docs/architecture/mobile-hardware-gateway-design.md`. + * + * This class deliberately avoids Android framework types so its logic is + * exercised by plain JVM unit tests (there is no Android SDK in CI's unit + * test task). + */ +class DeviceAuthClient( + private val apiBaseUrl: String, + private val httpClient: OkHttpClient = OkHttpClient(), + private val json: Json = defaultJson, +) { + /** The `ewc_` credential and identity handed back after a successful pairing. */ + data class PairingResult( + val accessToken: String, + val deviceId: String, + val clientInstallationId: String, + val scope: List, + val userId: String, + val orgId: String, + ) + + /** A user-facing failure with a message safe to show verbatim. */ + class DeviceAuthException(message: String) : Exception(message) + + /** + * Ask the backend for a device code. Returns the pending grant plus the + * PKCE verifier the caller must keep to redeem it in [pollForToken]. + */ + suspend fun requestDeviceCode( + deviceLabel: String, + clientVersion: String, + clientInstallationId: String?, + ): PendingGrant { + val pkce = Pkce.generate() + val body = buildJsonObject { + put("client_id", CLIENT_ID) + putJsonArray("scope") { add(SCOPE_TUNNEL_CONNECT) } + put("code_challenge", pkce.challenge) + put("code_challenge_method", "S256") + put("device_label", deviceLabel) + put("platform", PLATFORM_ANDROID) + put("client_version", clientVersion) + if (clientInstallationId != null) put("client_installation_id", clientInstallationId) + }.toString() + val response = post("$apiBaseUrl$PATH_CODE", body) + if (!response.isSuccessful) { + throw DeviceAuthException(describeCodeError(response.code, response.body)) + } + val code = try { + json.decodeFromString(response.body) + } catch (e: Exception) { + throw DeviceAuthException("The gateway returned an unexpected sign-in response.") + } + return PendingGrant( + deviceCode = code.deviceCode, + userCode = code.userCode, + verificationUri = code.verificationUri, + verificationUriComplete = code.verificationUriComplete, + expiresInSeconds = code.expiresIn, + intervalSeconds = code.interval, + codeVerifier = pkce.verifier, + ) + } + + /** + * Poll the token endpoint until the grant is approved, denied, or expires. + * Honours the server's `interval` and `slow_down` back-pressure. + */ + suspend fun pollForToken(grant: PendingGrant): PairingResult { + var intervalSeconds = grant.intervalSeconds.coerceAtLeast(1) + val deadlineMillis = System.currentTimeMillis() + grant.expiresInSeconds.toLong() * 1000L + val body = buildJsonObject { + put("client_id", CLIENT_ID) + put("device_code", grant.deviceCode) + put("code_verifier", grant.codeVerifier) + }.toString() + while (true) { + delay(intervalSeconds.toLong() * 1000L) + if (System.currentTimeMillis() >= deadlineMillis) { + throw DeviceAuthException(EXPIRED_MESSAGE) + } + val response = post("$apiBaseUrl$PATH_TOKEN", body) + if (response.isSuccessful) { + val token = try { + json.decodeFromString(response.body) + } catch (e: Exception) { + throw DeviceAuthException("The gateway returned an unexpected token response.") + } + return PairingResult( + accessToken = token.accessToken, + deviceId = token.deviceId, + clientInstallationId = token.clientInstallationId, + scope = token.scope, + userId = token.userId, + orgId = token.orgId, + ) + } + when (val action = pollActionFor(response.code, response.body, intervalSeconds)) { + is PollAction.Retry -> intervalSeconds = action.intervalSeconds + is PollAction.Fail -> throw DeviceAuthException(action.message) + } + } + } + + private suspend fun post(url: String, jsonBody: String): HttpResult = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(url) + .header("Accept", "application/json") + .post(jsonBody.toRequestBody(JSON_MEDIA_TYPE)) + .build() + try { + httpClient.newCall(request).execute().use { raw -> + HttpResult(raw.code, raw.body?.string().orEmpty()) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw DeviceAuthException( + "Could not reach the SealGate gateway. Check the URL and your connection.", + ) + } + } + + /** Decode a token-endpoint error and decide what to do next. Pure, so it is unit-tested. */ + internal fun pollActionFor(statusCode: Int, body: String, intervalSeconds: Int): PollAction = + when (val error = parseErrorCode(body)) { + "authorization_pending" -> PollAction.Retry(intervalSeconds) + // The server widens its own interval on slow_down; mirror that locally. + "slow_down" -> PollAction.Retry(intervalSeconds + SLOW_DOWN_BACKOFF_SECONDS) + "access_denied" -> PollAction.Fail("Sign-in was denied in the dashboard.") + "expired_token" -> PollAction.Fail(EXPIRED_MESSAGE) + "invalid_scope" -> PollAction.Fail("This app requested a scope the gateway does not allow.") + "invalid_client" -> PollAction.Fail("The gateway does not recognise this app as a client.") + else -> PollAction.Fail( + "Sign-in failed (${error ?: "HTTP $statusCode"}). Please try again.", + ) + } + + private fun describeCodeError(statusCode: Int, body: String): String = + when (parseErrorCode(body)) { + "invalid_client" -> + "This gateway does not support mobile sign-in yet. " + + "Update the SealGate backend or use an API key." + "invalid_scope" -> "This app requested a scope the gateway does not allow." + else -> "Could not start sign-in (HTTP $statusCode). Check the gateway URL." + } + + private fun parseErrorCode(body: String): String? = + try { + json.decodeFromString(body).error + } catch (e: Exception) { + null + } + + sealed interface PollAction { + data class Retry(val intervalSeconds: Int) : PollAction + data class Fail(val message: String) : PollAction + } + + private data class HttpResult(val code: Int, val body: String) { + val isSuccessful: Boolean get() = code in 200..299 + } + + companion object { + const val CLIENT_ID = "mobile" + const val SCOPE_TUNNEL_CONNECT = "tunnel:connect" + const val PLATFORM_ANDROID = "android" + const val PATH_CODE = "/api/v1/auth/device/code" + const val PATH_TOKEN = "/api/v1/auth/device/token" + private const val SLOW_DOWN_BACKOFF_SECONDS = 5 + private const val EXPIRED_MESSAGE = "The sign-in code expired. Please try again." + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + + val defaultJson = Json { ignoreUnknownKeys = true } + } +} + +/** A device grant awaiting human approval; carries the PKCE verifier to redeem it. */ +data class PendingGrant( + val deviceCode: String, + val userCode: String, + val verificationUri: String, + val verificationUriComplete: String, + val expiresInSeconds: Int, + val intervalSeconds: Int, + val codeVerifier: String, +) + +/** PKCE (RFC 7636) verifier/challenge pair generation. */ +object Pkce { + data class Pair(val verifier: String, val challenge: String) + + fun generate(random: SecureRandom = SecureRandom()): Pair { + // 32 random bytes -> 43-char base64url verifier, within the 43..128 the + // backend accepts (device_auth.py DeviceTokenRequest.code_verifier). + val verifierBytes = ByteArray(32) + random.nextBytes(verifierBytes) + val verifier = base64Url(verifierBytes) + val digest = MessageDigest.getInstance("SHA-256") + .digest(verifier.toByteArray(Charsets.US_ASCII)) + return Pair(verifier, base64Url(digest)) + } + + private fun base64Url(bytes: ByteArray): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) +} + +/** Turns a tunnel WebSocket URL into the HTTP origin its REST API is served from. */ +object GatewayUrls { + /** + * `wss://host[:port]/api/v1/stdio-tunnel/ws` -> `https://host[:port]`. + * Returns null when [wsUrl] is not a ws/wss URL with a host. + */ + fun apiBaseFromWs(wsUrl: String): String? { + val trimmed = wsUrl.trim() + val scheme = when { + trimmed.startsWith("wss://", ignoreCase = true) -> "https" + trimmed.startsWith("ws://", ignoreCase = true) -> "http" + else -> return null + } + val afterScheme = trimmed.substringAfter("://", "") + // Authority ends at the first '/', '?' or '#'. + val authority = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#') + if (authority.isBlank() || authority.startsWith(":")) return null + return "$scheme://$authority" + } +} + +@Serializable +private data class DeviceCodeResponse( + @SerialName("device_code") val deviceCode: String, + @SerialName("user_code") val userCode: String, + @SerialName("verification_uri") val verificationUri: String, + @SerialName("verification_uri_complete") val verificationUriComplete: String, + @SerialName("expires_in") val expiresIn: Int, + val interval: Int, +) + +@Serializable +private data class DeviceTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("client_installation_id") val clientInstallationId: String, + @SerialName("device_id") val deviceId: String, + val scope: List, + @SerialName("user_id") val userId: String, + @SerialName("org_id") val orgId: String, + @SerialName("api_key") val apiKey: String? = null, +) + +@Serializable +private data class OAuthError(val error: String? = null) diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt index 5be8d15..f789ec8 100644 --- a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt +++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt @@ -14,28 +14,46 @@ import java.util.UUID */ object DeviceIdentityStore { - fun load(context: Context, clientVersion: String): DeviceIdentity { + /** + * @param preferredDeviceId when non-blank (an OAuth `ewd_...` id), it is + * authoritative: the gateway requires the tunnel's device id to match the + * one bound to the `ewc_` credential. It is persisted so it stays stable + * across restarts. When null/blank (API-key mode), a locally minted UUID + * is used, matching the desktop daemon's config-dir identity behaviour. + */ + fun load( + context: Context, + clientVersion: String, + preferredDeviceId: String? = null, + ): DeviceIdentity { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - var deviceId = prefs.getString(KEY_DEVICE_ID, null) + var deviceId = preferredDeviceId?.ifBlank { null } ?: prefs.getString(KEY_DEVICE_ID, null) if (deviceId == null) { deviceId = UUID.randomUUID().toString() + } + if (deviceId != prefs.getString(KEY_DEVICE_ID, null)) { prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply() } val model = Build.MODEL ?: "Android device" - val manufacturer = Build.MANUFACTURER ?: "" - val label = if (manufacturer.isBlank() || model.startsWith(manufacturer, ignoreCase = true)) { - model - } else { - "$manufacturer $model" - } return DeviceIdentity( deviceId = deviceId, hostname = model, - label = label, + label = deviceLabel(), clientVersion = clientVersion, ) } + /** Human-readable device name (e.g. "Google Pixel 8"); no side effects. */ + fun deviceLabel(): String { + val model = Build.MODEL ?: "Android device" + val manufacturer = Build.MANUFACTURER ?: "" + return if (manufacturer.isBlank() || model.startsWith(manufacturer, ignoreCase = true)) { + model + } else { + "$manufacturer $model" + } + } + private const val PREFS = "tunnel_identity" private const val KEY_DEVICE_ID = "device_id" } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index fb05bc1..b40d7a0 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -262,6 +262,32 @@ android:textSize="14sp" /> + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c06db4..0cadff4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,7 +31,20 @@ Gateway URL SealGate API key Use a wss:// URL (ws:// is allowed for local development) - Paste your API key from the dashboard + Sign in, or paste an API key from the dashboard + + Sign in with SealGate + Or connect with an API key + Starting sign-in… + Approve this device + In the SealGate dashboard, go to + and enter this code: + Waiting for approval in the dashboard… + Signed in. Starting tunnel… + Open dashboard + Cancel + No browser found. Open %1$s manually to approve. + Sign-in failed Diagram: this phone is ready to connect securely to the SealGate gateway. Diagram: this phone is creating a secure connection to the SealGate gateway. diff --git a/app/src/test/java/ai/sealgate/stdiod/TunnelConfigTest.kt b/app/src/test/java/ai/sealgate/stdiod/TunnelConfigTest.kt index 188e364..4194732 100644 --- a/app/src/test/java/ai/sealgate/stdiod/TunnelConfigTest.kt +++ b/app/src/test/java/ai/sealgate/stdiod/TunnelConfigTest.kt @@ -1,6 +1,8 @@ package ai.sealgate.stdiod +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -32,4 +34,18 @@ class TunnelConfigTest { ) assertFalse(config.isValid()) } + + @Test + fun `device id defaults to null and does not affect validity`() { + val apiKey = TunnelConfig( + gatewayUrl = "wss://gateway.sealgate.ai/tunnel", + authToken = "ew_key", + ) + assertNull(apiKey.deviceId) + assertTrue(apiKey.isValid()) + + val oauth = apiKey.copy(authToken = "ewc_token", deviceId = "ewd_device") + assertEquals("ewd_device", oauth.deviceId) + assertTrue(oauth.isValid()) + } } diff --git a/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt b/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt new file mode 100644 index 0000000..a69c370 --- /dev/null +++ b/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt @@ -0,0 +1,100 @@ +package ai.sealgate.stdiod.tunnel + +import java.security.MessageDigest +import java.util.Base64 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayUrlsTest { + + @Test + fun `derives https origin from wss tunnel url`() { + assertEquals( + "https://demo-dashboard.sealgate.ai", + GatewayUrls.apiBaseFromWs("wss://demo-dashboard.sealgate.ai/api/v1/stdio-tunnel/ws"), + ) + } + + @Test + fun `derives http origin from ws url and preserves port`() { + assertEquals( + "http://10.0.2.2:8000", + GatewayUrls.apiBaseFromWs("ws://10.0.2.2:8000/api/v1/stdio-tunnel/ws"), + ) + } + + @Test + fun `trims surrounding whitespace and ignores query and fragment`() { + assertEquals( + "https://host.example", + GatewayUrls.apiBaseFromWs(" wss://host.example/ws?x=1#frag "), + ) + } + + @Test + fun `rejects non websocket schemes and empty authority`() { + assertNull(GatewayUrls.apiBaseFromWs("https://host/ws")) + assertNull(GatewayUrls.apiBaseFromWs("host/ws")) + assertNull(GatewayUrls.apiBaseFromWs("wss:///ws")) + assertNull(GatewayUrls.apiBaseFromWs("")) + } +} + +class PkceTest { + + @Test + fun `verifier is 43 char base64url and challenge matches sha256`() { + val pair = Pkce.generate() + assertEquals(43, pair.verifier.length) + assertTrue(pair.verifier.all { it.isLetterOrDigit() || it == '-' || it == '_' }) + + val expected = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(pair.verifier.toByteArray(Charsets.US_ASCII)), + ) + assertEquals(expected, pair.challenge) + // 43 chars is exactly a 32-byte base64url payload (no padding). + assertEquals(43, pair.challenge.length) + } + + @Test + fun `successive verifiers differ`() { + assertTrue(Pkce.generate().verifier != Pkce.generate().verifier) + } +} + +class DeviceAuthPollActionTest { + + private val client = DeviceAuthClient(apiBaseUrl = "https://example.test") + + @Test + fun `authorization_pending retries at the same interval`() { + val action = client.pollActionFor(400, """{"error":"authorization_pending"}""", 7) + assertEquals(DeviceAuthClient.PollAction.Retry(7), action) + } + + @Test + fun `slow_down backs off the interval`() { + val action = client.pollActionFor(400, """{"error":"slow_down"}""", 7) + assertEquals(DeviceAuthClient.PollAction.Retry(12), action) + } + + @Test + fun `access_denied and expired_token are terminal`() { + assertTrue( + client.pollActionFor(400, """{"error":"access_denied"}""", 5) + is DeviceAuthClient.PollAction.Fail, + ) + assertTrue( + client.pollActionFor(400, """{"error":"expired_token"}""", 5) + is DeviceAuthClient.PollAction.Fail, + ) + } + + @Test + fun `unrecognised error body still fails cleanly`() { + val action = client.pollActionFor(500, "not json", 5) + assertTrue(action is DeviceAuthClient.PollAction.Fail) + } +} From 7195230dcdb0b28d7369cf74a23fe35800e9eddb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 20:24:02 +0000 Subject: [PATCH 2/4] Review follow-up: fix OAuth device-id binding and lifecycle Address code-review findings on the device sign-in flow: - configFromInputs bound the stored device id to gateway-URL equality, so editing the gateway URL dropped the ewd_ id (and save() then deleted it), leaving the ewc_ credential unusable. The device id is bound to the credential, not the endpoint: reuse it whenever the field still holds the saved ewc_ token, regardless of URL. - DeviceIdentityStore wrote the OAuth-supplied ewd_ id into its local-UUID slot, so a later switch to API-key mode would present the backend's device id as its own. Only persist a locally minted id now; a supplied id is authoritative and already persisted by TunnelSettings. - MainActivity declares configChanges so a rotation during the (up to 10 min) browser-approval wait no longer cancels the poll and orphans the grant. - Trim PairingResult / DeviceTokenResponse to the fields the app consumes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8 --- app/src/main/AndroidManifest.xml | 3 ++- .../main/java/ai/sealgate/stdiod/MainActivity.kt | 6 +++++- .../ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt | 14 +++----------- .../sealgate/stdiod/tunnel/DeviceIdentityStore.kt | 13 ++++++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a8dd5a0..9564678 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,7 +73,8 @@ + android:exported="true" + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"> diff --git a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt index 7143577..07fabab 100644 --- a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt +++ b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt @@ -339,7 +339,11 @@ class MainActivity : AppCompatActivity() { val gatewayUrl = binding.gatewayUrlInput.text?.toString()?.trim().orEmpty() val authToken = binding.apiKeyInput.text?.toString()?.trim().orEmpty() val stored = TunnelSettings.load(this) - val deviceId = if (authToken == stored.authToken && gatewayUrl == stored.gatewayUrl) { + // The OAuth device id is bound to the `ewc_` credential, not the endpoint, + // so keep it as long as the saved credential is still the one in the field. + // Editing the gateway URL must not drop it (that would leave the credential + // unusable); a manually pasted API key simply has no bound device id. + val deviceId = if (authToken == stored.authToken && authToken.startsWith("ewc_")) { stored.deviceId } else { null diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt index e50a48d..248561b 100644 --- a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt +++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt @@ -37,14 +37,11 @@ class DeviceAuthClient( private val httpClient: OkHttpClient = OkHttpClient(), private val json: Json = defaultJson, ) { - /** The `ewc_` credential and identity handed back after a successful pairing. */ + /** The `ewc_` credential and identity the app persists after a successful pairing. */ data class PairingResult( val accessToken: String, val deviceId: String, val clientInstallationId: String, - val scope: List, - val userId: String, - val orgId: String, ) /** A user-facing failure with a message safe to show verbatim. */ @@ -118,9 +115,6 @@ class DeviceAuthClient( accessToken = token.accessToken, deviceId = token.deviceId, clientInstallationId = token.clientInstallationId, - scope = token.scope, - userId = token.userId, - orgId = token.orgId, ) } when (val action = pollActionFor(response.code, response.body, intervalSeconds)) { @@ -264,15 +258,13 @@ private data class DeviceCodeResponse( val interval: Int, ) +// Only the fields the app consumes; the token endpoint also returns token_type, +// scope, user_id, org_id and api_key (always null for mobile), ignored here. @Serializable private data class DeviceTokenResponse( @SerialName("access_token") val accessToken: String, @SerialName("client_installation_id") val clientInstallationId: String, @SerialName("device_id") val deviceId: String, - val scope: List, - @SerialName("user_id") val userId: String, - @SerialName("org_id") val orgId: String, - @SerialName("api_key") val apiKey: String? = null, ) @Serializable diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt index f789ec8..118eaa2 100644 --- a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt +++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt @@ -27,11 +27,14 @@ object DeviceIdentityStore { preferredDeviceId: String? = null, ): DeviceIdentity { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - var deviceId = preferredDeviceId?.ifBlank { null } ?: prefs.getString(KEY_DEVICE_ID, null) - if (deviceId == null) { - deviceId = UUID.randomUUID().toString() - } - if (deviceId != prefs.getString(KEY_DEVICE_ID, null)) { + val supplied = preferredDeviceId?.ifBlank { null } + val stored = prefs.getString(KEY_DEVICE_ID, null) + // A caller-supplied (OAuth `ewd_`) id is authoritative and already persisted + // by TunnelSettings, so it must NOT overwrite the locally minted UUID here - + // otherwise a later switch back to API-key mode would present the backend's + // device id as its own. Only persist an id we mint ourselves. + val deviceId = supplied ?: stored ?: UUID.randomUUID().toString() + if (supplied == null && deviceId != stored) { prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply() } val model = Build.MODEL ?: "Android device" From 603afcd54a54a23c392ebbc48af273d9306dcd4f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 14:41:35 +0000 Subject: [PATCH 3/4] Resume interrupted OAuth sign-in after process death Persist the in-flight device grant so a sign-in that the OS interrupts while the user is approving in the browser resumes on next launch instead of being silently orphaned. Completes the review's #3: rotation was already covered by configChanges; this adds true process-death resilience without a ViewModel (whose scope would not survive the process anyway). - PendingSignInStore: private, short-lived store of the grant (device code, user code, verify URLs, PKCE verifier, absolute expiry), rebased on the time remaining at load and dropped once too little time is left. - MainActivity: extract the shared poll runner; save the grant when sign-in starts, clear it on success / terminal failure / explicit cancel, and keep it on lifecycle cancellation and process kill. onStart resumes a live grant (guarded, so the common foreground-return case is a no-op) without reopening the browser. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8 --- .../java/ai/sealgate/stdiod/MainActivity.kt | 124 +++++++++++++----- .../ai/sealgate/stdiod/PendingSignInStore.kt | 95 ++++++++++++++ 2 files changed, 186 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt diff --git a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt index 07fabab..1d8c05e 100644 --- a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt +++ b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt @@ -219,6 +219,10 @@ class MainActivity : AppCompatActivity() { .registerOnSharedPreferenceChangeListener(computerUsePreferenceListener) renderComputerControl() } + // Resume a sign-in that a process kill interrupted mid-approval. Guarded + // (no-op when a poll is already running or no grant is stored), so the + // common foreground-return case does nothing. + if (::binding.isInitialized) maybeResumeSignIn() } override fun onStop() { @@ -352,9 +356,8 @@ class MainActivity : AppCompatActivity() { } /** - * Run the OAuth device-authorization flow: request a code, show it for the - * user to approve in the dashboard, poll until approved, then persist the - * credential and start the tunnel. + * Start the OAuth device-authorization flow: request a code, persist the + * grant (so a process death mid-approval can resume), then poll. */ private fun startDeviceSignIn() { if (signInJob?.isActive == true) return @@ -374,38 +377,87 @@ class MainActivity : AppCompatActivity() { Toast.makeText(this, R.string.sign_in_starting, Toast.LENGTH_SHORT).show() signInJob = lifecycleScope.launch { - try { - val grant = client.requestDeviceCode(label, BuildConfig.VERSION_NAME, existingInstallation) - val dialog = showSignInDialog(grant) - openUri(grant.verificationUriComplete) - val result = try { - client.pollForToken(grant) - } finally { - dialog.dismiss() - } - TunnelSettings.saveOAuthResult( - context = this@MainActivity, - gatewayUrl = gatewayUrl, - accessToken = result.accessToken, - deviceId = result.deviceId, - clientInstallationId = result.clientInstallationId, - ) - binding.gatewayUrlInput.setText(gatewayUrl) - binding.apiKeyInput.setText(result.accessToken) - binding.apiKeyLayout.error = null - binding.settingsPanel.visibility = View.GONE - Toast.makeText(this@MainActivity, R.string.sign_in_success, Toast.LENGTH_SHORT).show() - TunnelService.start( - this@MainActivity, - TunnelConfig(gatewayUrl, result.accessToken, result.deviceId), - ) - } catch (e: CancellationException) { - throw e + val grant = try { + client.requestDeviceCode(label, BuildConfig.VERSION_NAME, existingInstallation) } catch (e: DeviceAuthClient.DeviceAuthException) { + binding.signInButton.isEnabled = true showSignInError(e.message) + return@launch + } + PendingSignInStore.save( + this@MainActivity, + gatewayUrl, + grant, + System.currentTimeMillis() + grant.expiresInSeconds.toLong() * 1000L, + ) + pollGrantToTunnel(client, gatewayUrl, grant, openBrowser = true) + } + } + + /** + * Resume a sign-in whose poll was killed by process death while the user was + * approving in the browser. No-op when there is no live grant. The browser is + * not reopened - the user was already there. + */ + private fun maybeResumeSignIn() { + if (signInJob?.isActive == true) return + val saved = PendingSignInStore.load(this) ?: return + val apiBase = GatewayUrls.apiBaseFromWs(saved.gatewayUrl) + if (apiBase == null) { + PendingSignInStore.clear(this) + return + } + binding.signInButton.isEnabled = false + signInJob = lifecycleScope.launch { + pollGrantToTunnel(DeviceAuthClient(apiBase), saved.gatewayUrl, saved.grant, openBrowser = false) + } + } + + /** + * Show the approval dialog, poll to completion, and on success persist the + * credential and start the tunnel. The persisted grant is cleared on success + * and on terminal failure here, and on explicit user cancel in the dialog + * handlers; it is deliberately kept on lifecycle cancellation and process + * kill so [maybeResumeSignIn] can pick it up on the next launch. + */ + private suspend fun pollGrantToTunnel( + client: DeviceAuthClient, + gatewayUrl: String, + grant: PendingGrant, + openBrowser: Boolean, + ) { + try { + val dialog = showSignInDialog(grant) + if (openBrowser) openUri(grant.verificationUriComplete) + val result = try { + client.pollForToken(grant) } finally { - binding.signInButton.isEnabled = true + dialog.dismiss() } + TunnelSettings.saveOAuthResult( + context = this, + gatewayUrl = gatewayUrl, + accessToken = result.accessToken, + deviceId = result.deviceId, + clientInstallationId = result.clientInstallationId, + ) + binding.gatewayUrlInput.setText(gatewayUrl) + binding.apiKeyInput.setText(result.accessToken) + binding.apiKeyLayout.error = null + binding.settingsPanel.visibility = View.GONE + Toast.makeText(this, R.string.sign_in_success, Toast.LENGTH_SHORT).show() + TunnelService.start(this, TunnelConfig(gatewayUrl, result.accessToken, result.deviceId)) + PendingSignInStore.clear(this) + } catch (e: CancellationException) { + // Lifecycle cancellation (e.g. the activity is destroyed): leave the + // grant persisted so it can resume. Explicit user cancel clears it in + // the dialog handlers before cancelling the job. + throw e + } catch (e: DeviceAuthClient.DeviceAuthException) { + PendingSignInStore.clear(this) + showSignInError(e.message) + } finally { + binding.signInButton.isEnabled = true } } @@ -417,8 +469,8 @@ class MainActivity : AppCompatActivity() { .setTitle(R.string.sign_in_dialog_title) .setView(view) .setPositiveButton(R.string.action_open_dashboard, null) - .setNegativeButton(R.string.action_cancel) { _, _ -> signInJob?.cancel() } - .setOnCancelListener { signInJob?.cancel() } + .setNegativeButton(R.string.action_cancel) { _, _ -> cancelSignIn() } + .setOnCancelListener { cancelSignIn() } .create() // Keep the dialog open when "Open dashboard" is tapped so the user can // return and watch it flip to connected. @@ -431,6 +483,12 @@ class MainActivity : AppCompatActivity() { return dialog } + /** User aborted sign-in: drop the persisted grant so it is not resumed, then stop the poll. */ + private fun cancelSignIn() { + PendingSignInStore.clear(this) + signInJob?.cancel() + } + private fun showSignInError(message: String?) { MaterialAlertDialogBuilder(this) .setTitle(R.string.sign_in_failed_title) diff --git a/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt b/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt new file mode 100644 index 0000000..3d6434a --- /dev/null +++ b/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt @@ -0,0 +1,95 @@ +package ai.sealgate.stdiod + +import ai.sealgate.stdiod.tunnel.PendingGrant +import android.content.Context + +/** + * Persists an in-flight OAuth device grant so a sign-in that is interrupted by + * process death (the OS reclaiming the app while the user is approving in the + * browser) can resume on next launch instead of silently orphaning the grant. + * + * Rotation and other configuration changes are already handled by + * `android:configChanges` on MainActivity, so this store only earns its keep + * for true process death. The grant is short-lived (minutes) and the stored + * fields carry the same trust level as the credential they redeem, so they + * live in the app's private SharedPreferences and are cleared the moment the + * poll reaches any terminal state. + */ +object PendingSignInStore { + + /** A resumable grant, with [grant] re-expressed against the time remaining. */ + data class Saved(val gatewayUrl: String, val grant: PendingGrant) + + fun save(context: Context, gatewayUrl: String, grant: PendingGrant, expiresAtMillis: Long) { + context.prefs() + .edit() + .putString(KEY_GATEWAY_URL, gatewayUrl) + .putString(KEY_DEVICE_CODE, grant.deviceCode) + .putString(KEY_USER_CODE, grant.userCode) + .putString(KEY_VERIFY_URI, grant.verificationUri) + .putString(KEY_VERIFY_URI_COMPLETE, grant.verificationUriComplete) + .putInt(KEY_INTERVAL, grant.intervalSeconds) + .putString(KEY_CODE_VERIFIER, grant.codeVerifier) + .putLong(KEY_EXPIRES_AT, expiresAtMillis) + .apply() + } + + /** + * Return the persisted grant with its lifetime rebased on the time left, or + * null when there is none or it has (all but) expired. Clears an expired or + * malformed record so a stale grant is never resumed. + */ + fun load(context: Context, nowMillis: Long = System.currentTimeMillis()): Saved? { + val prefs = context.prefs() + val gatewayUrl = prefs.getString(KEY_GATEWAY_URL, null) + val deviceCode = prefs.getString(KEY_DEVICE_CODE, null) + val userCode = prefs.getString(KEY_USER_CODE, null) + val verifyUri = prefs.getString(KEY_VERIFY_URI, null) + val verifyUriComplete = prefs.getString(KEY_VERIFY_URI_COMPLETE, null) + val codeVerifier = prefs.getString(KEY_CODE_VERIFIER, null) + val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L) + val interval = prefs.getInt(KEY_INTERVAL, 0) + if ( + gatewayUrl == null || deviceCode == null || userCode == null || + verifyUri == null || verifyUriComplete == null || codeVerifier == null + ) { + return null + } + val remainingSeconds = ((expiresAt - nowMillis) / 1000L).toInt() + if (remainingSeconds < MIN_RESUME_SECONDS) { + clear(context) + return null + } + return Saved( + gatewayUrl = gatewayUrl, + grant = PendingGrant( + deviceCode = deviceCode, + userCode = userCode, + verificationUri = verifyUri, + verificationUriComplete = verifyUriComplete, + expiresInSeconds = remainingSeconds, + intervalSeconds = interval.coerceAtLeast(1), + codeVerifier = codeVerifier, + ), + ) + } + + fun clear(context: Context) { + context.prefs().edit().clear().apply() + } + + private fun Context.prefs() = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + // Too little time left is not worth resuming: the poll interval alone could + // outlast it, so the user would just watch it expire. + private const val MIN_RESUME_SECONDS = 15 + private const val PREFS = "pending_sign_in" + private const val KEY_GATEWAY_URL = "gateway_url" + private const val KEY_DEVICE_CODE = "device_code" + private const val KEY_USER_CODE = "user_code" + private const val KEY_VERIFY_URI = "verification_uri" + private const val KEY_VERIFY_URI_COMPLETE = "verification_uri_complete" + private const val KEY_INTERVAL = "interval" + private const val KEY_CODE_VERIFIER = "code_verifier" + private const val KEY_EXPIRES_AT = "expires_at" +} From 3b44114c5ddc6b66c16151275a8a455bc30a3a10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 19:03:31 +0000 Subject: [PATCH 4/4] Handle revoked credentials, add sign-out, encrypt credential at rest Three hardening changes to the OAuth tunnel auth, all in the mobile client. Revoked/rejected credential no longer loops forever: TunnelClient now classifies terminal gateway refusals (1008 policy closes by their reason string, and 401/403 rejected upgrades) and stops the reconnect loop, publishing a new TunnelState.Unauthorized(reason). The UI turns that into a call to action - "Sign-in required", "Update required", or "Not enabled for your org" - and reveals the settings panel. Transient closes (network drops, server restart, "connection replaced") still reconnect with backoff. Sign out / revoke: a Sign out button (shown while a credential is stored) stops the tunnel, forgets the local credential, and best-effort revokes the installation via POST /api/v1/auth/device/revoke. Local sign-out always completes; a failed revoke only downgrades the confirmation toast. Credential encrypted at rest: SecretCipher wraps the tunnel credential and the in-flight PKCE verifier/device code with an AES-256-GCM key held in the AndroidKeyStore, so a prefs dump or a backup restored to another device cannot lift them. Legacy plaintext values are read transparently and re-encrypted on next save; a value that no longer decrypts reads as signed out, the correct outcome for an off-device credential. Pure decision helpers (close/failure classification, revoke status) are extracted and unit-tested; the Keystore and UI paths are exercised by CI's build + lint. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8 --- README.md | 9 ++ .../java/ai/sealgate/stdiod/MainActivity.kt | 80 ++++++++++++- .../ai/sealgate/stdiod/PendingSignInStore.kt | 16 ++- .../java/ai/sealgate/stdiod/SecretCipher.kt | 95 ++++++++++++++++ .../java/ai/sealgate/stdiod/TunnelService.kt | 15 ++- .../java/ai/sealgate/stdiod/TunnelSettings.kt | 27 ++++- .../stdiod/tunnel/DeviceAuthClient.kt | 46 ++++++++ .../ai/sealgate/stdiod/tunnel/TunnelClient.kt | 86 +++++++++++++- .../stdiod/ui/NotificationTunnelArtwork.kt | 8 +- .../ai/sealgate/stdiod/ui/TunnelVisualView.kt | 5 +- app/src/main/res/layout/activity_main.xml | 17 +++ app/src/main/res/values/strings.xml | 11 ++ .../stdiod/tunnel/DeviceAuthClientTest.kt | 15 +++ .../TunnelClientCloseClassificationTest.kt | 107 ++++++++++++++++++ 14 files changed, 515 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/ai/sealgate/stdiod/SecretCipher.kt create mode 100644 app/src/test/java/ai/sealgate/stdiod/tunnel/TunnelClientCloseClassificationTest.kt diff --git a/README.md b/README.md index 9bceca6..904da6b 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,15 @@ per-file limit, and 32 MiB total virtual filesystem limit. dedicated `mobile` client id; the backend side lives in `edison-watch` (`src/api/v1/routes/device_auth.py`, `dev-docs/architecture/mobile-hardware-gateway-design.md`). + The credential (and the in-flight PKCE verifier of an interrupted sign-in) is + stored encrypted at rest with an AES-256-GCM key held in the AndroidKeyStore + (`SecretCipher`), so a prefs dump or a backup restored to another phone cannot + lift it. To disconnect, open settings and tap **Sign out**: the app stops the + tunnel, forgets the local credential, and revokes the installation in the + dashboard (`POST /api/v1/auth/device/revoke`). If the gateway later revokes the + credential itself, the tunnel stops reconnecting and the app asks you to sign + in again instead of looping. + While the tunnel is running, pull down from the top of the app screen to close the current socket and reconnect immediately with the saved settings. diff --git a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt index 1d8c05e..4890eb0 100644 --- a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt +++ b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt @@ -29,6 +29,7 @@ import ai.sealgate.stdiod.tunnel.DeviceIdentityStore import ai.sealgate.stdiod.tunnel.GatewayUrls import ai.sealgate.stdiod.tunnel.PendingGrant import ai.sealgate.stdiod.tunnel.TunnelState +import ai.sealgate.stdiod.tunnel.TunnelStopReason import ai.sealgate.stdiod.mcp.ComputerAccessibilityService import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CancellationException @@ -163,6 +164,12 @@ class MainActivity : AppCompatActivity() { startDeviceSignIn() } + binding.signOutButton.setOnClickListener { + binding.signOutButton.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + confirmSignOut() + } + updateSignOutVisibility() + binding.tunnelButton.setOnClickListener { binding.tunnelButton.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) if (tunnelState != null) { @@ -184,6 +191,7 @@ class MainActivity : AppCompatActivity() { } TunnelSettings.save(this, config) binding.settingsPanel.visibility = View.GONE + updateSignOutVisibility() TunnelService.start(this, config) } @@ -198,6 +206,7 @@ class MainActivity : AppCompatActivity() { TunnelState.Connected -> getString(R.string.tunnel_state_connected) TunnelState.Connecting -> getString(R.string.tunnel_state_connecting) TunnelState.Disconnected -> getString(R.string.tunnel_state_disconnected) + is TunnelState.Unauthorized -> getString(stopReasonStatus(state.reason)) null -> getString(R.string.status_stopped) } renderState(state, text) @@ -281,7 +290,7 @@ class MainActivity : AppCompatActivity() { when (state) { TunnelState.Connected -> R.color.circuit_green TunnelState.Connecting -> R.color.signal_amber - TunnelState.Disconnected, null -> R.color.infra_red + TunnelState.Disconnected, is TunnelState.Unauthorized, null -> R.color.infra_red }, ) binding.statusText.text = text @@ -303,11 +312,24 @@ class MainActivity : AppCompatActivity() { TunnelState.Connected -> R.string.tunnel_visual_connected TunnelState.Connecting -> R.string.tunnel_visual_connecting TunnelState.Disconnected -> R.string.tunnel_visual_reconnecting + is TunnelState.Unauthorized -> R.string.tunnel_visual_sign_in_required null -> R.string.tunnel_visual_stopped }, ) + // A terminal auth failure needs the user to act: surface the panel that + // holds Sign in (and the gateway/API-key fields) so the fix is one tap away. + if (state is TunnelState.Unauthorized) { + binding.settingsPanel.visibility = View.VISIBLE + } } + private fun stopReasonStatus(reason: TunnelStopReason): Int = + when (reason) { + TunnelStopReason.CREDENTIAL_REJECTED -> R.string.tunnel_state_sign_in_required + TunnelStopReason.PROTOCOL_UNSUPPORTED -> R.string.tunnel_state_update_required + TunnelStopReason.ORG_NOT_ENABLED -> R.string.tunnel_state_org_not_enabled + } + private fun maybeRequestNotificationPermission() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return val granted = ContextCompat.checkSelfPermission( @@ -445,6 +467,7 @@ class MainActivity : AppCompatActivity() { binding.apiKeyInput.setText(result.accessToken) binding.apiKeyLayout.error = null binding.settingsPanel.visibility = View.GONE + updateSignOutVisibility() Toast.makeText(this, R.string.sign_in_success, Toast.LENGTH_SHORT).show() TunnelService.start(this, TunnelConfig(gatewayUrl, result.accessToken, result.deviceId)) PendingSignInStore.clear(this) @@ -489,6 +512,61 @@ class MainActivity : AppCompatActivity() { signInJob?.cancel() } + /** Show the sign-out button only while a credential is stored. */ + private fun updateSignOutVisibility() { + val hasCredential = TunnelSettings.load(this).authToken.isNotBlank() + binding.signOutButton.visibility = if (hasCredential) View.VISIBLE else View.GONE + } + + private fun confirmSignOut() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.sign_out_dialog_title) + .setMessage(R.string.sign_out_dialog_message) + .setPositiveButton(R.string.action_sign_out) { _, _ -> signOut() } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + /** + * Stop the tunnel, forget the local credential, and best-effort revoke it in + * the dashboard. Local sign-out always completes; the revoke is attempted only + * for an OAuth (`ewc_`) credential and its failure only downgrades the toast. + */ + private fun signOut() { + val stored = TunnelSettings.load(this) + val token = stored.authToken + val apiBase = GatewayUrls.apiBaseFromWs(stored.gatewayUrl) + // A pasted API key is not ours to revoke through the device endpoint; only + // an OAuth `ewc_` credential is. (apiBase != null is checked below, both to + // guard the call and to smart-cast it for the request.) + val canRevoke = token.startsWith("ewc_") + + // Tear down every trace of the session on this device first, so the UI is + // honestly signed out even if the revoke call below never returns. + signInJob?.cancel() + PendingSignInStore.clear(this) + TunnelService.stop(this) + TunnelSettings.clearCredential(this) + binding.apiKeyInput.setText("") + binding.apiKeyLayout.error = null + binding.settingsPanel.visibility = View.VISIBLE + updateSignOutVisibility() + + if (!canRevoke || apiBase == null) { + Toast.makeText(this, R.string.sign_out_done, Toast.LENGTH_SHORT).show() + return + } + Toast.makeText(this, R.string.sign_out_in_progress, Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + val revoked = DeviceAuthClient(apiBase).revokeCredential(token) + Toast.makeText( + this@MainActivity, + if (revoked) R.string.sign_out_done else R.string.sign_out_revoke_failed, + if (revoked) Toast.LENGTH_SHORT else Toast.LENGTH_LONG, + ).show() + } + } + private fun showSignInError(message: String?) { MaterialAlertDialogBuilder(this) .setTitle(R.string.sign_in_failed_title) diff --git a/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt b/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt index 3d6434a..1ce41d9 100644 --- a/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt +++ b/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt @@ -13,7 +13,9 @@ import android.content.Context * for true process death. The grant is short-lived (minutes) and the stored * fields carry the same trust level as the credential they redeem, so they * live in the app's private SharedPreferences and are cleared the moment the - * poll reaches any terminal state. + * poll reaches any terminal state. The two secrets that redeem the grant - the + * PKCE verifier and the device code - are [SecretCipher]-encrypted at rest, so + * an interrupted sign-in is no weaker on disk than a completed one. */ object PendingSignInStore { @@ -24,12 +26,12 @@ object PendingSignInStore { context.prefs() .edit() .putString(KEY_GATEWAY_URL, gatewayUrl) - .putString(KEY_DEVICE_CODE, grant.deviceCode) + .putString(KEY_DEVICE_CODE, SecretCipher.encrypt(grant.deviceCode)) .putString(KEY_USER_CODE, grant.userCode) .putString(KEY_VERIFY_URI, grant.verificationUri) .putString(KEY_VERIFY_URI_COMPLETE, grant.verificationUriComplete) .putInt(KEY_INTERVAL, grant.intervalSeconds) - .putString(KEY_CODE_VERIFIER, grant.codeVerifier) + .putString(KEY_CODE_VERIFIER, SecretCipher.encrypt(grant.codeVerifier)) .putLong(KEY_EXPIRES_AT, expiresAtMillis) .apply() } @@ -42,17 +44,21 @@ object PendingSignInStore { fun load(context: Context, nowMillis: Long = System.currentTimeMillis()): Saved? { val prefs = context.prefs() val gatewayUrl = prefs.getString(KEY_GATEWAY_URL, null) - val deviceCode = prefs.getString(KEY_DEVICE_CODE, null) + val deviceCode = prefs.getString(KEY_DEVICE_CODE, null)?.let { SecretCipher.decrypt(it) } val userCode = prefs.getString(KEY_USER_CODE, null) val verifyUri = prefs.getString(KEY_VERIFY_URI, null) val verifyUriComplete = prefs.getString(KEY_VERIFY_URI_COMPLETE, null) - val codeVerifier = prefs.getString(KEY_CODE_VERIFIER, null) + val codeVerifier = prefs.getString(KEY_CODE_VERIFIER, null)?.let { SecretCipher.decrypt(it) } val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L) val interval = prefs.getInt(KEY_INTERVAL, 0) if ( gatewayUrl == null || deviceCode == null || userCode == null || verifyUri == null || verifyUriComplete == null || codeVerifier == null ) { + // When a record was stored but a secret no longer decrypts, the grant + // is unredeemable: drop it so it is never resumed. An empty store (no + // gateway url) needs no write. + if (gatewayUrl != null) clear(context) return null } val remainingSeconds = ((expiresAt - nowMillis) / 1000L).toInt() diff --git a/app/src/main/java/ai/sealgate/stdiod/SecretCipher.kt b/app/src/main/java/ai/sealgate/stdiod/SecretCipher.kt new file mode 100644 index 0000000..2ea7703 --- /dev/null +++ b/app/src/main/java/ai/sealgate/stdiod/SecretCipher.kt @@ -0,0 +1,95 @@ +package ai.sealgate.stdiod + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Encrypts small secrets (the tunnel credential, the in-flight PKCE verifier) + * at rest using an AES-256-GCM key held in the AndroidKeyStore. + * + * The key never leaves the Keystore (hardware-backed on devices with a TEE or + * StrongBox), so the ciphertext stored in SharedPreferences is useless off the + * device: it cannot be read from a backup restored to another phone, nor from a + * `run-as`/root dump of the prefs file without also compromising the Keystore. + * That is a deliberate step up from storing the `ewc_` bearer token in plain + * text, and it makes an `allowBackup` copy of the prefs inert. + * + * Values are stored as `v1:` + base64(iv ‖ ciphertext‖GCM-tag). A stored value + * without the prefix is treated as legacy plaintext and returned as-is, so an + * existing sign-in survives the upgrade and is re-encrypted the next time it is + * saved. A value that fails to decrypt (e.g. the Keystore key is gone after a + * cross-device restore) yields null, which the callers treat as "signed out" - + * the correct outcome for a credential that must not survive off its device. + */ +object SecretCipher { + + /** Wrap a plaintext secret for storage. */ + fun encrypt(plaintext: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val blob = ByteArray(iv.size + ciphertext.size) + System.arraycopy(iv, 0, blob, 0, iv.size) + System.arraycopy(ciphertext, 0, blob, iv.size, ciphertext.size) + return PREFIX + Base64.encodeToString(blob, Base64.NO_WRAP) + } + + /** + * Unwrap a value produced by [encrypt]. Returns a legacy (unprefixed) + * plaintext value unchanged, and null when a prefixed value cannot be + * decrypted (corrupt, or the key is no longer available). + */ + fun decrypt(stored: String): String? { + if (!stored.startsWith(PREFIX)) return stored + return try { + val blob = Base64.decode(stored.substring(PREFIX.length), Base64.NO_WRAP) + if (blob.size <= IV_LENGTH) return null + val iv = blob.copyOfRange(0, IV_LENGTH) + val ciphertext = blob.copyOfRange(IV_LENGTH, blob.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_LENGTH_BITS, iv)) + String(cipher.doFinal(ciphertext), Charsets.UTF_8) + } catch (e: GeneralSecurityException) { + Log.w(TAG, "failed to decrypt stored secret; treating as signed out", e) + null + } catch (e: IllegalArgumentException) { + Log.w(TAG, "stored secret was not valid base64", e) + null + } + } + + private fun secretKey(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } + + private const val TAG = "SecretCipher" + private const val KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "sealgate_secret_v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + // AES-GCM standard nonce length; the Keystore generates it on encrypt. + private const val IV_LENGTH = 12 + private const val TAG_LENGTH_BITS = 128 + private const val PREFIX = "v1:" +} diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt index ec591e2..a8524d5 100644 --- a/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt +++ b/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt @@ -36,6 +36,7 @@ import ai.sealgate.stdiod.mcp.WifiModule import ai.sealgate.stdiod.tunnel.DeviceIdentityStore import ai.sealgate.stdiod.tunnel.TunnelClient import ai.sealgate.stdiod.tunnel.TunnelState +import ai.sealgate.stdiod.tunnel.TunnelStopReason import ai.sealgate.stdiod.ui.NotificationTunnelArtwork import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -167,7 +168,8 @@ class TunnelService : LifecycleService() { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationAnimationJob?.cancel() manager.notify(NOTIFICATION_ID, buildNotification(state, frame = 0)) - if (state != TunnelState.Disconnected && systemAnimationsEnabled()) { + val animated = state != TunnelState.Disconnected && state !is TunnelState.Unauthorized + if (animated && systemAnimationsEnabled()) { notificationAnimationJob = animateNotification(state, manager) } } @@ -282,6 +284,17 @@ class TunnelService : LifecycleService() { status = R.string.tunnel_state_disconnected, color = R.color.infra_red, ) + is TunnelState.Unauthorized -> NotificationPresentation( + status = stopReasonStatus(state.reason), + color = R.color.infra_red, + ) + } + + private fun stopReasonStatus(reason: TunnelStopReason): Int = + when (reason) { + TunnelStopReason.CREDENTIAL_REJECTED -> R.string.tunnel_state_sign_in_required + TunnelStopReason.PROTOCOL_UNSUPPORTED -> R.string.tunnel_state_update_required + TunnelStopReason.ORG_NOT_ENABLED -> R.string.tunnel_state_org_not_enabled } private fun systemAnimationsEnabled(): Boolean = diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt index 322f263..f24e4cb 100644 --- a/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt +++ b/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt @@ -6,7 +6,8 @@ import android.content.Context * Persisted connection settings, so the tunnel can be configured from the * screen instead of by editing code. SharedPreferences is enough for a few * strings; the credential never leaves the device except as the tunnel's - * bearer header. + * bearer header, and is stored [SecretCipher]-encrypted at rest so a prefs + * dump or backup cannot lift it. * * Two auth modes share this store: a pasted API key, or an OAuth `ewc_` client * credential from device sign-in. OAuth additionally persists the @@ -26,9 +27,13 @@ object TunnelSettings { fun load(context: Context): TunnelConfig { val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + // A stored credential that no longer decrypts (e.g. restored to another + // device where the Keystore key does not exist) reads as empty: the user + // is signed out, which is the right outcome for an off-device credential. + val authToken = prefs.getString(KEY_AUTH_TOKEN, null)?.let { SecretCipher.decrypt(it) } return TunnelConfig( gatewayUrl = prefs.getString(KEY_GATEWAY_URL, null) ?: DEFAULT_GATEWAY_URL, - authToken = prefs.getString(KEY_AUTH_TOKEN, null).orEmpty(), + authToken = authToken.orEmpty(), deviceId = prefs.getString(KEY_DEVICE_ID, null)?.ifBlank { null }, ) } @@ -43,7 +48,7 @@ object TunnelSettings { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit() .putString(KEY_GATEWAY_URL, config.gatewayUrl) - .putString(KEY_AUTH_TOKEN, config.authToken) + .putString(KEY_AUTH_TOKEN, SecretCipher.encrypt(config.authToken)) .apply { if (config.deviceId.isNullOrBlank()) { remove(KEY_DEVICE_ID) @@ -70,13 +75,27 @@ object TunnelSettings { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit() .putString(KEY_GATEWAY_URL, gatewayUrl) - .putString(KEY_AUTH_TOKEN, accessToken) + .putString(KEY_AUTH_TOKEN, SecretCipher.encrypt(accessToken)) .putString(KEY_DEVICE_ID, deviceId) .putString(KEY_CLIENT_INSTALLATION_ID, clientInstallationId) .remove(LEGACY_KEY_BASH_MODE) .apply() } + /** + * Forget the stored credential and OAuth identity (sign out). The gateway + * URL is kept so the user can sign in again to the same endpoint without + * retyping it. + */ + fun clearCredential(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .remove(KEY_AUTH_TOKEN) + .remove(KEY_DEVICE_ID) + .remove(KEY_CLIENT_INSTALLATION_ID) + .apply() + } + private const val PREFS = "tunnel_settings" private const val KEY_GATEWAY_URL = "gateway_url" private const val KEY_AUTH_TOKEN = "auth_token" diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt index 248561b..bcddbb7 100644 --- a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt +++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt @@ -124,6 +124,51 @@ class DeviceAuthClient( } } + /** + * Revoke the client installation behind an `ewc_` credential, so the phone's + * server row is dropped and its grants denied even if the local credential + * later leaks. Best-effort: returns true when the gateway confirmed the + * revocation (or the credential was already invalid), false when it could not + * be reached. Local sign-out should proceed either way. + */ + suspend fun revokeCredential(accessToken: String): Boolean { + val response = try { + postAuthorized("$apiBaseUrl$PATH_REVOKE", accessToken) + } catch (e: DeviceAuthException) { + return false + } + return revocationSucceeded(response.code) + } + + /** + * Whether a revoke response means the credential is gone. 2xx is a fresh + * revocation; 401 means the gateway already considers it invalid/revoked, + * which is the same end state. Pure, so it is unit-tested. + */ + internal fun revocationSucceeded(statusCode: Int): Boolean = + statusCode in 200..299 || statusCode == 401 + + private suspend fun postAuthorized(url: String, bearer: String): HttpResult = + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(url) + .header("Accept", "application/json") + .header("Authorization", "Bearer $bearer") + .post(ByteArray(0).toRequestBody(JSON_MEDIA_TYPE)) + .build() + try { + httpClient.newCall(request).execute().use { raw -> + HttpResult(raw.code, raw.body?.string().orEmpty()) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw DeviceAuthException( + "Could not reach the SealGate gateway. Check the URL and your connection.", + ) + } + } + private suspend fun post(url: String, jsonBody: String): HttpResult = withContext(Dispatchers.IO) { val request = Request.Builder() .url(url) @@ -189,6 +234,7 @@ class DeviceAuthClient( const val PLATFORM_ANDROID = "android" const val PATH_CODE = "/api/v1/auth/device/code" const val PATH_TOKEN = "/api/v1/auth/device/token" + const val PATH_REVOKE = "/api/v1/auth/device/revoke" private const val SLOW_DOWN_BACKOFF_SECONDS = 5 private const val EXPIRED_MESSAGE = "The sign-in code expired. Please try again." private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt index e72d78d..d7523fc 100644 --- a/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt +++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt @@ -40,6 +40,25 @@ sealed interface TunnelState { /** `server_hello` received; the tunnel is live. */ data object Connected : TunnelState + + /** + * Terminal: the gateway refused this credential for good, so the reconnect + * loop has stopped. Reconnecting with the same credential would only loop, + * so the UI turns this into a call to action (see [reason]). + */ + data class Unauthorized(val reason: TunnelStopReason) : TunnelState +} + +/** Why the gateway refused the tunnel for good, and thus what the user must do. */ +enum class TunnelStopReason { + /** Credential invalid, revoked, or bound to a different device: sign in again. */ + CREDENTIAL_REJECTED, + + /** The client's protocol version is outside the gateway's window: update the app. */ + PROTOCOL_UNSUPPORTED, + + /** stdio tunnel is not enabled for this org: contact an admin. */ + ORG_NOT_ENABLED, } /** @@ -121,11 +140,19 @@ class TunnelClient( var backoffMillis = INITIAL_BACKOFF_MILLIS while (true) { _state.value = TunnelState.Connecting - val sessionSawHello = runOneConnection() + val outcome = runOneConnection() + if (outcome.terminalReason != null) { + // The gateway rejected the credential for good. Stop reconnecting + // (retrying the same credential would just loop every backoff) and + // publish a terminal state the UI turns into a call to action. + Log.w(TAG, "tunnel stopped, credential no longer usable: ${outcome.terminalReason}") + _state.value = TunnelState.Unauthorized(outcome.terminalReason) + return + } _state.value = TunnelState.Disconnected // A handshake that completed earns a fresh backoff; a connection // refused/dropped before server_hello keeps climbing toward the cap. - backoffMillis = if (sessionSawHello) { + backoffMillis = if (outcome.sawServerHello) { INITIAL_BACKOFF_MILLIS } else { (backoffMillis * 2).coerceAtMost(MAX_BACKOFF_MILLIS) @@ -136,8 +163,14 @@ class TunnelClient( } } - /** Runs one WebSocket session to completion. Returns true if `server_hello` arrived. */ - private suspend fun runOneConnection(): Boolean = suspendCancellableCoroutine { cont -> + /** The result of one WebSocket session: whether it handshook, and any terminal refusal. */ + private data class ConnectionOutcome( + val sawServerHello: Boolean, + val terminalReason: TunnelStopReason?, + ) + + /** Runs one WebSocket session to completion. */ + private suspend fun runOneConnection(): ConnectionOutcome = suspendCancellableCoroutine { cont -> val sessionActive = AtomicBoolean(true) val dispatcher = McpRequestDispatcher() activeDispatcher.getAndSet(dispatcher)?.close() @@ -148,8 +181,9 @@ class TunnelClient( .build() val listener = object : WebSocketListener() { - // OkHttp delivers reader callbacks sequentially, so this needs no lock. + // OkHttp delivers reader callbacks sequentially, so these need no lock. var sawServerHello = false + var terminalReason: TunnelStopReason? = null override fun onOpen(webSocket: WebSocket, response: Response) { if (stopped.get()) { @@ -220,14 +254,22 @@ class TunnelClient( override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.w(TAG, "tunnel socket failure (http=${response?.code})", t) + // A rejected upgrade (the gateway closes before `accept`, e.g. an + // invalid or revoked credential) reaches us as an HTTP status, not + // a WS close frame; treat 401/403 as terminal. + if (terminalReason == null) terminalReason = terminalReasonForFailure(response) finish() } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + // onClosing carries the peer's close code/reason; capture it here + // before echoing our own close (onClosed reports the same peer code). + terminalReason = terminalReasonForClose(code, reason) webSocket.close(NORMAL_CLOSURE, null) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (terminalReason == null) terminalReason = terminalReasonForClose(code, reason) finish() } @@ -237,7 +279,7 @@ class TunnelClient( activeDispatcher.compareAndSet(dispatcher, null) this@TunnelClient.webSocket = null modulesByServerId.clear() - if (cont.isActive) cont.resume(sawServerHello) + if (cont.isActive) cont.resume(ConnectionOutcome(sawServerHello, terminalReason)) } } @@ -316,10 +358,42 @@ class TunnelClient( companion object { private const val TAG = "TunnelClient" private const val NORMAL_CLOSURE = 1000 + private const val POLICY_VIOLATION = 1008 private const val TRY_AGAIN_LATER = 1013 private const val INITIAL_BACKOFF_MILLIS = 1_000L private const val MAX_BACKOFF_MILLIS = 60_000L + /** + * Classify a WS close frame. Only the gateway's own 1008 policy closes are + * terminal, and the reason string (a documented, stable contract - see + * `_authenticate_ws` and the protocol handshake in edison-watch's + * stdio_tunnel.py) says which. An unrecognised close (network 1006, server + * restart 1012, "connection replaced", ...) is transient: keep reconnecting. + */ + internal fun terminalReasonForClose(code: Int, reason: String): TunnelStopReason? { + if (code != POLICY_VIOLATION) return null + val r = reason.lowercase() + return when { + "protocol_version" in r -> TunnelStopReason.PROTOCOL_UNSUPPORTED + "not enabled" in r -> TunnelStopReason.ORG_NOT_ENABLED + "revoked" in r || "identity" in r || "credential" in r || + "device id" in r || "device_id" in r -> + TunnelStopReason.CREDENTIAL_REJECTED + else -> null + } + } + + /** + * Classify a failed WS upgrade. The gateway rejects a bad/revoked + * credential before `accept`, which OkHttp surfaces as an HTTP status + * rather than a close frame; 401/403 mean the credential was refused. + */ + internal fun terminalReasonForFailure(response: Response?): TunnelStopReason? = + when (response?.code) { + 401, 403 -> TunnelStopReason.CREDENTIAL_REJECTED + else -> null + } + private fun defaultHttpClient(): OkHttpClient = OkHttpClient.Builder() // One WS, no request/response cycle: no read timeout, but do // fail dead links: OkHttp pings keep NAT mappings warm and diff --git a/app/src/main/java/ai/sealgate/stdiod/ui/NotificationTunnelArtwork.kt b/app/src/main/java/ai/sealgate/stdiod/ui/NotificationTunnelArtwork.kt index a73b62b..ecc2780 100644 --- a/app/src/main/java/ai/sealgate/stdiod/ui/NotificationTunnelArtwork.kt +++ b/app/src/main/java/ai/sealgate/stdiod/ui/NotificationTunnelArtwork.kt @@ -22,7 +22,8 @@ object NotificationTunnelArtwork { @Synchronized fun render(context: Context, state: TunnelState, frame: Int = 0): Bitmap { - val frameIndex = if (state == TunnelState.Disconnected) 0 else frame % FRAME_COUNT + val isStatic = state == TunnelState.Disconnected || state is TunnelState.Unauthorized + val frameIndex = if (isStatic) 0 else frame % FRAME_COUNT val key = FrameKey(state, frameIndex) if (cachedState != state) { frameCache.clear() @@ -36,13 +37,14 @@ object NotificationTunnelArtwork { val route = when (state) { TunnelState.Connected -> context.getColor(R.color.circuit_green) TunnelState.Connecting -> context.getColor(R.color.signal_amber) - TunnelState.Disconnected -> context.getColor(R.color.infra_red) + TunnelState.Disconnected, is TunnelState.Unauthorized -> + context.getColor(R.color.infra_red) } canvas.drawColor(context.getColor(R.color.baseline_black)) drawGrid(canvas, paint, context.getColor(R.color.grid_dark)) drawRoute(canvas, paint, route, frameIndex) - if (state == TunnelState.Disconnected) { + if (isStatic) { drawDisconnectedPlug(context, canvas, paint, route) } else { drawSecureLock(canvas, paint, route, context.getColor(R.color.baseline_black)) diff --git a/app/src/main/java/ai/sealgate/stdiod/ui/TunnelVisualView.kt b/app/src/main/java/ai/sealgate/stdiod/ui/TunnelVisualView.kt index e47e99a..fb2eace 100644 --- a/app/src/main/java/ai/sealgate/stdiod/ui/TunnelVisualView.kt +++ b/app/src/main/java/ai/sealgate/stdiod/ui/TunnelVisualView.kt @@ -97,7 +97,7 @@ class TunnelVisualView @JvmOverloads constructor( val color = when (state) { TunnelState.Connected -> green TunnelState.Connecting -> amber - TunnelState.Disconnected, null -> red + TunnelState.Disconnected, is TunnelState.Unauthorized, null -> red } val startX = 48f * density val endX = w - 64f * density @@ -113,7 +113,7 @@ class TunnelVisualView @JvmOverloads constructor( ) canvas.drawLine(startX, centerY, endX, centerY, paint) paint.pathEffect = null - if (state == null || state == TunnelState.Disconnected) { + if (state == null || state == TunnelState.Disconnected || state is TunnelState.Unauthorized) { drawDisconnectedPlug(canvas, w / 2f, centerY, color) } else { drawSecureLock(canvas, w / 2f, centerY, color) @@ -256,6 +256,7 @@ class TunnelVisualView @JvmOverloads constructor( !isAttachedToWindow || state == null || state == TunnelState.Disconnected || + state is TunnelState.Unauthorized || !animationsEnabled() ) return animator = ValueAnimator.ofFloat(0f, 1f).apply { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b40d7a0..1594cba 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -310,6 +310,23 @@ android:textSize="14sp" /> + + No browser found. Open %1$s manually to approve. Sign-in failed + Sign out + Sign out? + This stops the tunnel, forgets this device\'s credential, and revokes it in the dashboard. You will need to sign in again to reconnect. + Signing out… + Signed out. + Signed out on this device. Could not reach the gateway to revoke it — revoke it from the dashboard if needed. + Diagram: this phone is ready to connect securely to the SealGate gateway. Diagram: this phone is creating a secure connection to the SealGate gateway. Diagram: this phone has a secure connection to the SealGate gateway. Diagram: the secure connection between this phone and the SealGate gateway was interrupted and is reconnecting automatically. + Diagram: this phone is disconnected from the SealGate gateway because its credential is no longer valid; sign in again to reconnect. Mobile Tunnel Ongoing status of the Mobile Tunnel secure bridge. @@ -59,4 +67,7 @@ Connected · Mobile Bash Opening tunnel Reconnecting + Sign-in required + Update required + Not enabled for your org diff --git a/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt b/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt index a69c370..484bfaf 100644 --- a/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt +++ b/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt @@ -3,6 +3,7 @@ package ai.sealgate.stdiod.tunnel import java.security.MessageDigest import java.util.Base64 import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -97,4 +98,18 @@ class DeviceAuthPollActionTest { val action = client.pollActionFor(500, "not json", 5) assertTrue(action is DeviceAuthClient.PollAction.Fail) } + + @Test + fun `revocation counts 2xx and an already-invalid 401 as done`() { + assertTrue(client.revocationSucceeded(200)) + assertTrue(client.revocationSucceeded(204)) + assertTrue(client.revocationSucceeded(401)) + } + + @Test + fun `revocation treats other failures as unconfirmed`() { + assertFalse(client.revocationSucceeded(403)) + assertFalse(client.revocationSucceeded(500)) + assertFalse(client.revocationSucceeded(0)) + } } diff --git a/app/src/test/java/ai/sealgate/stdiod/tunnel/TunnelClientCloseClassificationTest.kt b/app/src/test/java/ai/sealgate/stdiod/tunnel/TunnelClientCloseClassificationTest.kt new file mode 100644 index 0000000..20a4a73 --- /dev/null +++ b/app/src/test/java/ai/sealgate/stdiod/tunnel/TunnelClientCloseClassificationTest.kt @@ -0,0 +1,107 @@ +package ai.sealgate.stdiod.tunnel + +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The reconnect loop must stop only on the gateway's terminal refusals, and keep + * retrying everything else. These assert the classification of the close codes + * and reasons `edison-watch`'s stdio_tunnel.py emits. + */ +class TunnelClientCloseClassificationTest { + + private fun classifyClose(code: Int, reason: String) = + TunnelClient.terminalReasonForClose(code, reason) + + @Test + fun revokedInstallationCloseIsCredentialRejected() { + assertEquals( + TunnelStopReason.CREDENTIAL_REJECTED, + classifyClose(1008, "client installation revoked"), + ) + } + + @Test + fun changedIdentityCloseIsCredentialRejected() { + assertEquals( + TunnelStopReason.CREDENTIAL_REJECTED, + classifyClose(1008, "client identity changed"), + ) + } + + @Test + fun deviceIdMismatchCloseIsCredentialRejected() { + assertEquals( + TunnelStopReason.CREDENTIAL_REJECTED, + classifyClose(1008, "device id does not match client credential"), + ) + assertEquals( + TunnelStopReason.CREDENTIAL_REJECTED, + classifyClose(1008, "device_id mismatch between header and client_hello"), + ) + } + + @Test + fun protocolMismatchCloseIsProtocolUnsupported() { + assertEquals( + TunnelStopReason.PROTOCOL_UNSUPPORTED, + classifyClose(1008, "protocol_version mismatch (client=1, server supports 2-3)"), + ) + } + + @Test + fun orgDisabledCloseIsOrgNotEnabled() { + assertEquals( + TunnelStopReason.ORG_NOT_ENABLED, + classifyClose(1008, "stdio_tunnel not enabled for this org"), + ) + } + + @Test + fun connectionReplacedCloseIsTransient() { + // Another connection took over; reconnecting is legitimate, not terminal. + assertNull(classifyClose(1008, "connection replaced")) + } + + @Test + fun normalAndTransientCloseCodesAreNotTerminal() { + assertNull(classifyClose(1000, "client stopping")) + assertNull(classifyClose(1006, "abnormal closure")) + assertNull(classifyClose(1011, "server error")) + assertNull(classifyClose(1012, "service restart")) + // A 1008 with an unrecognised reason stays transient rather than bricking + // the tunnel on a reason string we did not anticipate. + assertNull(classifyClose(1008, "policy violation")) + } + + @Test + fun rejectedUpgradeStatusesAreCredentialRejected() { + assertEquals( + TunnelStopReason.CREDENTIAL_REJECTED, + TunnelClient.terminalReasonForFailure(responseWithCode(403)), + ) + assertEquals( + TunnelStopReason.CREDENTIAL_REJECTED, + TunnelClient.terminalReasonForFailure(responseWithCode(401)), + ) + } + + @Test + fun networkFailureWithoutResponseIsTransient() { + assertNull(TunnelClient.terminalReasonForFailure(null)) + // A 5xx upgrade failure is the server hiccupping, not a refused credential. + assertNull(TunnelClient.terminalReasonForFailure(responseWithCode(503))) + } + + private fun responseWithCode(code: Int): Response = + Response.Builder() + .request(Request.Builder().url("https://gateway.example/api/v1/stdio-tunnel/ws").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("test") + .build() +}