diff --git a/apps/android/app/src/main/java/com/coulterpeterson/floatnative/api/AuthInterceptor.kt b/apps/android/app/src/main/java/com/coulterpeterson/floatnative/api/AuthInterceptor.kt index 2321873..0b99b41 100644 --- a/apps/android/app/src/main/java/com/coulterpeterson/floatnative/api/AuthInterceptor.kt +++ b/apps/android/app/src/main/java/com/coulterpeterson/floatnative/api/AuthInterceptor.kt @@ -3,10 +3,9 @@ package com.coulterpeterson.floatnative.api import com.coulterpeterson.floatnative.data.TokenManager import kotlinx.coroutines.runBlocking import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response import retrofit2.HttpException -import org.json.JSONObject -import java.io.IOException import java.text.SimpleDateFormat import java.util.Locale @@ -17,231 +16,70 @@ class AuthInterceptor( private val authApiProvider: () -> OAuthApi // Lazy provider to avoid circular dependency ) : Interceptor { + companion object { + private const val USER_AGENT = "FloatNative/1.0 (Android)" + private const val MAX_NONCE_RETRIES = 3 + } + override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() - val builder = originalRequest.newBuilder() - - // 1. Add Headers - val accessToken = tokenManager.accessToken - val authCookie = tokenManager.authCookie - - val url = originalRequest.url.toString() - val isFloatplane = url.contains("floatplane.com") || url.contains("floatnative.coulterpeterson.com") - - if (accessToken != null && isFloatplane) { - // Generate DPoP Proof - try { - val method = originalRequest.method - val url = originalRequest.url.toString() - val proof = dpopManager.generateProof(method, url, accessToken) - - builder.header("DPoP", proof) - builder.header("Authorization", "DPoP $accessToken") - } catch (e: Exception) { - e.printStackTrace() - // Fallback (though DPoP is required now) - builder.header("Authorization", "Bearer $accessToken") - } - } - - // Add Cookie if present (Sails requires this for chat, even if we have DPoP) - if (authCookie != null) { - builder.header("Cookie", "sails.sid=$authCookie") - } - - builder.header("User-Agent", "FloatNative/1.0 (Android)") - - val finalRequest = builder.build() - - var response: Response? = null - try { - response = chain.proceed(finalRequest) - } catch (e: Exception) { - // Check for SSL Handshake or Peer Unverified exceptions which might indicate - // clock skew or cert issues that a fresh token *might* help with (user request), - // or network glitches we want to retry once. - if (e is javax.net.ssl.SSLHandshakeException || e is javax.net.ssl.SSLPeerUnverifiedException) { - val refreshToken = tokenManager.refreshToken - if (refreshToken != null) { - synchronized(this) { - try { - // Attempt to refresh token - val authApi = authApiProvider() - val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token" - val refreshProof = dpopManager.generateProof("POST", tokenEndpoint) - - // Run blocking because Interceptor is synchronous - val tokenResponse = runBlocking { - authApi.getToken( - dpop = refreshProof, - grantType = "refresh_token", - clientId = "floatnative", - refreshToken = refreshToken - ) - } - - // Save new tokens - tokenManager.accessToken = tokenResponse.access_token - tokenManager.refreshToken = tokenResponse.refresh_token - - // Retry original request with new token - val method = originalRequest.method - val url = originalRequest.url.toString() - val proof = dpopManager.generateProof(method, url, tokenResponse.access_token) - - return chain.proceed( - originalRequest.newBuilder() - .header("DPoP", proof) - .header("Authorization", "DPoP ${tokenResponse.access_token}") - .build() - ) - } catch (refreshEx: Exception) { - android.util.Log.e("AuthInterceptor", "Refresh failed during SSL recovery. Forcing logout.", refreshEx) - // Force logout so user can try to log in again (as requested) - tokenManager.clearAll() - throw e - } - } - } - } - // If not SSL error or no refresh token, rethrow - throw e + var accessToken = tokenManager.accessToken + + var request = authorize(originalRequest, accessToken) + var response = proceedCapturingNonce(chain, request) + + // Cloudflare HTML challenges and DPoP nonce challenges both surface as + // 401/403. Handle nonce first (no token refresh). Only then try refresh + // for real auth failures — refreshing on every 403 caused request storms + // and 429s once Floatplane started requiring DPoP nonces more strictly. + var nonceAttempts = 0 + while (isAuthChallenge(response) && isDpopNonceChallenge(response) && nonceAttempts < MAX_NONCE_RETRIES) { + nonceAttempts++ + response.close() + accessToken = tokenManager.accessToken + request = authorize(originalRequest, accessToken) + response = proceedCapturingNonce(chain, request) } - // 2. Extract Cookie from Response (if logging in via legacy or hybrid) - val cookies = response.headers("Set-Cookie") - for (cookie in cookies) { - if (cookie.contains("sails.sid")) { - // Simple parsing to get value - val parts = cookie.split(";") - for (part in parts) { - val pair = part.trim().split("=") - if (pair.size == 2 && pair[0] == "sails.sid") { - tokenManager.authCookie = pair[1] - } - } - } - } - - // 3. Handle 401 Unauthorized or 403 Forbidden - if (response.code == 401 || response.code == 403) { + if (isAuthChallenge(response) && !isCloudflareChallenge(response) && !isDpopNonceChallenge(response)) { val refreshToken = tokenManager.refreshToken if (refreshToken != null) { synchronized(this) { - // Double check if token was updated by another thread val currentAccessToken = tokenManager.accessToken if (currentAccessToken != null && currentAccessToken != accessToken) { - // Token was updated, retry with new token response.close() - // Generate new proof for the retried request with new token - try { - val method = originalRequest.method - val url = originalRequest.url.toString() - val proof = dpopManager.generateProof(method, url, currentAccessToken) - - return chain.proceed( - originalRequest.newBuilder() - .header("DPoP", proof) - .header("Authorization", "DPoP $currentAccessToken") - .build() - ) - } catch (e: Exception) { - return chain.proceed( - originalRequest.newBuilder() - .header("Authorization", "Bearer $currentAccessToken") - .build() - ) - } + return proceedCapturingNonce(chain, authorize(originalRequest, currentAccessToken)) } - // Try to refresh try { - val authApi = authApiProvider() - - // Generate DPoP proof for Token Endpoint - val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token" - val refreshProof = dpopManager.generateProof("POST", tokenEndpoint) - - // Run blocking because Interceptor is synchronous - val tokenResponse = runBlocking { - authApi.getToken( - dpop = refreshProof, - grantType = "refresh_token", - clientId = "floatnative", - refreshToken = refreshToken - ) - } - - // Save new tokens - tokenManager.accessToken = tokenResponse.access_token - tokenManager.refreshToken = tokenResponse.refresh_token - // Update expiry if needed - - // Retry request + val newAccess = refreshAccessToken(refreshToken) response.close() - - // Generate DPoP proof for the retried request with NEW token - val method = originalRequest.method - val url = originalRequest.url.toString() - val proof = dpopManager.generateProof(method, url, tokenResponse.access_token) - - return chain.proceed( - originalRequest.newBuilder() - .header("DPoP", proof) - .header("Authorization", "DPoP ${tokenResponse.access_token}") - .build() - ) - + var retry = proceedCapturingNonce(chain, authorize(originalRequest, newAccess)) + + // Fresh token may still need a nonce round-trip. + var postRefreshNonceAttempts = 0 + while ( + isAuthChallenge(retry) && + isDpopNonceChallenge(retry) && + postRefreshNonceAttempts < MAX_NONCE_RETRIES + ) { + postRefreshNonceAttempts++ + retry.close() + retry = proceedCapturingNonce(chain, authorize(originalRequest, newAccess)) + } + return retry } catch (e: Exception) { - // Handle DPoP Time Skew for Refresh Token - if (e is HttpException && e.code() == 400) { - val errorBody = e.response()?.errorBody()?.string() - if (!errorBody.isNullOrEmpty() && errorBody.contains("DPoP", ignoreCase = true)) { + if (e is HttpException && e.code() == 400) { + val errorBody = e.response()?.errorBody()?.string().orEmpty() + if (errorBody.contains("DPoP", ignoreCase = true) || + errorBody.contains("use_dpop_nonce", ignoreCase = true) + ) { try { - // 1. Auto-correct time - val dateHeader = e.response()?.headers()?.get("date") - if (dateHeader != null) { - val sdf = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US) - val serverTime = sdf.parse(dateHeader)?.time - if (serverTime != null) { - val deviceTime = System.currentTimeMillis() - val offsetSeconds = (serverTime - deviceTime) / 1000 - dpopManager.timeOffsetSeconds = offsetSeconds - android.util.Log.i("AuthInterceptor", "Auto-corrected DPoP time offset: $offsetSeconds s") - } - } - - // 2. Retry Refresh - val authApi = authApiProvider() - val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token" - val retryProof = dpopManager.generateProof("POST", tokenEndpoint) - - val retryTokenResponse = runBlocking { - authApi.getToken( - dpop = retryProof, - grantType = "refresh_token", - clientId = "floatnative", - refreshToken = refreshToken - ) - } - - // 3. Save new tokens - tokenManager.accessToken = retryTokenResponse.access_token - tokenManager.refreshToken = retryTokenResponse.refresh_token - - // 4. Retry Original Request + autoCorrectTimeSkew(e) + e.response()?.headers()?.let { dpopManager.captureNonce(it) } + val newAccess = refreshAccessToken(refreshToken) response.close() - val method = originalRequest.method - val url = originalRequest.url.toString() - val proof = dpopManager.generateProof(method, url, retryTokenResponse.access_token) - - return chain.proceed( - originalRequest.newBuilder() - .header("DPoP", proof) - .header("Authorization", "DPoP ${retryTokenResponse.access_token}") - .build() - ) - + return proceedCapturingNonce(chain, authorize(originalRequest, newAccess)) } catch (retryEx: Exception) { android.util.Log.e("AuthInterceptor", "Retry refresh failed", retryEx) com.coulterpeterson.floatnative.utils.DebugLogManager.auth( @@ -257,8 +95,12 @@ class AuthInterceptor( ) tokenManager.clearAll() } + } else if (e is javax.net.ssl.SSLHandshakeException || + e is javax.net.ssl.SSLPeerUnverifiedException + ) { + android.util.Log.e("AuthInterceptor", "SSL error during refresh. Forcing logout.", e) + tokenManager.clearAll() } else { - // Refresh failed, clear tokens to force re-login com.coulterpeterson.floatnative.utils.DebugLogManager.auth( "Token refresh failed; signing out", "${e.javaClass.simpleName}: ${e.message}" @@ -272,4 +114,116 @@ class AuthInterceptor( return response } + + private fun authorize(original: Request, accessToken: String?): Request { + val builder = original.newBuilder() + builder.header("User-Agent", USER_AGENT) + + val url = original.url.toString() + val isFloatplane = url.contains("floatplane.com") || url.contains("floatnative.coulterpeterson.com") + + if (accessToken != null && isFloatplane) { + try { + val proof = dpopManager.generateProof(original.method, url, accessToken) + builder.header("DPoP", proof) + builder.header("Authorization", "DPoP $accessToken") + } catch (e: Exception) { + e.printStackTrace() + builder.header("Authorization", "Bearer $accessToken") + } + } + + val authCookie = tokenManager.authCookie + if (authCookie != null) { + // Sails still expects the session cookie for some hybrid paths (chat / delivery). + builder.header("Cookie", "sails.sid=$authCookie") + } + + return builder.build() + } + + private fun proceedCapturingNonce(chain: Interceptor.Chain, request: Request): Response { + val response = chain.proceed(request) + dpopManager.captureNonce(response.headers) + captureSailsCookie(response) + return response + } + + private fun captureSailsCookie(response: Response) { + for (cookie in response.headers("Set-Cookie")) { + if (!cookie.contains("sails.sid")) continue + for (part in cookie.split(";")) { + val pair = part.trim().split("=", limit = 2) + if (pair.size == 2 && pair[0] == "sails.sid") { + tokenManager.authCookie = pair[1] + } + } + } + } + + private fun isAuthChallenge(response: Response): Boolean { + return response.code == 401 || response.code == 403 + } + + private fun isDpopNonceChallenge(response: Response): Boolean { + val www = response.header("WWW-Authenticate").orEmpty() + return www.contains("use_dpop_nonce", ignoreCase = true) + } + + private fun isCloudflareChallenge(response: Response): Boolean { + val contentType = response.header("Content-Type").orEmpty() + if (contentType.contains("text/html", ignoreCase = true)) return true + if (response.header("cf-mitigated") != null) return true + return false + } + + private fun refreshAccessToken(refreshToken: String): String { + val authApi = authApiProvider() + val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token" + + fun attempt(): String { + val refreshProof = dpopManager.generateProof("POST", tokenEndpoint) + val tokenResponse = runBlocking { + authApi.getToken( + dpop = refreshProof, + grantType = "refresh_token", + clientId = "floatnative", + refreshToken = refreshToken + ) + } + tokenManager.accessToken = tokenResponse.access_token + tokenManager.refreshToken = tokenResponse.refresh_token + return tokenResponse.access_token + } + + return try { + attempt() + } catch (e: HttpException) { + // Token endpoint may demand a nonce before accepting the refresh. + e.response()?.headers()?.let { dpopManager.captureNonce(it) } + val body = e.response()?.errorBody()?.string().orEmpty() + if (e.code() == 400 && + (body.contains("use_dpop_nonce") || + e.response()?.headers()?.get("WWW-Authenticate") + ?.contains("use_dpop_nonce", ignoreCase = true) == true) + ) { + attempt() + } else { + throw e + } + } + } + + private fun autoCorrectTimeSkew(e: HttpException) { + val dateHeader = e.response()?.headers()?.get("date") ?: return + try { + val sdf = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US) + val serverTime = sdf.parse(dateHeader)?.time ?: return + val offsetSeconds = (serverTime - System.currentTimeMillis()) / 1000 + dpopManager.timeOffsetSeconds = offsetSeconds + android.util.Log.i("AuthInterceptor", "Auto-corrected DPoP time offset: $offsetSeconds s") + } catch (parseEx: Exception) { + android.util.Log.e("AuthInterceptor", "Failed to parse Date header", parseEx) + } + } } diff --git a/apps/android/app/src/main/java/com/coulterpeterson/floatnative/api/FloatplaneApi.kt b/apps/android/app/src/main/java/com/coulterpeterson/floatnative/api/FloatplaneApi.kt index 00aca2b..574d99d 100644 --- a/apps/android/app/src/main/java/com/coulterpeterson/floatnative/api/FloatplaneApi.kt +++ b/apps/android/app/src/main/java/com/coulterpeterson/floatnative/api/FloatplaneApi.kt @@ -95,8 +95,19 @@ object FloatplaneApi { redactHeader("DPoP-Nonce") } + dpopManager = com.coulterpeterson.floatnative.data.DPoPManager(context) + + // Capture DPoP-Nonce from every response (auth + API) so subsequent proofs + // can satisfy Floatplane's RFC 9449 nonce challenges. + val dpopNonceInterceptor = okhttp3.Interceptor { chain -> + val response = chain.proceed(chain.request()) + dpopManager.captureNonce(response.headers) + response + } + // OAuth Client with logging but NO AuthInterceptor (to avoid cycles) val oauthClient = OkHttpClient.Builder() + .addInterceptor(dpopNonceInterceptor) .addInterceptor(loggingInterceptor) .build() @@ -109,7 +120,6 @@ object FloatplaneApi { oauthApi = oauthRetrofit.create(OAuthApi::class.java) - dpopManager = com.coulterpeterson.floatnative.data.DPoPManager(context) val authInterceptor = AuthInterceptor(tokenManager, dpopManager) { oauthApi } okHttpClient = OkHttpClient.Builder() @@ -149,18 +159,17 @@ object FloatplaneApi { suspend fun exchangeAuthCode(code: String, verifier: String) { val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token" - // 1. Generate DPoP proof - val dpop = dpopManager.generateProof("POST", tokenEndpoint) - - // 2. Exchange Token - val response = oauthApi.getToken( - dpop = dpop, - grantType = "authorization_code", - clientId = "floatnative", - code = code, - codeVerifier = verifier, - redirectUri = "floatnative://auth" - ) + // 1-2. Exchange Token (retry if auth server demands a DPoP nonce) + val response = withDpopNonceRetry(tokenEndpoint) { dpop -> + oauthApi.getToken( + dpop = dpop, + grantType = "authorization_code", + clientId = "floatnative", + code = code, + codeVerifier = verifier, + redirectUri = "floatnative://auth" + ) + } // 3. Save Tokens tokenManager.accessToken = response.access_token @@ -249,15 +258,16 @@ object FloatplaneApi { // Refresh the access token val refreshToken = tokenManager.refreshToken!! val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token" - val dpop = dpopManager.generateProof("POST", tokenEndpoint) - + android.util.Log.d("FloatplaneApi", "ensureCompanionLogin: Calling token refresh") - val tokenResponse = oauthApi.getToken( - dpop = dpop, - grantType = "refresh_token", - clientId = "floatnative", - refreshToken = refreshToken - ) + val tokenResponse = withDpopNonceRetry(tokenEndpoint) { dpop -> + oauthApi.getToken( + dpop = dpop, + grantType = "refresh_token", + clientId = "floatnative", + refreshToken = refreshToken + ) + } // Update tokens tokenManager.accessToken = tokenResponse.access_token @@ -308,10 +318,12 @@ object FloatplaneApi { suspend fun pollDeviceToken(deviceCode: String): OAuthTokenResponse { val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token" - - // Generate DPoP for the token endpoint + // Do NOT wrap this in withDpopNonceRetry: authorization_pending is a normal + // 400 during TV polling, and reading the error body here would prevent the + // ViewModel from seeing `error=authorization_pending`. Nonce challenges are + // handled in TvLoginViewModel (capture nonce + continue); each poll already + // signs with dpopManager.lastNonce via generateProof(). val dpop = dpopManager.generateProof("POST", tokenEndpoint) - return oauthApi.getToken( dpop = dpop, grantType = "urn:ietf:params:oauth:grant-type:device_code", @@ -319,4 +331,70 @@ object FloatplaneApi { deviceCode = deviceCode ) } + + /** + * Persist device-flow tokens and prime the sails.sid cookie the same way the + * authorization-code path does. TV login previously skipped getSelf(), which + * left hybrid cookie+DPoP endpoints failing after an otherwise successful login. + */ + suspend fun completeDeviceLogin(tokenResponse: OAuthTokenResponse) { + tokenManager.accessToken = tokenResponse.access_token + tokenManager.refreshToken = tokenResponse.refresh_token + + try { + userV3.getSelf() + } catch (e: Exception) { + android.util.Log.e("FloatplaneApi", "completeDeviceLogin: failed to prime sails.sid via getSelf", e) + } + + ensureCompanionLogin() + } + + /** + * Run an OAuth token call, retrying when the auth server returns + * `error=use_dpop_nonce` (RFC 9449). Mirrors floatcli/auth.py. + * + * Only use for one-shot exchanges (authorization_code / refresh_token), not + * device-code polling — those 400 bodies must remain readable by the caller. + */ + private suspend fun withDpopNonceRetry( + tokenEndpoint: String, + block: suspend (dpop: String) -> OAuthTokenResponse + ): OAuthTokenResponse { + var lastError: Exception? = null + repeat(3) { attempt -> + val dpop = dpopManager.generateProof("POST", tokenEndpoint) + try { + return block(dpop) + } catch (e: retrofit2.HttpException) { + e.response()?.headers()?.let { dpopManager.captureNonce(it) } + val www = e.response()?.headers()?.get("WWW-Authenticate").orEmpty() + val needsNonceFromHeader = www.contains("use_dpop_nonce", ignoreCase = true) + + // Prefer WWW-Authenticate so we don't have to consume the body. Fall + // back to peeking the JSON error only when the header is absent. + val needsNonce = if (needsNonceFromHeader) { + true + } else { + val errBody = try { + e.response()?.errorBody()?.string().orEmpty() + } catch (_: Exception) { + "" + } + errBody.contains("use_dpop_nonce") + } + + if (needsNonce && attempt < 2) { + android.util.Log.i( + "FloatplaneApi", + "OAuth token call demanded DPoP nonce; retrying (attempt ${attempt + 1})" + ) + lastError = e + } else { + throw e + } + } + } + throw lastError ?: IllegalStateException("DPoP nonce retry exhausted") + } } diff --git a/apps/android/app/src/main/java/com/coulterpeterson/floatnative/data/DPoPManager.kt b/apps/android/app/src/main/java/com/coulterpeterson/floatnative/data/DPoPManager.kt index 39b4b44..77db5d1 100644 --- a/apps/android/app/src/main/java/com/coulterpeterson/floatnative/data/DPoPManager.kt +++ b/apps/android/app/src/main/java/com/coulterpeterson/floatnative/data/DPoPManager.kt @@ -44,6 +44,17 @@ class DPoPManager(context: Context) { prefs.edit().putLong(KEY_TIME_OFFSET, value).apply() } + /** + * Latest server-provided DPoP nonce (RFC 9449). Floatplane may require this + * on subsequent proofs after sending `DPoP-Nonce` / `use_dpop_nonce`. + */ + @Volatile + var lastNonce: String? = null + + fun captureNonce(headers: okhttp3.Headers) { + headers["DPoP-Nonce"]?.let { lastNonce = it } + } + private var keyPair: KeyPair? = null @Synchronized @@ -91,7 +102,12 @@ class DPoPManager(context: Context) { return map } - fun generateProof(httpMethod: String, httpUrl: String, accessToken: String? = null): String { + fun generateProof( + httpMethod: String, + httpUrl: String, + accessToken: String? = null, + nonce: String? = lastNonce + ): String { val keys = getOrGenerateKeyPair() val publicKey = keys.public as ECPublicKey @@ -136,6 +152,10 @@ class DPoPManager(context: Context) { payload.put("ath", base64UrlEncode(hash)) } + if (nonce != null) { + payload.put("nonce", nonce) + } + // 3. Sign val headerStr = base64UrlEncode(header.toString().toByteArray(Charsets.UTF_8)) val payloadStr = base64UrlEncode(payload.toString().toByteArray(Charsets.UTF_8)) diff --git a/apps/android/app/src/main/java/com/coulterpeterson/floatnative/viewmodels/TvLoginViewModel.kt b/apps/android/app/src/main/java/com/coulterpeterson/floatnative/viewmodels/TvLoginViewModel.kt index cb5b5fc..f3112aa 100644 --- a/apps/android/app/src/main/java/com/coulterpeterson/floatnative/viewmodels/TvLoginViewModel.kt +++ b/apps/android/app/src/main/java/com/coulterpeterson/floatnative/viewmodels/TvLoginViewModel.kt @@ -69,12 +69,8 @@ class TvLoginViewModel : ViewModel() { val tokenResponse = FloatplaneApi.pollDeviceToken(deviceCode) android.util.Log.d("TvLoginViewModel", "pollForToken: Success! Token received. AccessToken length: ${tokenResponse.access_token.length}") - // Success! - FloatplaneApi.tokenManager.accessToken = tokenResponse.access_token - FloatplaneApi.tokenManager.refreshToken = tokenResponse.refresh_token - - // Also trigger companion login in background - FloatplaneApi.ensureCompanionLogin() + // Success — persist tokens, prime sails.sid, companion login + FloatplaneApi.completeDeviceLogin(tokenResponse) _state.value = TvLoginState.Success isDone = true @@ -82,6 +78,7 @@ class TvLoginViewModel : ViewModel() { if (e.code() == 400) { val errorBody = e.response()?.errorBody()?.string() android.util.Log.d("TvLoginViewModel", "pollForToken: 400 Error Body: $errorBody") + e.response()?.headers()?.let { FloatplaneApi.dpopManager.captureNonce(it) } if (!errorBody.isNullOrEmpty()) { try { @@ -98,6 +95,12 @@ class TvLoginViewModel : ViewModel() { android.util.Log.w("TvLoginViewModel", "pollForToken: Received slow_down, adding delay") delay(5000) continue + } else if (error == "use_dpop_nonce") { + // Auth server wants a proof that includes the nonce it just sent. + // pollDeviceToken already retries, but if we still see this at the + // ViewModel layer, immediately poll again with the captured nonce. + android.util.Log.i("TvLoginViewModel", "pollForToken: use_dpop_nonce — retrying immediately") + continue } else if (errorDesc.contains("DPoP", ignoreCase = true)) { // Specific handling for DPoP time skew issues android.util.Log.w("TvLoginViewModel", "pollForToken: DPoP Error detected -> System Time Issue.") diff --git a/apps/ios/FloatNative/Services/FloatplaneAPI.swift b/apps/ios/FloatNative/Services/FloatplaneAPI.swift index 8639a63..3214d2b 100644 --- a/apps/ios/FloatNative/Services/FloatplaneAPI.swift +++ b/apps/ios/FloatNative/Services/FloatplaneAPI.swift @@ -570,7 +570,8 @@ class FloatplaneAPI: ObservableObject { endpoint: String, method: String = "POST", body: [String: String], - dpopProof: String? = nil + dpopProof: String? = nil, + nonceRetryCount: Int = 0 ) async throws -> T { guard let url = URL(string: authBaseURL + endpoint) else { throw FloatplaneAPIError.invalidURL @@ -643,6 +644,24 @@ class FloatplaneAPI: ObservableObject { // Parse error response if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let error = json["error"] as? String { + // Auth server may demand a nonce-bound proof (RFC 9449). Retry + // immediately with the nonce we just captured — same as floatcli. + let wwwAuth = headers["www-authenticate"] ?? "" + if (error == "use_dpop_nonce" || wwwAuth.contains("use_dpop_nonce")), + nonceRetryCount < 3 { + let retryProof = try DPoPManager.shared.generateProof( + httpMethod: method, + httpUrl: authBaseURL + endpoint, + nonce: lastDPoPNonce + ) + return try await requestAuth( + endpoint: endpoint, + method: method, + body: body, + dpopProof: retryProof, + nonceRetryCount: nonceRetryCount + 1 + ) + } // Return descriptive error for polling (e.g., authorization_pending) throw FloatplaneAPIError.httpError(statusCode: httpResponse.statusCode, message: error) } @@ -704,6 +723,8 @@ class FloatplaneAPI: ObservableObject { dpopProof: dpopProof ) handleOAuthResponse(response) + // Prime sails.sid / currentUser the same way authorization-code login does. + _ = try? await getCurrentUser() return response } catch { throw error