Skip to content

Commit e0fce36

Browse files
fix: fully stateless OAuth tokens, drop HOMEASSISTANT_TOKEN requirement (homeassistant-ai#893)
* fix: make OAuth tokens fully stateless, drop HOMEASSISTANT_TOKEN requirement Fixes homeassistant-ai#886: OAuth mode no longer requires HOMEASSISTANT_TOKEN env var. When the var is empty/unset, main_oauth() sets the sentinel value so Settings validation passes. Fixes homeassistant-ai#837: Both access and refresh tokens are now stateless (base64-encoded JSON containing the HA LLAT, type, client_id, scopes, and expiry). No server-side token state is stored, eliminating oauth_state.json and all disk I/O. Tokens survive container restarts by design — clients re-register via DCR automatically. Removed: _save_state(), _load_state(), _refresh_to_access_map, state_dir parameter, get_ha_credentials_for_token(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: validate json.loads returns dict in _decode_token json.loads can return non-dict types (list, str, int) for valid JSON. Without isinstance check, calling .get() on a non-dict payload would raise AttributeError, which is not in the except clause. Addresses Gemini Code Assist review feedback on PR homeassistant-ai#893. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 09f4b69 commit e0fce36

4 files changed

Lines changed: 579 additions & 672 deletions

File tree

.github/workflows/performance-tests.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ permissions:
2727
env:
2828
PYTHON_VERSION: "3.13"
2929
UV_CACHE_DIR: /tmp/.uv-cache
30+
# renovate: datasource=docker depName=ghcr.io/home-assistant/home-assistant
31+
HA_IMAGE_GHCR: "ghcr.io/home-assistant/home-assistant:2026.4.1"
3032

3133
jobs:
3234
performance-tests:
@@ -39,6 +41,38 @@ jobs:
3941

4042
- name: Set up Docker Buildx
4143
uses: docker/setup-buildx-action@v4
44+
with:
45+
cache-binary: true
46+
47+
- name: Cache HA Docker image
48+
id: cache-ha-image
49+
uses: actions/cache@v5
50+
with:
51+
path: /tmp/ha-image.tar
52+
key: ha-image-${{ env.HA_IMAGE_GHCR }}-${{ runner.arch }}
53+
54+
- name: Load cached HA image
55+
if: steps.cache-ha-image.outputs.cache-hit == 'true'
56+
run: docker load -i /tmp/ha-image.tar
57+
58+
- name: Pull HA image (GHCR → Docker Hub fallback)
59+
if: steps.cache-ha-image.outputs.cache-hit != 'true'
60+
run: |
61+
HA_VERSION="${HA_IMAGE_GHCR##*:}"
62+
HA_IMAGE_DOCKERHUB="homeassistant/home-assistant:${HA_VERSION}"
63+
for registry in "$HA_IMAGE_GHCR" "$HA_IMAGE_DOCKERHUB"; do
64+
echo "Trying $registry..."
65+
if docker pull "$registry"; then
66+
if [ "$registry" != "$HA_IMAGE_GHCR" ]; then
67+
docker tag "$registry" "$HA_IMAGE_GHCR"
68+
fi
69+
docker save "$HA_IMAGE_GHCR" -o /tmp/ha-image.tar
70+
echo "Pulled and cached from $registry"
71+
exit 0
72+
fi
73+
done
74+
echo "Failed to pull from any registry"
75+
exit 1
4276
4377
- name: Install uv
4478
uses: astral-sh/setup-uv@v7

src/ha_mcp/__main__.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ def _get_oauth_client(self) -> "HomeAssistantClient":
6161
claims = token.claims
6262

6363
if not claims or "ha_token" not in claims:
64-
logger.error(f"OAuth token missing HA credentials. Keys present: {list(claims.keys()) if claims else []}")
64+
logger.error(
65+
f"OAuth token missing HA credentials. Keys present: {list(claims.keys()) if claims else []}"
66+
)
6567
raise RuntimeError("No Home Assistant credentials in OAuth token claims")
6668

6769
ha_token = claims["ha_token"]
@@ -610,7 +612,9 @@ def register_browser_landing(mcp_instance: "FastMCP | _DeferredMCP", path: str)
610612
path: The MCP endpoint path (e.g. "/mcp" or a secret path).
611613
"""
612614
if path in _registered_landing_paths:
613-
logger.warning("register_browser_landing: %r already registered, skipping", path)
615+
logger.warning(
616+
"register_browser_landing: %r already registered, skipping", path
617+
)
614618
return
615619
_registered_landing_paths.add(path)
616620

@@ -710,6 +714,15 @@ def main_oauth() -> None:
710714
Note: HOMEASSISTANT_TOKEN is NOT required in this mode.
711715
Per-user tokens are collected via the OAuth consent form.
712716
"""
717+
# In OAuth mode, per-user tokens come from the consent form — no
718+
# server-level HOMEASSISTANT_TOKEN is needed. Set the sentinel so
719+
# Settings validation passes even when the env var is empty (e.g.
720+
# Dockerfile sets HOMEASSISTANT_TOKEN=""). Fixes #886.
721+
if not os.getenv("HOMEASSISTANT_TOKEN"):
722+
from ha_mcp.config import OAUTH_MODE_TOKEN
723+
724+
os.environ["HOMEASSISTANT_TOKEN"] = OAUTH_MODE_TOKEN
725+
713726
# Configure logging for OAuth mode
714727
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
715728
_setup_logging(log_level, force=True)
@@ -791,9 +804,7 @@ async def _run_oauth_server(ha_url: str, base_url: str, port: int, path: str) ->
791804
f"Starting OAuth-enabled MCP server with {len(tools)} tools on {base_url}{path}"
792805
)
793806

794-
await _run_with_shutdown(
795-
mcp.run_async(**_http_run_kwargs("http", port, path))
796-
)
807+
await _run_with_shutdown(mcp.run_async(**_http_run_kwargs("http", port, path)))
797808

798809

799810
if __name__ == "__main__":

0 commit comments

Comments
 (0)