Fix watch complication/widget expired-token auth failures#5073
Conversation
The watchOS complications and WatchWidgets extension were sending expired access tokens to /api/states, which Home Assistant logs as invalid auth and eventually IP-bans. HA access tokens live ~30 min while the WatchWidgets extension only held a static token snapshot it could not refresh, and the watch app path could send a token that lapsed in flight. - WatchWidgetServerCredential now carries the access token expiration, the long-lived refresh token, and the OAuth client_id. - WatchWidgetLiveFetch validates each server's token before hitting /api/states: it refreshes near-expiry tokens itself via a plain POST /auth/token (reusing the mTLS/self-signed delegate) and skips the request entirely when it can't obtain a valid token, so it never sends an expired one. Refreshed tokens are persisted back to the app group. - ComplicationStateFetcher.credential(for:) writes the full TokenInfo so the widget has what it needs to self-refresh. - TokenManager refreshes 60s early (matching TokenInfo.needsRefresh) instead of 10s, so a token can't expire mid-request on slow watch/cloud paths. - ServerManager.restoreState keeps the existing token when it has a later expiration than an incoming snapshot, so the watch's independently-refreshed token isn't clobbered by the phone's staler one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Found 1 unused localization strings in the codebase. Click to see detailsTo remove them, run the |
There was a problem hiding this comment.
Pull request overview
This PR addresses watch complications and the WatchWidgets extension repeatedly calling /api/states/<entity> with expired OAuth access tokens by propagating token metadata to the widget extension and enabling it to refresh tokens itself before making REST calls. It also hardens token refresh timing and state restoration so fresher tokens aren’t overwritten by older snapshots.
Changes:
- Extend
WatchWidgetServerCredentialto includeexpiration,refreshToken, andclientIDso the widget can self-refresh access tokens. - Add in-extension token validation/refresh (
POST /auth/token) inWatchWidgetLiveFetchand skip state fetches entirely when a valid token can’t be obtained. - Refresh tokens earlier in
TokenManagerand preventServerManager.restoreStatefrom downgrading a fresher token already held by the current process.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| Sources/WatchWidgets/WatchWidgetLiveFetch.swift | Validates/refreshes access tokens before hitting /api/states, persists refreshed credentials to the app group, and skips requests when auth can’t be made valid. |
| Sources/WatchApp/ExtensionDelegate.swift | Writes full token metadata (access token, expiration, refresh token, clientID) into the widget credential snapshot. |
| Sources/HANetworking/Sources/TokenManager.swift | Aligns refresh margin to 60s (matching TokenInfo.requiresRefresh) to reduce in-flight expiry. |
| Sources/HANetworking/Sources/ServerManager.swift | Avoids overwriting a newer in-memory token with an older restored snapshot. |
| Sources/HAModels/Sources/WatchWidgetServerCredential.swift | Adds fields needed for widget-side refresh and a shared clientID helper consistent with onboarding/auth routes. |
Covers the new restoreState behavior from Copilot review: a fresher existing token isn't downgraded by an older incoming snapshot (while other ServerInfo fields still update), and a newer incoming token is accepted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Watch complications and the
WatchWidgetsextension were repeatedly hitting/api/states/<entity>with expired access tokens, which Home Assistant logs as invalid authentication and eventually IP-bans:Root cause: HA OAuth access tokens live ~30 minutes, but:
WatchWidgetsextension only held a static token snapshot written by the watch app and had no way to refresh it. When the snapshot aged past ~30 min, every timeline fetch on the widget's own WidgetKit budget went out with an expired token.restoreState) overwrote a token the watch had refreshed independently with the phone's staler one.Changes
WatchWidgetServerCredentialnow carries the access-tokenexpiration, the long-livedrefreshToken, and the OAuthclientID.WatchWidgetLiveFetchvalidates each server's token before touching/api/states. Near-expiry tokens are refreshed in-extension via a plain form-encodedPOST /auth/token(reusing the existing mTLS/self-signedURLSessiondelegate); refreshed tokens are persisted back to the app group. If a valid token can't be obtained, the request is skipped entirely — the extension never sends an expired token again.ComplicationStateFetcher.credential(for:)writes the fullTokenInfo(expiration + refresh token) and the resolvedclient_idso the widget can self-refresh.TokenManager.currentTokennow refreshes 60s early (matchingTokenInfo.needsRefresh) instead of 10s, preventing in-flight expiry.ServerManager.restoreStatekeeps the existing token when it has a later expiration than an incoming snapshot, so the watch's freshly-refreshed token isn't clobbered by the phone's staler one.Rollout note
The credential's new fields are non-optional, so a blob written by the previous build won't decode — the widget shows its last snapshot until the updated watch app writes a fresh credential on next launch/refresh. Both ship in the same watch app bundle, so the window is brief and self-healing; no migration needed.
Testing
WatchAppscheme builds cleanly (compilesHAModels,HANetworking, the watch app, and theWatchWidgetsextension).🤖 Generated with Claude Code