refactor: unify gateway connect auth selection

This commit is contained in:
Peter Steinberger
2026-03-12 22:35:50 +00:00
parent 2c8f31135b
commit 589aca0e6d
6 changed files with 452 additions and 133 deletions

View File

@@ -5,6 +5,7 @@ import ai.openclaw.app.SecurePrefs
interface DeviceAuthTokenStore { interface DeviceAuthTokenStore {
fun loadToken(deviceId: String, role: String): String? fun loadToken(deviceId: String, role: String): String?
fun saveToken(deviceId: String, role: String, token: String) fun saveToken(deviceId: String, role: String, token: String)
fun clearToken(deviceId: String, role: String)
} }
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore { class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
@@ -18,7 +19,7 @@ class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
prefs.putString(key, token.trim()) prefs.putString(key, token.trim())
} }
fun clearToken(deviceId: String, role: String) { override fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role) val key = tokenKey(deviceId, role)
prefs.remove(key) prefs.remove(key)
} }

View File

@@ -52,6 +52,33 @@ data class GatewayConnectOptions(
val userAgent: String? = null, 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( class GatewaySession(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore, 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 json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex() private val writeLock = Mutex()
@@ -104,6 +135,9 @@ class GatewaySession(
private var desired: DesiredConnection? = null private var desired: DesiredConnection? = null
private var job: Job? = null private var job: Job? = null
@Volatile private var currentConnection: Connection? = null @Volatile private var currentConnection: Connection? = null
@Volatile private var pendingDeviceTokenRetry = false
@Volatile private var deviceTokenRetryBudgetUsed = false
@Volatile private var reconnectPausedForAuthFailure = false
fun connect( fun connect(
endpoint: GatewayEndpoint, endpoint: GatewayEndpoint,
@@ -114,6 +148,9 @@ class GatewaySession(
tls: GatewayTlsParams? = null, tls: GatewayTlsParams? = null,
) { ) {
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls) desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
if (job == null) { if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() } job = scope.launch(Dispatchers.IO) { runLoop() }
} }
@@ -121,6 +158,9 @@ class GatewaySession(
fun disconnect() { fun disconnect() {
desired = null desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly() currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
job?.cancelAndJoin() job?.cancelAndJoin()
@@ -132,6 +172,7 @@ class GatewaySession(
} }
fun reconnect() { fun reconnect() {
reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly() currentConnection?.closeQuietly()
} }
@@ -347,24 +388,48 @@ class GatewaySession(
private suspend fun sendConnect(connectNonce: String) { private suspend fun sendConnect(connectNonce: String) {
val identity = identityStore.loadOrCreate() val identity = identityStore.loadOrCreate()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)?.trim()
val trimmedToken = token?.trim().orEmpty() val selectedAuth =
val trimmedBootstrapToken = bootstrapToken?.trim().orEmpty() selectConnectAuth(
// QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding. endpoint = endpoint,
val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty() tls = tls,
val authBootstrapToken = if (authToken.isBlank()) trimmedBootstrapToken else "" 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 = val payload =
buildConnectParams( buildConnectParams(
identity = identity, identity = identity,
connectNonce = connectNonce, connectNonce = connectNonce,
authToken = authToken, selectedAuth = selectedAuth,
authBootstrapToken = authBootstrapToken,
authPassword = password?.trim(),
) )
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS) val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
if (!res.ok) { if (!res.ok) {
val msg = res.error?.message ?: "connect failed" val error = res.error ?: ErrorShape("UNAVAILABLE", "connect failed")
throw IllegalStateException(msg) 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) handleConnectSuccess(res, identity.deviceId)
connectDeferred.complete(Unit) connectDeferred.complete(Unit)
@@ -373,6 +438,9 @@ class GatewaySession(
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) { private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") 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 serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull() val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull() val deviceToken = authObj?.get("deviceToken").asStringOrNull()
@@ -392,9 +460,7 @@ class GatewaySession(
private fun buildConnectParams( private fun buildConnectParams(
identity: DeviceIdentity, identity: DeviceIdentity,
connectNonce: String, connectNonce: String,
authToken: String, selectedAuth: SelectedConnectAuth,
authBootstrapToken: String,
authPassword: String?,
): JsonObject { ): JsonObject {
val client = options.client val client = options.client
val locale = Locale.getDefault().toLanguageTag() val locale = Locale.getDefault().toLanguageTag()
@@ -410,20 +476,20 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
} }
val password = authPassword?.trim().orEmpty()
val authJson = val authJson =
when { when {
authToken.isNotEmpty() -> selectedAuth.authToken != null ->
buildJsonObject { buildJsonObject {
put("token", JsonPrimitive(authToken)) put("token", JsonPrimitive(selectedAuth.authToken))
selectedAuth.authDeviceToken?.let { put("deviceToken", JsonPrimitive(it)) }
} }
authBootstrapToken.isNotEmpty() -> selectedAuth.authBootstrapToken != null ->
buildJsonObject { buildJsonObject {
put("bootstrapToken", JsonPrimitive(authBootstrapToken)) put("bootstrapToken", JsonPrimitive(selectedAuth.authBootstrapToken))
} }
password.isNotEmpty() -> selectedAuth.authPassword != null ->
buildJsonObject { buildJsonObject {
put("password", JsonPrimitive(password)) put("password", JsonPrimitive(selectedAuth.authPassword))
} }
else -> null else -> null
} }
@@ -437,12 +503,7 @@ class GatewaySession(
role = options.role, role = options.role,
scopes = options.scopes, scopes = options.scopes,
signedAtMs = signedAtMs, signedAtMs = signedAtMs,
token = token = selectedAuth.signatureToken,
when {
authToken.isNotEmpty() -> authToken
authBootstrapToken.isNotEmpty() -> authBootstrapToken
else -> null
},
nonce = connectNonce, nonce = connectNonce,
platform = client.platform, platform = client.platform,
deviceFamily = client.deviceFamily, deviceFamily = client.deviceFamily,
@@ -505,7 +566,16 @@ class GatewaySession(
frame["error"]?.asObjectOrNull()?.let { obj -> frame["error"]?.asObjectOrNull()?.let { obj ->
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE" val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = obj["message"].asStringOrNull() ?: "request failed" 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)) pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
} }
@@ -629,6 +699,10 @@ class GatewaySession(
delay(250) delay(250)
continue continue
} }
if (reconnectPausedForAuthFailure) {
delay(250)
continue
}
try { try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
@@ -637,6 +711,13 @@ class GatewaySession(
} catch (err: Throwable) { } catch (err: Throwable) {
attempt += 1 attempt += 1
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}") 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()) val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
delay(sleepMs) delay(sleepMs)
} }
@@ -728,6 +809,100 @@ class GatewaySession(
if (host == "0.0.0.0" || host == "::") return true if (host == "0.0.0.0" || host == "::") return true
return host.startsWith("127.") 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 private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

View File

@@ -27,6 +27,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
private const val TEST_TIMEOUT_MS = 8_000L 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) { override fun saveToken(deviceId: String, role: String, token: String) {
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim() tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
} }
override fun clearToken(deviceId: String, role: String) {
tokens.remove("${deviceId.trim()}|${role.trim()}")
}
} }
private data class NodeHarness( private data class NodeHarness(
@@ -144,6 +149,70 @@ class GatewaySessionInvokeTest {
} }
} }
@Test
fun connect_retriesWithStoredDeviceTokenAfterSharedTokenMismatch() = runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val firstConnectAuth = CompletableDeferred<JsonObject?>()
val secondConnectAuth = CompletableDeferred<JsonObject?>()
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 @Test
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
val handshakeOrigin = AtomicReference<String?>(null) val handshakeOrigin = AtomicReference<String?>(null)

View File

@@ -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 { private enum GatewayConnectErrorCodes {
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
@@ -408,48 +418,24 @@ public actor GatewayChannelActor {
} }
let includeDeviceIdentity = options.includeDeviceIdentity let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let storedToken = let selectedAuth = self.selectConnectAuth(
(includeDeviceIdentity && identity != nil) role: role,
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token includeDeviceIdentity: includeDeviceIdentity,
: nil deviceId: identity?.deviceId)
let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry {
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 {
self.pendingDeviceTokenRetry = false self.pendingDeviceTokenRetry = false
} }
// Keep shared credentials explicit when provided. Device token retry is attached self.lastAuthSource = selectedAuth.authSource
// only on a bounded second attempt after token mismatch. self.logger.info("gateway connect auth=\(selectedAuth.authSource.rawValue, privacy: .public)")
let authToken = explicitToken ?? (includeDeviceIdentity ? storedToken : nil) if let authToken = selectedAuth.authToken {
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 {
var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)] var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
if let authDeviceToken { if let authDeviceToken = selectedAuth.authDeviceToken {
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken) auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
} }
params["auth"] = ProtoAnyCodable(auth) params["auth"] = ProtoAnyCodable(auth)
} else if let authBootstrapToken { } else if let authBootstrapToken = selectedAuth.authBootstrapToken {
params["auth"] = ProtoAnyCodable(["bootstrapToken": ProtoAnyCodable(authBootstrapToken)]) params["auth"] = ProtoAnyCodable(["bootstrapToken": ProtoAnyCodable(authBootstrapToken)])
} else if let password = explicitPassword { } else if let password = selectedAuth.authPassword {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
} }
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
@@ -462,7 +448,7 @@ public actor GatewayChannelActor {
role: role, role: role,
scopes: scopes, scopes: scopes,
signedAtMs: signedAtMs, signedAtMs: signedAtMs,
token: authToken ?? authBootstrapToken, token: selectedAuth.signatureToken,
nonce: connectNonce, nonce: connectNonce,
platform: platform, platform: platform,
deviceFamily: InstanceIdentity.deviceFamily) deviceFamily: InstanceIdentity.deviceFamily)
@@ -491,14 +477,14 @@ public actor GatewayChannelActor {
} catch { } catch {
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken( let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
error: error, error: error,
explicitGatewayToken: explicitToken, explicitGatewayToken: self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
storedToken: storedToken, storedToken: selectedAuth.storedToken,
attemptedDeviceTokenRetry: authDeviceToken != nil) attemptedDeviceTokenRetry: selectedAuth.authDeviceToken != nil)
if shouldRetryWithDeviceToken { if shouldRetryWithDeviceToken {
self.pendingDeviceTokenRetry = true self.pendingDeviceTokenRetry = true
self.deviceTokenRetryBudgetUsed = true self.deviceTokenRetryBudgetUsed = true
self.backoffMs = min(self.backoffMs, 250) self.backoffMs = min(self.backoffMs, 250)
} else if authDeviceToken != nil, } else if selectedAuth.authDeviceToken != nil,
let identity, let identity,
self.shouldClearStoredDeviceTokenAfterRetry(error) 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( private func handleConnectResponse(
_ res: ResponseFrame, _ res: ResponseFrame,
identity: DeviceIdentity?, identity: DeviceIdentity?,

View File

@@ -52,6 +52,16 @@ type GatewayClientErrorShape = {
details?: unknown; details?: unknown;
}; };
type SelectedConnectAuth = {
authToken?: string;
authBootstrapToken?: string;
authDeviceToken?: string;
authPassword?: string;
signatureToken?: string;
resolvedDeviceToken?: string;
storedToken?: string;
};
class GatewayClientRequestError extends Error { class GatewayClientRequestError extends Error {
readonly gatewayCode: string; readonly gatewayCode: string;
readonly details?: unknown; readonly details?: unknown;
@@ -281,43 +291,24 @@ export class GatewayClient {
this.connectTimer = null; this.connectTimer = null;
} }
const role = this.opts.role ?? "operator"; const role = this.opts.role ?? "operator";
const explicitGatewayToken = this.opts.token?.trim() || undefined; const {
const explicitBootstrapToken = this.opts.bootstrapToken?.trim() || undefined; authToken,
const explicitDeviceToken = this.opts.deviceToken?.trim() || undefined; authBootstrapToken,
const storedToken = this.opts.deviceIdentity authDeviceToken,
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token authPassword,
: null; signatureToken,
const shouldUseDeviceRetryToken = resolvedDeviceToken,
this.pendingDeviceTokenRetry && storedToken,
!explicitDeviceToken && } = this.selectConnectAuth(role);
Boolean(explicitGatewayToken) && if (this.pendingDeviceTokenRetry && authDeviceToken) {
Boolean(storedToken) &&
this.isTrustedDeviceRetryEndpoint();
if (shouldUseDeviceRetryToken) {
this.pendingDeviceTokenRetry = false; 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 = const auth =
authToken || authBootstrapToken || authPassword || resolvedDeviceToken authToken || authBootstrapToken || authPassword || resolvedDeviceToken
? { ? {
token: authToken, token: authToken,
bootstrapToken: authBootstrapToken, bootstrapToken: authBootstrapToken,
deviceToken: resolvedDeviceToken, deviceToken: authDeviceToken ?? resolvedDeviceToken,
password: authPassword, password: authPassword,
} }
: undefined; : undefined;
@@ -335,7 +326,7 @@ export class GatewayClient {
role, role,
scopes, scopes,
signedAtMs, signedAtMs,
token: authToken ?? authBootstrapToken ?? null, token: signatureToken ?? null,
nonce, nonce,
platform, platform,
deviceFamily: this.opts.deviceFamily, deviceFamily: this.opts.deviceFamily,
@@ -402,7 +393,7 @@ export class GatewayClient {
err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null; err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null;
const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({ const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({
error: err, error: err,
explicitGatewayToken, explicitGatewayToken: this.opts.token?.trim() || undefined,
resolvedDeviceToken, resolvedDeviceToken,
storedToken: storedToken ?? undefined, 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) { private handleMessage(raw: string) {
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);

View File

@@ -119,6 +119,15 @@ type Pending = {
reject: (err: unknown) => void; reject: (err: unknown) => void;
}; };
type SelectedConnectAuth = {
authToken?: string;
authDeviceToken?: string;
authPassword?: string;
resolvedDeviceToken?: string;
storedToken?: string;
canFallbackToShared: boolean;
};
export type GatewayBrowserClientOptions = { export type GatewayBrowserClientOptions = {
url: string; url: string;
token?: string; token?: string;
@@ -236,40 +245,27 @@ export class GatewayBrowserClient {
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator"; const role = "operator";
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null; let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false; let selectedAuth: SelectedConnectAuth = { canFallbackToShared: false };
const explicitGatewayToken = this.opts.token?.trim() || undefined;
let authToken = explicitGatewayToken;
let deviceToken: string | undefined;
if (isSecureContext) { if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity(); deviceIdentity = await loadOrCreateDeviceIdentity();
const storedToken = loadDeviceAuthToken({ selectedAuth = this.selectConnectAuth({
deviceId: deviceIdentity.deviceId,
role, role,
})?.token; deviceId: deviceIdentity.deviceId,
const shouldUseDeviceRetryToken = });
this.pendingDeviceTokenRetry && if (this.pendingDeviceTokenRetry && selectedAuth.authDeviceToken) {
!deviceToken &&
Boolean(explicitGatewayToken) &&
Boolean(storedToken) &&
isTrustedRetryEndpoint(this.opts.url);
if (shouldUseDeviceRetryToken) {
deviceToken = storedToken ?? undefined;
this.pendingDeviceTokenRetry = false; 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 = const auth =
authToken || this.opts.password authToken || selectedAuth.authPassword
? { ? {
token: authToken, token: authToken,
deviceToken, deviceToken,
password: this.opts.password, password: selectedAuth.authPassword,
} }
: undefined; : undefined;
@@ -352,15 +348,10 @@ export class GatewayBrowserClient {
connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH; connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
const shouldRetryWithDeviceToken = const shouldRetryWithDeviceToken =
!this.deviceTokenRetryBudgetUsed && !this.deviceTokenRetryBudgetUsed &&
!deviceToken && !selectedAuth.authDeviceToken &&
Boolean(explicitGatewayToken) && Boolean(explicitGatewayToken) &&
Boolean(deviceIdentity) && Boolean(deviceIdentity) &&
Boolean( Boolean(selectedAuth.storedToken) &&
loadDeviceAuthToken({
deviceId: deviceIdentity?.deviceId ?? "",
role,
})?.token,
) &&
canRetryWithDeviceTokenHint && canRetryWithDeviceTokenHint &&
isTrustedRetryEndpoint(this.opts.url); isTrustedRetryEndpoint(this.opts.url);
if (shouldRetryWithDeviceToken) { if (shouldRetryWithDeviceToken) {
@@ -377,7 +368,7 @@ export class GatewayBrowserClient {
this.pendingConnectError = undefined; this.pendingConnectError = undefined;
} }
if ( if (
canFallbackToShared && selectedAuth.canFallbackToShared &&
deviceIdentity && deviceIdentity &&
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH 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<T = unknown>(method: string, params?: unknown): Promise<T> { request<T = unknown>(method: string, params?: unknown): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway not connected")); return Promise.reject(new Error("gateway not connected"));