refactor: unify gateway connect auth selection
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
Reference in New Issue
Block a user