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

View File

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

View File

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

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 {
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?,

View File

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

View File

@@ -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"));