From 589aca0e6d7361ae5cbbd5e8ee988f1129e9940b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 12 Mar 2026 22:35:50 +0000 Subject: [PATCH] refactor: unify gateway connect auth selection --- .../openclaw/app/gateway/DeviceAuthStore.kt | 3 +- .../ai/openclaw/app/gateway/GatewaySession.kt | 233 +++++++++++++++--- .../app/gateway/GatewaySessionInvokeTest.kt | 69 ++++++ .../Sources/OpenClawKit/GatewayChannel.swift | 110 ++++++--- src/gateway/client.ts | 91 ++++--- ui/src/ui/gateway.ts | 79 +++--- 6 files changed, 452 insertions(+), 133 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt index d1ac63a90..202ea4820 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt @@ -5,6 +5,7 @@ import ai.openclaw.app.SecurePrefs interface DeviceAuthTokenStore { fun loadToken(deviceId: String, role: String): String? fun saveToken(deviceId: String, role: String, token: String) + fun clearToken(deviceId: String, role: String) } class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore { @@ -18,7 +19,7 @@ class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore { prefs.putString(key, token.trim()) } - fun clearToken(deviceId: String, role: String) { + override fun clearToken(deviceId: String, role: String) { val key = tokenKey(deviceId, role) prefs.remove(key) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 6f6daa321..55e371a57 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -52,6 +52,33 @@ data class GatewayConnectOptions( val userAgent: String? = null, ) +private enum class GatewayConnectAuthSource { + DEVICE_TOKEN, + SHARED_TOKEN, + BOOTSTRAP_TOKEN, + PASSWORD, + NONE, +} + +data class GatewayConnectErrorDetails( + val code: String?, + val canRetryWithDeviceToken: Boolean, + val recommendedNextStep: String?, +) + +private data class SelectedConnectAuth( + val authToken: String?, + val authBootstrapToken: String?, + val authDeviceToken: String?, + val authPassword: String?, + val signatureToken: String?, + val authSource: GatewayConnectAuthSource, + val attemptedDeviceTokenRetry: Boolean, +) + +private class GatewayConnectFailure(val gatewayError: GatewaySession.ErrorShape) : + IllegalStateException(gatewayError.message) + class GatewaySession( private val scope: CoroutineScope, private val identityStore: DeviceIdentityStore, @@ -83,7 +110,11 @@ class GatewaySession( } } - data class ErrorShape(val code: String, val message: String) + data class ErrorShape( + val code: String, + val message: String, + val details: GatewayConnectErrorDetails? = null, + ) private val json = Json { ignoreUnknownKeys = true } private val writeLock = Mutex() @@ -104,6 +135,9 @@ class GatewaySession( private var desired: DesiredConnection? = null private var job: Job? = null @Volatile private var currentConnection: Connection? = null + @Volatile private var pendingDeviceTokenRetry = false + @Volatile private var deviceTokenRetryBudgetUsed = false + @Volatile private var reconnectPausedForAuthFailure = false fun connect( endpoint: GatewayEndpoint, @@ -114,6 +148,9 @@ class GatewaySession( tls: GatewayTlsParams? = null, ) { desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls) + pendingDeviceTokenRetry = false + deviceTokenRetryBudgetUsed = false + reconnectPausedForAuthFailure = false if (job == null) { job = scope.launch(Dispatchers.IO) { runLoop() } } @@ -121,6 +158,9 @@ class GatewaySession( fun disconnect() { desired = null + pendingDeviceTokenRetry = false + deviceTokenRetryBudgetUsed = false + reconnectPausedForAuthFailure = false currentConnection?.closeQuietly() scope.launch(Dispatchers.IO) { job?.cancelAndJoin() @@ -132,6 +172,7 @@ class GatewaySession( } fun reconnect() { + reconnectPausedForAuthFailure = false currentConnection?.closeQuietly() } @@ -347,24 +388,48 @@ class GatewaySession( private suspend fun sendConnect(connectNonce: String) { val identity = identityStore.loadOrCreate() - val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) - val trimmedToken = token?.trim().orEmpty() - val trimmedBootstrapToken = bootstrapToken?.trim().orEmpty() - // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding. - val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty() - val authBootstrapToken = if (authToken.isBlank()) trimmedBootstrapToken else "" + val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)?.trim() + val selectedAuth = + selectConnectAuth( + endpoint = endpoint, + tls = tls, + role = options.role, + explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() }, + explicitBootstrapToken = bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }, + explicitPassword = password?.trim()?.takeIf { it.isNotEmpty() }, + storedToken = storedToken?.takeIf { it.isNotEmpty() }, + ) + if (selectedAuth.attemptedDeviceTokenRetry) { + pendingDeviceTokenRetry = false + } val payload = buildConnectParams( identity = identity, connectNonce = connectNonce, - authToken = authToken, - authBootstrapToken = authBootstrapToken, - authPassword = password?.trim(), + selectedAuth = selectedAuth, ) val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS) if (!res.ok) { - val msg = res.error?.message ?: "connect failed" - throw IllegalStateException(msg) + val error = res.error ?: ErrorShape("UNAVAILABLE", "connect failed") + val shouldRetryWithDeviceToken = + shouldRetryWithStoredDeviceToken( + error = error, + explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() }, + storedToken = storedToken?.takeIf { it.isNotEmpty() }, + attemptedDeviceTokenRetry = selectedAuth.attemptedDeviceTokenRetry, + endpoint = endpoint, + tls = tls, + ) + if (shouldRetryWithDeviceToken) { + pendingDeviceTokenRetry = true + deviceTokenRetryBudgetUsed = true + } else if ( + selectedAuth.attemptedDeviceTokenRetry && + shouldClearStoredDeviceTokenAfterRetry(error) + ) { + deviceAuthStore.clearToken(identity.deviceId, options.role) + } + throw GatewayConnectFailure(error) } handleConnectSuccess(res, identity.deviceId) connectDeferred.complete(Unit) @@ -373,6 +438,9 @@ class GatewaySession( private fun handleConnectSuccess(res: RpcResponse, deviceId: String) { val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") + pendingDeviceTokenRetry = false + deviceTokenRetryBudgetUsed = false + reconnectPausedForAuthFailure = false val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() val authObj = obj["auth"].asObjectOrNull() val deviceToken = authObj?.get("deviceToken").asStringOrNull() @@ -392,9 +460,7 @@ class GatewaySession( private fun buildConnectParams( identity: DeviceIdentity, connectNonce: String, - authToken: String, - authBootstrapToken: String, - authPassword: String?, + selectedAuth: SelectedConnectAuth, ): JsonObject { val client = options.client val locale = Locale.getDefault().toLanguageTag() @@ -410,20 +476,20 @@ class GatewaySession( client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } } - val password = authPassword?.trim().orEmpty() val authJson = when { - authToken.isNotEmpty() -> + selectedAuth.authToken != null -> buildJsonObject { - put("token", JsonPrimitive(authToken)) + put("token", JsonPrimitive(selectedAuth.authToken)) + selectedAuth.authDeviceToken?.let { put("deviceToken", JsonPrimitive(it)) } } - authBootstrapToken.isNotEmpty() -> + selectedAuth.authBootstrapToken != null -> buildJsonObject { - put("bootstrapToken", JsonPrimitive(authBootstrapToken)) + put("bootstrapToken", JsonPrimitive(selectedAuth.authBootstrapToken)) } - password.isNotEmpty() -> + selectedAuth.authPassword != null -> buildJsonObject { - put("password", JsonPrimitive(password)) + put("password", JsonPrimitive(selectedAuth.authPassword)) } else -> null } @@ -437,12 +503,7 @@ class GatewaySession( role = options.role, scopes = options.scopes, signedAtMs = signedAtMs, - token = - when { - authToken.isNotEmpty() -> authToken - authBootstrapToken.isNotEmpty() -> authBootstrapToken - else -> null - }, + token = selectedAuth.signatureToken, nonce = connectNonce, platform = client.platform, deviceFamily = client.deviceFamily, @@ -505,7 +566,16 @@ class GatewaySession( frame["error"]?.asObjectOrNull()?.let { obj -> val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE" val msg = obj["message"].asStringOrNull() ?: "request failed" - ErrorShape(code, msg) + val detailObj = obj["details"].asObjectOrNull() + val details = + detailObj?.let { + GatewayConnectErrorDetails( + code = it["code"].asStringOrNull(), + canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true, + recommendedNextStep = it["recommendedNextStep"].asStringOrNull(), + ) + } + ErrorShape(code, msg, details) } pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error)) } @@ -629,6 +699,10 @@ class GatewaySession( delay(250) continue } + if (reconnectPausedForAuthFailure) { + delay(250) + continue + } try { onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") @@ -637,6 +711,13 @@ class GatewaySession( } catch (err: Throwable) { attempt += 1 onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}") + if ( + err is GatewayConnectFailure && + shouldPauseReconnectAfterAuthFailure(err.gatewayError) + ) { + reconnectPausedForAuthFailure = true + continue + } val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong()) delay(sleepMs) } @@ -728,6 +809,100 @@ class GatewaySession( if (host == "0.0.0.0" || host == "::") return true return host.startsWith("127.") } + + private fun selectConnectAuth( + endpoint: GatewayEndpoint, + tls: GatewayTlsParams?, + role: String, + explicitGatewayToken: String?, + explicitBootstrapToken: String?, + explicitPassword: String?, + storedToken: String?, + ): SelectedConnectAuth { + val shouldUseDeviceRetryToken = + pendingDeviceTokenRetry && + explicitGatewayToken != null && + storedToken != null && + isTrustedDeviceRetryEndpoint(endpoint, tls) + val authToken = + explicitGatewayToken + ?: if ( + explicitPassword == null && + (explicitBootstrapToken == null || storedToken != null) + ) { + storedToken + } else { + null + } + val authDeviceToken = if (shouldUseDeviceRetryToken) storedToken else null + val authBootstrapToken = if (authToken == null) explicitBootstrapToken else null + val authSource = + when { + authDeviceToken != null || (explicitGatewayToken == null && authToken != null) -> + GatewayConnectAuthSource.DEVICE_TOKEN + authToken != null -> GatewayConnectAuthSource.SHARED_TOKEN + authBootstrapToken != null -> GatewayConnectAuthSource.BOOTSTRAP_TOKEN + explicitPassword != null -> GatewayConnectAuthSource.PASSWORD + else -> GatewayConnectAuthSource.NONE + } + return SelectedConnectAuth( + authToken = authToken, + authBootstrapToken = authBootstrapToken, + authDeviceToken = authDeviceToken, + authPassword = explicitPassword, + signatureToken = authToken ?: authBootstrapToken, + authSource = authSource, + attemptedDeviceTokenRetry = shouldUseDeviceRetryToken, + ) + } + + private fun shouldRetryWithStoredDeviceToken( + error: ErrorShape, + explicitGatewayToken: String?, + storedToken: String?, + attemptedDeviceTokenRetry: Boolean, + endpoint: GatewayEndpoint, + tls: GatewayTlsParams?, + ): Boolean { + if (deviceTokenRetryBudgetUsed) return false + if (attemptedDeviceTokenRetry) return false + if (explicitGatewayToken == null || storedToken == null) return false + if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false + val detailCode = error.details?.code + val recommendedNextStep = error.details?.recommendedNextStep + return error.details?.canRetryWithDeviceToken == true || + recommendedNextStep == "retry_with_device_token" || + detailCode == "AUTH_TOKEN_MISMATCH" + } + + private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean { + return when (error.details?.code) { + "AUTH_TOKEN_MISSING", + "AUTH_BOOTSTRAP_TOKEN_INVALID", + "AUTH_PASSWORD_MISSING", + "AUTH_PASSWORD_MISMATCH", + "AUTH_RATE_LIMITED", + "PAIRING_REQUIRED", + "CONTROL_UI_DEVICE_IDENTITY_REQUIRED", + "DEVICE_IDENTITY_REQUIRED" -> true + "AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry + else -> false + } + } + + private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean { + return error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH" + } + + private fun isTrustedDeviceRetryEndpoint( + endpoint: GatewayEndpoint, + tls: GatewayTlsParams?, + ): Boolean { + if (isLoopbackHost(endpoint.host)) { + return true + } + return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true + } } private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index 24f149114..2cfa1be48 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -27,6 +27,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference private const val TEST_TIMEOUT_MS = 8_000L @@ -41,6 +42,10 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { override fun saveToken(deviceId: String, role: String, token: String) { tokens["${deviceId.trim()}|${role.trim()}"] = token.trim() } + + override fun clearToken(deviceId: String, role: String) { + tokens.remove("${deviceId.trim()}|${role.trim()}") + } } private data class NodeHarness( @@ -144,6 +149,70 @@ class GatewaySessionInvokeTest { } } + @Test + fun connect_retriesWithStoredDeviceTokenAfterSharedTokenMismatch() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val firstConnectAuth = CompletableDeferred() + val secondConnectAuth = CompletableDeferred() + val connectAttempts = AtomicInteger(0) + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + val auth = frame["params"]?.jsonObject?.get("auth")?.jsonObject + when (connectAttempts.incrementAndGet()) { + 1 -> { + if (!firstConnectAuth.isCompleted) { + firstConnectAuth.complete(auth) + } + webSocket.send( + """{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unauthorized","details":{"code":"AUTH_TOKEN_MISMATCH","canRetryWithDeviceToken":true,"recommendedNextStep":"retry_with_device_token"}}}""", + ) + webSocket.close(1000, "retry") + } + else -> { + if (!secondConnectAuth.isCompleted) { + secondConnectAuth.complete(auth) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + harness.deviceAuthStore.saveToken(deviceId, "node", "stored-device-token") + + connectNodeSession( + session = harness.session, + port = server.port, + token = "shared-auth-token", + bootstrapToken = null, + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val firstAuth = withTimeout(TEST_TIMEOUT_MS) { firstConnectAuth.await() } + val secondAuth = withTimeout(TEST_TIMEOUT_MS) { secondConnectAuth.await() } + assertEquals("shared-auth-token", firstAuth?.get("token")?.jsonPrimitive?.content) + assertNull(firstAuth?.get("deviceToken")) + assertEquals("shared-auth-token", secondAuth?.get("token")?.jsonPrimitive?.content) + assertEquals("stored-device-token", secondAuth?.get("deviceToken")?.jsonPrimitive?.content) + } finally { + shutdownHarness(harness, server) + } + } + @Test fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { val handshakeOrigin = AtomicReference(null) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index b78e3813b..2c3da84af 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -138,6 +138,16 @@ private extension String { } } +private struct SelectedConnectAuth: Sendable { + let authToken: String? + let authBootstrapToken: String? + let authDeviceToken: String? + let authPassword: String? + let signatureToken: String? + let storedToken: String? + let authSource: GatewayAuthSource +} + private enum GatewayConnectErrorCodes { static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue @@ -408,48 +418,24 @@ public actor GatewayChannelActor { } let includeDeviceIdentity = options.includeDeviceIdentity let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil - let storedToken = - (includeDeviceIdentity && identity != nil) - ? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token - : nil - let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - let explicitBootstrapToken = - self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - let shouldUseDeviceRetryToken = - includeDeviceIdentity && self.pendingDeviceTokenRetry && - storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint() - if shouldUseDeviceRetryToken { + let selectedAuth = self.selectConnectAuth( + role: role, + includeDeviceIdentity: includeDeviceIdentity, + deviceId: identity?.deviceId) + if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry { self.pendingDeviceTokenRetry = false } - // Keep shared credentials explicit when provided. Device token retry is attached - // only on a bounded second attempt after token mismatch. - let authToken = explicitToken ?? (includeDeviceIdentity ? storedToken : nil) - let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil - let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil - let authSource: GatewayAuthSource - if authDeviceToken != nil || (explicitToken == nil && storedToken != nil) { - authSource = .deviceToken - } else if authToken != nil { - authSource = .sharedToken - } else if authBootstrapToken != nil { - authSource = .bootstrapToken - } else if explicitPassword != nil { - authSource = .password - } else { - authSource = .none - } - self.lastAuthSource = authSource - self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)") - if let authToken { + self.lastAuthSource = selectedAuth.authSource + self.logger.info("gateway connect auth=\(selectedAuth.authSource.rawValue, privacy: .public)") + if let authToken = selectedAuth.authToken { var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)] - if let authDeviceToken { + if let authDeviceToken = selectedAuth.authDeviceToken { auth["deviceToken"] = ProtoAnyCodable(authDeviceToken) } params["auth"] = ProtoAnyCodable(auth) - } else if let authBootstrapToken { + } else if let authBootstrapToken = selectedAuth.authBootstrapToken { params["auth"] = ProtoAnyCodable(["bootstrapToken": ProtoAnyCodable(authBootstrapToken)]) - } else if let password = explicitPassword { + } else if let password = selectedAuth.authPassword { params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) @@ -462,7 +448,7 @@ public actor GatewayChannelActor { role: role, scopes: scopes, signedAtMs: signedAtMs, - token: authToken ?? authBootstrapToken, + token: selectedAuth.signatureToken, nonce: connectNonce, platform: platform, deviceFamily: InstanceIdentity.deviceFamily) @@ -491,14 +477,14 @@ public actor GatewayChannelActor { } catch { let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken( error: error, - explicitGatewayToken: explicitToken, - storedToken: storedToken, - attemptedDeviceTokenRetry: authDeviceToken != nil) + explicitGatewayToken: self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + storedToken: selectedAuth.storedToken, + attemptedDeviceTokenRetry: selectedAuth.authDeviceToken != nil) if shouldRetryWithDeviceToken { self.pendingDeviceTokenRetry = true self.deviceTokenRetryBudgetUsed = true self.backoffMs = min(self.backoffMs, 250) - } else if authDeviceToken != nil, + } else if selectedAuth.authDeviceToken != nil, let identity, self.shouldClearStoredDeviceTokenAfterRetry(error) { @@ -509,6 +495,50 @@ public actor GatewayChannelActor { } } + private func selectConnectAuth( + role: String, + includeDeviceIdentity: Bool, + deviceId: String? + ) -> SelectedConnectAuth { + let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let explicitBootstrapToken = + self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let storedToken = + (includeDeviceIdentity && deviceId != nil) + ? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token + : nil + let shouldUseDeviceRetryToken = + includeDeviceIdentity && self.pendingDeviceTokenRetry && + storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint() + let authToken = + explicitToken ?? + (includeDeviceIdentity && explicitPassword == nil && + (explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil) + let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil + let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil + let authSource: GatewayAuthSource + if authDeviceToken != nil || (explicitToken == nil && authToken != nil) { + authSource = .deviceToken + } else if authToken != nil { + authSource = .sharedToken + } else if authBootstrapToken != nil { + authSource = .bootstrapToken + } else if explicitPassword != nil { + authSource = .password + } else { + authSource = .none + } + return SelectedConnectAuth( + authToken: authToken, + authBootstrapToken: authBootstrapToken, + authDeviceToken: authDeviceToken, + authPassword: explicitPassword, + signatureToken: authToken ?? authBootstrapToken, + storedToken: storedToken, + authSource: authSource) + } + private func handleConnectResponse( _ res: ResponseFrame, identity: DeviceIdentity?, diff --git a/src/gateway/client.ts b/src/gateway/client.ts index eb6461e54..9e98a9bc0 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -52,6 +52,16 @@ type GatewayClientErrorShape = { details?: unknown; }; +type SelectedConnectAuth = { + authToken?: string; + authBootstrapToken?: string; + authDeviceToken?: string; + authPassword?: string; + signatureToken?: string; + resolvedDeviceToken?: string; + storedToken?: string; +}; + class GatewayClientRequestError extends Error { readonly gatewayCode: string; readonly details?: unknown; @@ -281,43 +291,24 @@ export class GatewayClient { this.connectTimer = null; } const role = this.opts.role ?? "operator"; - const explicitGatewayToken = this.opts.token?.trim() || undefined; - const explicitBootstrapToken = this.opts.bootstrapToken?.trim() || undefined; - const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined; - const storedToken = this.opts.deviceIdentity - ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token - : null; - const shouldUseDeviceRetryToken = - this.pendingDeviceTokenRetry && - !explicitDeviceToken && - Boolean(explicitGatewayToken) && - Boolean(storedToken) && - this.isTrustedDeviceRetryEndpoint(); - if (shouldUseDeviceRetryToken) { + const { + authToken, + authBootstrapToken, + authDeviceToken, + authPassword, + signatureToken, + resolvedDeviceToken, + storedToken, + } = this.selectConnectAuth(role); + if (this.pendingDeviceTokenRetry && authDeviceToken) { this.pendingDeviceTokenRetry = false; } - // Shared gateway credentials stay explicit. Bootstrap tokens are different: - // once a role-scoped device token exists, it should take precedence so the - // temporary bootstrap secret falls out of active use. - const resolvedDeviceToken = - explicitDeviceToken ?? - (shouldUseDeviceRetryToken || - (!(explicitGatewayToken || this.opts.password?.trim()) && - (!explicitBootstrapToken || Boolean(storedToken))) - ? (storedToken ?? undefined) - : undefined); - // Legacy compatibility: keep `auth.token` populated for device-token auth when - // no explicit shared token is present. - const authToken = explicitGatewayToken ?? resolvedDeviceToken; - const authBootstrapToken = - !explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined; - const authPassword = this.opts.password?.trim() || undefined; const auth = authToken || authBootstrapToken || authPassword || resolvedDeviceToken ? { token: authToken, bootstrapToken: authBootstrapToken, - deviceToken: resolvedDeviceToken, + deviceToken: authDeviceToken ?? resolvedDeviceToken, password: authPassword, } : undefined; @@ -335,7 +326,7 @@ export class GatewayClient { role, scopes, signedAtMs, - token: authToken ?? authBootstrapToken ?? null, + token: signatureToken ?? null, nonce, platform, deviceFamily: this.opts.deviceFamily, @@ -402,7 +393,7 @@ export class GatewayClient { err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null; const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({ error: err, - explicitGatewayToken, + explicitGatewayToken: this.opts.token?.trim() || undefined, resolvedDeviceToken, storedToken: storedToken ?? undefined, }); @@ -503,6 +494,42 @@ export class GatewayClient { } } + private selectConnectAuth(role: string): SelectedConnectAuth { + const explicitGatewayToken = this.opts.token?.trim() || undefined; + const explicitBootstrapToken = this.opts.bootstrapToken?.trim() || undefined; + const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined; + const authPassword = this.opts.password?.trim() || undefined; + const storedToken = this.opts.deviceIdentity + ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token + : null; + const shouldUseDeviceRetryToken = + this.pendingDeviceTokenRetry && + !explicitDeviceToken && + Boolean(explicitGatewayToken) && + Boolean(storedToken) && + this.isTrustedDeviceRetryEndpoint(); + const resolvedDeviceToken = + explicitDeviceToken ?? + (shouldUseDeviceRetryToken || + (!(explicitGatewayToken || authPassword) && (!explicitBootstrapToken || Boolean(storedToken))) + ? (storedToken ?? undefined) + : undefined); + // Legacy compatibility: keep `auth.token` populated for device-token auth when + // no explicit shared token is present. + const authToken = explicitGatewayToken ?? resolvedDeviceToken; + const authBootstrapToken = + !explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined; + return { + authToken, + authBootstrapToken, + authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined, + authPassword, + signatureToken: authToken ?? authBootstrapToken ?? undefined, + resolvedDeviceToken, + storedToken: storedToken ?? undefined, + }; + } + private handleMessage(raw: string) { try { const parsed = JSON.parse(raw); diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 53cfa086f..7c9580795 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -119,6 +119,15 @@ type Pending = { reject: (err: unknown) => void; }; +type SelectedConnectAuth = { + authToken?: string; + authDeviceToken?: string; + authPassword?: string; + resolvedDeviceToken?: string; + storedToken?: string; + canFallbackToShared: boolean; +}; + export type GatewayBrowserClientOptions = { url: string; token?: string; @@ -236,40 +245,27 @@ export class GatewayBrowserClient { const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const role = "operator"; let deviceIdentity: Awaited> | null = null; - let canFallbackToShared = false; - const explicitGatewayToken = this.opts.token?.trim() || undefined; - let authToken = explicitGatewayToken; - let deviceToken: string | undefined; + let selectedAuth: SelectedConnectAuth = { canFallbackToShared: false }; if (isSecureContext) { deviceIdentity = await loadOrCreateDeviceIdentity(); - const storedToken = loadDeviceAuthToken({ - deviceId: deviceIdentity.deviceId, + selectedAuth = this.selectConnectAuth({ role, - })?.token; - const shouldUseDeviceRetryToken = - this.pendingDeviceTokenRetry && - !deviceToken && - Boolean(explicitGatewayToken) && - Boolean(storedToken) && - isTrustedRetryEndpoint(this.opts.url); - if (shouldUseDeviceRetryToken) { - deviceToken = storedToken ?? undefined; + deviceId: deviceIdentity.deviceId, + }); + if (this.pendingDeviceTokenRetry && selectedAuth.authDeviceToken) { this.pendingDeviceTokenRetry = false; - } else { - deviceToken = !(explicitGatewayToken || this.opts.password?.trim()) - ? (storedToken ?? undefined) - : undefined; } - canFallbackToShared = Boolean(deviceToken && explicitGatewayToken); } - authToken = explicitGatewayToken ?? deviceToken; + const explicitGatewayToken = this.opts.token?.trim() || undefined; + const authToken = selectedAuth.authToken; + const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken; const auth = - authToken || this.opts.password + authToken || selectedAuth.authPassword ? { token: authToken, deviceToken, - password: this.opts.password, + password: selectedAuth.authPassword, } : undefined; @@ -352,15 +348,10 @@ export class GatewayBrowserClient { connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH; const shouldRetryWithDeviceToken = !this.deviceTokenRetryBudgetUsed && - !deviceToken && + !selectedAuth.authDeviceToken && Boolean(explicitGatewayToken) && Boolean(deviceIdentity) && - Boolean( - loadDeviceAuthToken({ - deviceId: deviceIdentity?.deviceId ?? "", - role, - })?.token, - ) && + Boolean(selectedAuth.storedToken) && canRetryWithDeviceTokenHint && isTrustedRetryEndpoint(this.opts.url); if (shouldRetryWithDeviceToken) { @@ -377,7 +368,7 @@ export class GatewayBrowserClient { this.pendingConnectError = undefined; } if ( - canFallbackToShared && + selectedAuth.canFallbackToShared && deviceIdentity && connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ) { @@ -444,6 +435,32 @@ export class GatewayBrowserClient { } } + private selectConnectAuth(params: { role: string; deviceId: string }): SelectedConnectAuth { + const explicitGatewayToken = this.opts.token?.trim() || undefined; + const authPassword = this.opts.password?.trim() || undefined; + const storedToken = loadDeviceAuthToken({ + deviceId: params.deviceId, + role: params.role, + })?.token; + const shouldUseDeviceRetryToken = + this.pendingDeviceTokenRetry && + Boolean(explicitGatewayToken) && + Boolean(storedToken) && + isTrustedRetryEndpoint(this.opts.url); + const resolvedDeviceToken = !(explicitGatewayToken || authPassword) + ? (storedToken ?? undefined) + : undefined; + const authToken = explicitGatewayToken ?? resolvedDeviceToken; + return { + authToken, + authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined, + authPassword, + resolvedDeviceToken, + storedToken: storedToken ?? undefined, + canFallbackToShared: Boolean(storedToken && explicitGatewayToken), + }; + } + request(method: string, params?: unknown): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error("gateway not connected"));