Skip to content

Add OAuth device sign-in, sign-out/revoke, and at-rest credential encryption - #47

Merged
Miyamura80 merged 5 commits into
mainfrom
claude/oauth-mobile-app-auth-wsrfyd
Sep 17, 2026
Merged

Miyamura80 merged 5 commits into
mainfrom
claude/oauth-mobile-app-auth-wsrfyd

Conversation

@Miyamura80

@Miyamura80 Miyamura80 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an OAuth 2.0 device-authorization sign-in flow to the Android tunnel client (replacing manual API-key entry as the primary path) and hardens the stored credential. The backend side lives in Edison-Watch/edison-watch.

Changes

  • OAuth device sign-in (RFC 8628 + PKCE): "Sign in with SealGate" runs the device-authorization flow under a dedicated mobile client id, shows a short code, and receives a scoped ewc_ tunnel credential bound to a backend-issued device id. Pasting an API key still works as an alternative. Sign-in survives process death mid-flow (the in-flight PKCE verifier / device code is persisted and resumed).
  • Revoked-credential handling: the tunnel now classifies terminal gateway refusals (1008 policy closes by reason, and 401/403 on the upgrade) into a new TunnelState.Unauthorized(reason) and stops the reconnect loop instead of retrying forever, surfacing "Sign-in required" / "Update required" / "Not enabled for your org" and revealing the settings panel. Transient closes still reconnect with backoff.
  • Sign out / revoke UI: a Sign out button stops the tunnel, forgets the local credential, and best-effort calls POST /api/v1/auth/device/revoke; local sign-out always completes even if the revoke call fails.
  • Credential encrypted at rest: the tunnel credential and the in-flight PKCE verifier are stored via a new SecretCipher (AES-256-GCM key in the AndroidKeyStore), so a prefs dump or a backup restored to another phone cannot lift it. Legacy plaintext reads transparently and re-encrypts on next save.

Testing

  • Build passes (./gradlew assembleDebug)
  • Unit tests pass (./gradlew testDebugUnitTest)
  • Lint passes (./gradlew lintDebug)

Note: the cloud sandbox has no Android SDK (dl.google.com is blocked), so the CI job "Build & unit test" is the sole build/lint/test gate; these boxes track its result on this PR. New unit tests cover the WS close/failure classification and the revoke status handling; the previous head passed all three CI steps.

Related Issues

N/A

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8


Generated by Claude Code


Summary by cubic

Adds OAuth 2.0 device sign-in to the Android tunnel client, so users approve the phone from the SealGate dashboard instead of pasting an API key. Also stops reconnecting forever when the gateway revokes a credential, adds sign-out, and encrypts stored credentials at rest.

Sign-in and sign-out

  • Sign-in runs the device-authorization flow (RFC 8628, with PKCE), shows a short code, and stores the scoped ewc_ credential bound to a backend-issued device id; pasting an API key still works.
  • Sign-in resumes after process death mid-flow.
  • Sign out 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.
  • The backend mobile client profile ships separately in Edison-Watch/edison-watch.

Credential handling

  • Terminal gateway refusals (1008 policy closes by reason, 401/403 upgrades) now surface as "Sign-in required" / "Update required" / "Not enabled for your org" and stop the reconnect loop; transient closes still reconnect with backoff.
  • The credential and in-flight PKCE verifier are stored AES-256-GCM encrypted with an AndroidKeyStore key; legacy plaintext reads transparently and re-encrypts on next save.

Written for commit 0493ab9. Summary will update on new commits.

Review in cubic

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 <queries> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra1a7MLkLFYFQGPMNZMoy8
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13 issues found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="README.md">

<violation number="1" location="README.md:112">
P2: For users upgrading with an existing API key, this statement is false until the settings are saved again: legacy plaintext is read unchanged and is not re-encrypted during load. Document the migration caveat so users do not assume the credential is already protected immediately after upgrading.</violation>

<violation number="2" location="README.md:117">
P3: Sign out does not always revoke an installation: API-key sign-out skips the revoke endpoint, and OAuth revocation is best-effort. Qualify this as a best-effort revoke for OAuth credentials so the fallback behavior and failure case are documented accurately.</violation>
</file>

<file name="app/src/main/java/ai/sealgate/stdiod/TunnelService.kt">

<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/TunnelService.kt:293">
P3: This duplicates the `TunnelStopReason` resource mapping already maintained in `MainActivity`, so changing a stop reason or its presentation can leave the notification and screen inconsistent. Move the mapping into one shared helper and use it from both classes.</violation>
</file>

<file name="app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt">

<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt:292">
P2: When the gateway field contains a malformed authority such as `wss://gateway:bad/path`, this check still accepts it. `Request.Builder().url(url)` then throws outside the guarded network call, so sign-in can crash instead of reporting an invalid gateway; validate the complete URL before returning it or move request construction inside the `try` blocks.</violation>
</file>

<file name="app/src/main/res/values/strings.xml">

<violation number="1" location="app/src/main/res/values/strings.xml:51">
P2: When the saved credential is an API key, sign-out only clears the local key and never revokes anything remotely, but this dialog promises dashboard revocation. Describe revocation as conditional and tell API-key users to connect again rather than sign in again.</violation>

<violation number="2" location="app/src/main/res/values/strings.xml:60">
P2: When the unauthorized reason is an unsupported protocol or a disabled organization, this accessibility description incorrectly blames an invalid credential and tells the user to sign in. Use wording that covers all terminal unauthorized reasons, or select the description from `state.reason` like the visible status does.</violation>
</file>

<file name="app/src/main/res/layout/dialog_device_sign_in.xml">

<violation number="1" location="app/src/main/res/layout/dialog_device_sign_in.xml:55">
P3: The indeterminate ProgressBar announces its own "loading" node alongside the adjacent "Waiting for approval…" text, so screen reader users hear redundant/unclear status. Mark the bar as decorative (importantForAccessibility=no) so only the labeled text is announced.</violation>
</file>

<file name="app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt">

<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt:65">
P2: When the remaining lifetime is no longer longer than the polling interval, `load` resumes a grant that cannot make its first poll before expiry. Reject records whose remaining time is less than or equal to the effective interval, rather than waiting for them to fail after resumption.</violation>
</file>

<file name="app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt">

<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt:25">
P2: When an `ewc_` token has no device ID, `isValid()` still allows connection, so `DeviceIdentityStore` substitutes a local UUID and the gateway rejects the credential. Require a nonblank device ID for `ewc_` credentials while retaining null for API-key mode.</violation>
</file>

<file name="app/src/main/java/ai/sealgate/stdiod/MainActivity.kt">

<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/MainActivity.kt:388">
P2: This auth path accepts gateway strings that the tunnel cannot use and can crash on malformed authorities. Validate or normalize the gateway with the same strict rules used by `TunnelConfig` before requesting device authorization.</violation>

<violation number="2" location="app/src/main/java/ai/sealgate/stdiod/MainActivity.kt:472">
P1: When approval completes while the browser still covers this activity, this call starts a foreground service from the background and can throw `ForegroundServiceStartNotAllowedException`. Defer the start until the activity is `STARTED`/resumed, then clear the pending grant after the start succeeds.</violation>

<violation number="3" location="app/src/main/java/ai/sealgate/stdiod/MainActivity.kt:512">
P2: When sign-out cancels a job still inside `requestDeviceCode`, `signInButton` remains disabled because cancellation bypasses the only re-enable path. Re-enable it when cancelling the sign-in job or wrap the whole job in a cancellation-safe `finally` block.</violation>
</file>

<file name="app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt">

<violation number="1" location="app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt:144">
P2: When `stop()` races with terminal close handling, `connectLoop()` can publish `Unauthorized` after `stop()` published `Disconnected`. Guard terminal publication against `stopped`, atomically with the stop state update, so explicit shutdown cannot leave a stale terminal status.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When approval completes while the browser still covers this activity, this call starts a foreground service from the background and can throw ForegroundServiceStartNotAllowedException. Defer the start until the activity is STARTED/resumed, then clear the pending grant after the start succeeds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/MainActivity.kt, line 472:

<comment>When approval completes while the browser still covers this activity, this call starts a foreground service from the background and can throw `ForegroundServiceStartNotAllowedException`. Defer the start until the activity is `STARTED`/resumed, then clear the pending grant after the start succeeds.</comment>

<file context>
@@ -315,6 +355,238 @@ class MainActivity : AppCompatActivity() {
+            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)
+        } catch (e: CancellationException) {
</file context>

val afterScheme = trimmed.substringAfter("://", "")
// Authority ends at the first '/', '?' or '#'.
val authority = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#')
if (authority.isBlank() || authority.startsWith(":")) return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the gateway field contains a malformed authority such as wss://gateway:bad/path, this check still accepts it. Request.Builder().url(url) then throws outside the guarded network call, so sign-in can crash instead of reporting an invalid gateway; validate the complete URL before returning it or move request construction inside the try blocks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt, line 292:

<comment>When the gateway field contains a malformed authority such as `wss://gateway:bad/path`, this check still accepts it. `Request.Builder().url(url)` then throws outside the guarded network call, so sign-in can crash instead of reporting an invalid gateway; validate the complete URL before returning it or move request construction inside the `try` blocks.</comment>

<file context>
@@ -0,0 +1,317 @@
+        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"
+    }
</file context>

<string name="tunnel_visual_connecting">Diagram: this phone is creating a secure connection to the SealGate gateway.</string>
<string name="tunnel_visual_connected">Diagram: this phone has a secure connection to the SealGate gateway.</string>
<string name="tunnel_visual_reconnecting">Diagram: the secure connection between this phone and the SealGate gateway was interrupted and is reconnecting automatically.</string>
<string name="tunnel_visual_sign_in_required">Diagram: this phone is disconnected from the SealGate gateway because its credential is no longer valid; sign in again to reconnect.</string>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the unauthorized reason is an unsupported protocol or a disabled organization, this accessibility description incorrectly blames an invalid credential and tells the user to sign in. Use wording that covers all terminal unauthorized reasons, or select the description from state.reason like the visible status does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/values/strings.xml, line 60:

<comment>When the unauthorized reason is an unsupported protocol or a disabled organization, this accessibility description incorrectly blames an invalid credential and tells the user to sign in. Use wording that covers all terminal unauthorized reasons, or select the description from `state.reason` like the visible status does.</comment>

<file context>
@@ -31,12 +31,33 @@
     <string name="tunnel_visual_connecting">Diagram: this phone is creating a secure connection to the SealGate gateway.</string>
     <string name="tunnel_visual_connected">Diagram: this phone has a secure connection to the SealGate gateway.</string>
     <string name="tunnel_visual_reconnecting">Diagram: the secure connection between this phone and the SealGate gateway was interrupted and is reconnecting automatically.</string>
+    <string name="tunnel_visual_sign_in_required">Diagram: this phone is disconnected from the SealGate gateway because its credential is no longer valid; sign in again to reconnect.</string>
 
     <string name="tunnel_channel_name">Mobile Tunnel</string>
</file context>
Suggested change
<string name="tunnel_visual_sign_in_required">Diagram: this phone is disconnected from the SealGate gateway because its credential is no longer valid; sign in again to reconnect.</string>
<string name="tunnel_visual_sign_in_required">Diagram: this phone is disconnected from the SealGate gateway and needs attention before it can reconnect.</string>


<string name="action_sign_out">Sign out</string>
<string name="sign_out_dialog_title">Sign out?</string>
<string name="sign_out_dialog_message">This stops the tunnel, forgets this device\'s credential, and revokes it in the dashboard. You will need to sign in again to reconnect.</string>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the saved credential is an API key, sign-out only clears the local key and never revokes anything remotely, but this dialog promises dashboard revocation. Describe revocation as conditional and tell API-key users to connect again rather than sign in again.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/values/strings.xml, line 51:

<comment>When the saved credential is an API key, sign-out only clears the local key and never revokes anything remotely, but this dialog promises dashboard revocation. Describe revocation as conditional and tell API-key users to connect again rather than sign in again.</comment>

<file context>
@@ -31,12 +31,33 @@
+
+    <string name="action_sign_out">Sign out</string>
+    <string name="sign_out_dialog_title">Sign out?</string>
+    <string name="sign_out_dialog_message">This stops the tunnel, forgets this device\'s credential, and revokes it in the dashboard. You will need to sign in again to reconnect.</string>
+    <string name="sign_out_in_progress">Signing out…</string>
+    <string name="sign_out_done">Signed out.</string>
</file context>
Suggested change
<string name="sign_out_dialog_message">This stops the tunnel, forgets this device\'s credential, and revokes it in the dashboard. You will need to sign in again to reconnect.</string>
<string name="sign_out_dialog_message">This stops the tunnel and forgets this device\'s credential. OAuth credentials are also revoked in the dashboard when possible. You will need to connect again to reconnect.</string>

Comment thread README.md
Comment on lines +112 to +114
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For users upgrading with an existing API key, this statement is false until the settings are saved again: legacy plaintext is read unchanged and is not re-encrypted during load. Document the migration caveat so users do not assume the credential is already protected immediately after upgrading.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 112:

<comment>For users upgrading with an existing API key, this statement is false until the settings are saved again: legacy plaintext is read unchanged and is not re-encrypted during load. Document the migration caveat so users do not assume the credential is already protected immediately after upgrading.</comment>

<file context>
@@ -94,9 +94,29 @@ 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
</file context>
Suggested change
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
Newly saved credentials (and the in-flight PKCE verifier of an interrupted sign-in) are
stored encrypted at rest with an AES-256-GCM key held in the AndroidKeyStore
(`SecretCipher`). Existing plaintext credentials are re-encrypted the next time
settings are saved, so a prefs dump or a backup restored to another phone cannot
lift a newly saved credential.

if (signInJob?.isActive == true) return
val gatewayUrl = binding.gatewayUrlInput.text?.toString()?.trim().orEmpty()
binding.gatewayUrlLayout.error = null
val apiBase = GatewayUrls.apiBaseFromWs(gatewayUrl)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This auth path accepts gateway strings that the tunnel cannot use and can crash on malformed authorities. Validate or normalize the gateway with the same strict rules used by TunnelConfig before requesting device authorization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/MainActivity.kt, line 388:

<comment>This auth path accepts gateway strings that the tunnel cannot use and can crash on malformed authorities. Validate or normalize the gateway with the same strict rules used by `TunnelConfig` before requesting device authorization.</comment>

<file context>
@@ -315,6 +355,238 @@ class MainActivity : AppCompatActivity() {
+        if (signInJob?.isActive == true) return
+        val gatewayUrl = binding.gatewayUrlInput.text?.toString()?.trim().orEmpty()
+        binding.gatewayUrlLayout.error = null
+        val apiBase = GatewayUrls.apiBaseFromWs(gatewayUrl)
+        if (apiBase == null) {
+            binding.gatewayUrlLayout.error = getString(R.string.error_gateway_url)
</file context>

_state.value = TunnelState.Connecting
val sessionSawHello = runOneConnection()
val outcome = runOneConnection()
if (outcome.terminalReason != null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When stop() races with terminal close handling, connectLoop() can publish Unauthorized after stop() published Disconnected. Guard terminal publication against stopped, atomically with the stop state update, so explicit shutdown cannot leave a stale terminal status.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt, line 144:

<comment>When `stop()` races with terminal close handling, `connectLoop()` can publish `Unauthorized` after `stop()` published `Disconnected`. Guard terminal publication against `stopped`, atomically with the stop state update, so explicit shutdown cannot leave a stale terminal status.</comment>

<file context>
@@ -121,11 +140,19 @@ class TunnelClient(
             _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
</file context>

Comment thread README.md
(`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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Sign out does not always revoke an installation: API-key sign-out skips the revoke endpoint, and OAuth revocation is best-effort. Qualify this as a best-effort revoke for OAuth credentials so the fallback behavior and failure case are documented accurately.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 117:

<comment>Sign out does not always revoke an installation: API-key sign-out skips the revoke endpoint, and OAuth revocation is best-effort. Qualify this as a best-effort revoke for OAuth credentials so the fallback behavior and failure case are documented accurately.</comment>

<file context>
@@ -94,9 +94,29 @@ per-file limit, and 32 MiB total virtual filesystem limit.
+   (`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.
</file context>
Suggested change
dashboard (`POST /api/v1/auth/device/revoke`). If the gateway later revokes the
+ dashboard for OAuth credentials on a best-effort basis (`POST /api/v1/auth/device/revoke`). If the gateway later revokes the

)
}

private fun stopReasonStatus(reason: TunnelStopReason): Int =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This duplicates the TunnelStopReason resource mapping already maintained in MainActivity, so changing a stop reason or its presentation can leave the notification and screen inconsistent. Move the mapping into one shared helper and use it from both classes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/ai/sealgate/stdiod/TunnelService.kt, line 293:

<comment>This duplicates the `TunnelStopReason` resource mapping already maintained in `MainActivity`, so changing a stop reason or its presentation can leave the notification and screen inconsistent. Move the mapping into one shared helper and use it from both classes.</comment>

<file context>
@@ -281,6 +284,17 @@ class TunnelService : LifecycleService() {
+            )
+        }
+
+    private fun stopReasonStatus(reason: TunnelStopReason): Int =
+        when (reason) {
+            TunnelStopReason.CREDENTIAL_REJECTED -> R.string.tunnel_state_sign_in_required
</file context>

android:gravity="center_vertical"
android:orientation="horizontal">

<ProgressBar

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The indeterminate ProgressBar announces its own "loading" node alongside the adjacent "Waiting for approval…" text, so screen reader users hear redundant/unclear status. Mark the bar as decorative (importantForAccessibility=no) so only the labeled text is announced.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/layout/dialog_device_sign_in.xml, line 55:

<comment>The indeterminate ProgressBar announces its own "loading" node alongside the adjacent "Waiting for approval…" text, so screen reader users hear redundant/unclear status. Mark the bar as decorative (importantForAccessibility=no) so only the labeled text is announced.</comment>

<file context>
@@ -0,0 +1,68 @@
+        android:gravity="center_vertical"
+        android:orientation="horizontal">
+
+        <ProgressBar
+            android:layout_width="18dp"
+            android:layout_height="18dp"
</file context>

@Miyamura80
Miyamura80 merged commit 40c2314 into main Sep 17, 2026
3 checks passed
@github-actions
github-actions Bot deleted the claude/oauth-mobile-app-auth-wsrfyd branch September 17, 2026 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants