Skip to content

Fix watch complication/widget expired-token auth failures#5073

Merged
bgoncal merged 3 commits into
mainfrom
fix/watch-widget-token-refresh
Jul 13, 2026
Merged

Fix watch complication/widget expired-token auth failures#5073
bgoncal merged 3 commits into
mainfrom
fix/watch-widget-token-refresh

Conversation

@bgoncal

@bgoncal bgoncal commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

Watch complications and the WatchWidgets extension were repeatedly hitting /api/states/<entity> with expired access tokens, which Home Assistant logs as invalid authentication and eventually IP-bans:

Login attempt or request with invalid authentication from localhost (127.0.0.1).
Requested URL: '/api/states/weather.home_weather'. (Extensions-WatchWidgets/… CFNetwork/… Darwin/…)
Login attempt or request with invalid authentication from localhost (127.0.0.1).
Requested URL: '/api/states/sensor.…_battery_level'. (Home Assistant/… watchOS …)

Root cause: HA OAuth access tokens live ~30 minutes, but:

  • The WatchWidgets extension 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.
  • The watch-app complication path refreshed only when within 10s of expiry, so a token could lapse in flight over a slow watch/cloud round trip.
  • Phone→watch server-state sync (restoreState) overwrote a token the watch had refreshed independently with the phone's staler one.

Changes

  • WatchWidgetServerCredential now carries the access-token expiration, the long-lived refreshToken, and the OAuth clientID.
  • WatchWidgetLiveFetch validates each server's token before touching /api/states. Near-expiry tokens are refreshed in-extension via a plain form-encoded POST /auth/token (reusing the existing mTLS/self-signed URLSession delegate); 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 full TokenInfo (expiration + refresh token) and the resolved client_id so the widget can self-refresh.
  • TokenManager.currentToken now refreshes 60s early (matching TokenInfo.needsRefresh) instead of 10s, preventing in-flight expiry.
  • ServerManager.restoreState keeps 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

  • WatchApp scheme builds cleanly (compiles HAModels, HANetworking, the watch app, and the WatchWidgets extension).

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 13, 2026 15:01
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

⚠️ Unused L10n strings detected

Found 1 unused localization strings in the codebase.

Click to see details
Parsing Strings.swift...
Found 1989 L10n strings

Reading all Swift source code...
Read 6681114 characters of Swift code

Checking for unused strings...
Checked 100/1989 strings...
Checked 200/1989 strings...
Checked 300/1989 strings...
Checked 400/1989 strings...
Checked 500/1989 strings...
Checked 600/1989 strings...
Checked 700/1989 strings...
Checked 800/1989 strings...
Checked 900/1989 strings...
Checked 1000/1989 strings...
Checked 1100/1989 strings...
Checked 1200/1989 strings...
Checked 1300/1989 strings...
Checked 1400/1989 strings...
Checked 1500/1989 strings...
Checked 1600/1989 strings...
Checked 1700/1989 strings...
Checked 1800/1989 strings...
Checked 1900/1989 strings...

================================================================================
UNUSED STRINGS REPORT
================================================================================

Found 1 unused strings:


INTENTS:
  - L10n.Intents.serverRequiredForValue
    Key: intents.server_required_for_value
    Line: 1999

================================================================================
Total unused: 1
================================================================================

================================================================================
Copy-paste these keys into the "Lokalise: Delete Keys" workflow (keys input):
================================================================================
intents.server_required_for_value

To remove them, run the
Lokalise: Delete Keys
workflow — it deletes the keys from Lokalise and opens a PR removing them from
Localizable.strings and regenerating Strings.swift. Copy-paste these keys into the keys input:

intents.server_required_for_value

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 WatchWidgetServerCredential to include expiration, refreshToken, and clientID so the widget can self-refresh access tokens.
  • Add in-extension token validation/refresh (POST /auth/token) in WatchWidgetLiveFetch and skip state fetches entirely when a valid token can’t be obtained.
  • Refresh tokens earlier in TokenManager and prevent ServerManager.restoreState from 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.

Comment thread Sources/HANetworking/Sources/ServerManager.swift
bgoncal and others added 2 commits July 13, 2026 17:22
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>
@bgoncal bgoncal merged commit 2e7fbaa into main Jul 13, 2026
11 checks passed
@bgoncal bgoncal deleted the fix/watch-widget-token-refresh branch July 13, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants