refactor: unify gateway connect auth selection
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ReturnType<typeof loadOrCreateDeviceIdentity>> | 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<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error("gateway not connected"));
|
||||
|
||||
Reference in New Issue
Block a user