Skip to content

Commit 796e6f4

Browse files
fix: support opaque access tokens in OIDC mode (#2194)
* docs: design OIDC Authelia compatibility fix * docs: plan OIDC Authelia compatibility fix * fix: require openid scope in OIDC mode * fix: apply log level to FastMCP * docs: explain Authelia OIDC configuration * test: restore OIDC logging test state * fix: address OIDC review feedback * fix: preserve OIDC scope compatibility * docs: clarify OIDC verification contract * docs: make OIDC test command explicit * fix: address OIDC compatibility review --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5703328 commit 796e6f4

8 files changed

Lines changed: 1694 additions & 74 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ These run the server outside Home Assistant — useful for **Container** / **Cor
8585
- **Docker (HTTP server):** run `ghcr.io/homeassistant-ai/ha-mcp` in HTTP mode, pointed at your Home Assistant URL and a long-lived token, and connect your client to its secret URL. See the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/) for the full command and per-client config.
8686
- **PyPI / uvx (HTTP server):** run the published `ha-mcp` package with `uvx ha-mcp@latest` (or pip) as a streamable-HTTP server the same way. Details in the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/).
8787
- **Local stdio (not recommended):** runs ha-mcp on your own machine over stdio. The one-command installers in the **Demo server** section below use this path; the [Setup Wizard](https://homeassistant-ai.github.io/ha-mcp/setup/) covers connecting it to your own Home Assistant.
88-
- **OIDC authentication:** gate remote access behind an external identity provider (Authentik, Keycloak, Auth0, Google, etc.) instead of a secret URL — all authenticated users share the server's Home Assistant credentials. See [OIDC Mode](docs/oidc.md).
88+
- **OIDC authentication:** gate remote access behind an external identity provider (Authentik, Keycloak, Auth0, etc.) instead of a secret URL — all authenticated users share the server's Home Assistant credentials. See [OIDC Mode](docs/oidc.md).
8989

9090
> ⚠️ **stdio has known transport issues.** The stdio transport has connection problems that streamable HTTP does not ([#1713](https://github.com/homeassistant-ai/ha-mcp/issues/1713)). It is recommended only for demo/testing tinkering — for a real setup, use the custom component or an HTTP method above.
9191

docs/oidc.md

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# OIDC Authentication for ha-mcp
22

33
OIDC mode gates access to the MCP server behind an external identity provider
4-
(Authentik, Keycloak, Auth0, Google, etc.). Unlike [OAuth mode](OAUTH.md),
4+
(Authentik, Keycloak, Auth0, etc.). Unlike [OAuth mode](OAUTH.md),
55
which collects a per-user Home Assistant Long-Lived Access Token via a consent
66
form, OIDC is purely an **access gate**: once a user authenticates through
77
your OIDC provider, all requests share the same Home Assistant credentials
@@ -77,16 +77,19 @@ as [OAuth mode](OAUTH.md#1-expose-with-https)).
7777
| `HA_MCP_DISABLE_SETTINGS_UI` | Set truthy (`1`/`true`/`yes`/`on`) to not serve the web settings UI at all. | unset (UI served) |
7878
| `OIDC_JWT_SIGNING_KEY` | Optional. Secret key for signing FastMCP session JWTs. Sessions **persist across restarts by default** — when unset, FastMCP derives the signing key deterministically from `OIDC_CLIENT_SECRET`, so restarting the server does not log users out. Set this var to decouple the signing key from the client secret. To force a logout of all sessions, rotate whichever secret the key derives from (this var if set, otherwise `OIDC_CLIENT_SECRET`). Generate with: `python -c "import secrets; print(secrets.token_urlsafe(32))"` | Derived from `OIDC_CLIENT_SECRET` |
7979
| `OIDC_ALLOWED_CLIENT_REDIRECT_URIS` | Optional but **strongly recommended for internet-facing deployments**. Comma-separated list of redirect URI patterns accepted from dynamically-registered clients. Open dynamic client registration (DCR) lets an attacker register their own client with their own redirect URI; setting this constrains what any dynamically-registered client may register in the first place. | no allow-list — each dynamically-registered client's own redirect URIs are accepted |
80-
| `OIDC_VERIFY_ID_TOKEN` | Optional. Set `true` for OIDC providers that issue opaque access tokens the default JWT verifier can't validate (e.g. Google always; Auth0 without an API audience configured). | `false` |
80+
| `OIDC_VERIFY_ID_TOKEN` | Optional. Set `true` for OIDC providers that issue opaque access tokens the default JWT verifier cannot validate (e.g. Authelia, or Auth0 without an API audience configured). ha-mcp always requests `openid`, so these providers return the ID token FastMCP verifies instead. | `false` |
8181
| `OIDC_AUDIENCE` | Optional. Expected `aud` claim for IdP-issued access tokens. Without it (and with `OIDC_VERIFY_ID_TOKEN` off), FastMCP's JWT verifier checks issuer, signature, and expiry but not audience — fine on a dedicated IdP, weaker on a shared one where other clients' tokens would also pass. With `OIDC_VERIFY_ID_TOKEN=true`, verification instead pins `aud` to `OIDC_CLIENT_ID` and this value is not used for verification — it is still forwarded to the IdP's authorize/token endpoints, which is exactly what makes Auth0 issue JWT rather than opaque access tokens (see the `OIDC_VERIFY_ID_TOKEN` row). | Unset (no audience check) |
82-
| `LOG_LEVEL` | Logging level | `INFO` |
82+
| `LOG_LEVEL` | Logging level for ha-mcp and, when FastMCP logging is enabled, FastMCP's OIDC token-validation diagnostics | `INFO` |
83+
| `FASTMCP_LOG_ENABLED` | Set `false` to disable FastMCP framework logging entirely. ha-mcp preserves this opt-out instead of routing FastMCP records through its root logger. | `true` |
8384

8485
## IdP Client Registration
8586

8687
When registering ha-mcp as an OAuth/OIDC application with your provider:
8788

8889
- **Redirect URI:** `<MCP_BASE_URL>/auth/callback`
8990
- **Grant type:** Authorization Code
91+
- **Allowed scope:** `openid`. ha-mcp always requests this protocol-level OIDC
92+
scope, so the provider must allow it for the registered client.
9093
- **Token endpoint auth method:** **Client Secret Basic.** ha-mcp's OIDC
9194
client (via authlib) does not pass `token_endpoint_auth_method`, so it uses
9295
authlib's default, which is Client Secret Basic — not Client Secret Post.
@@ -98,18 +101,44 @@ Example discovery URLs:
98101
- Authentik: `https://auth.example.com/application/o/<app-slug>/.well-known/openid-configuration`
99102
- Keycloak: `https://keycloak.example.com/realms/<realm>/.well-known/openid-configuration`
100103
- Auth0: `https://<tenant>.auth0.com/.well-known/openid-configuration`
101-
- Google: `https://accounts.google.com/.well-known/openid-configuration`
104+
105+
## Client Scopes and Upgrades
106+
107+
ha-mcp keeps `openid` as the required, default, and advertised scope. A client
108+
that omits `scope` during dynamic client registration (DCR) therefore receives
109+
`openid`, and MCP clients discover only `openid` in protected-resource
110+
metadata. If a client explicitly requests optional provider scopes such as
111+
`profile`, `email`, or `offline_access`, ha-mcp retains them and adds `openid`
112+
when the client omitted it. The compatibility layer also adds `openid` to every
113+
upstream authorization request, including requests from clients that supplied
114+
only optional scopes. ha-mcp does not advertise or request optional scopes on
115+
the client's behalf; the upstream identity provider's client and user policy
116+
decides whether to grant the optional scopes the client requested.
117+
118+
After upgrading from a version that did not require `openid`, ha-mcp retains
119+
persisted DCR registrations and adds `openid` to a registration the first time
120+
it is loaded. A live access token issued before the fix without `openid` is
121+
rejected on its next MCP request, and its legacy refresh token is rejected too,
122+
so the client starts authorization once and obtains compliant tokens. The DCR
123+
registration remains intact during this reauthorization. Most clients restart
124+
authorization automatically; manually clearing the client's connector or
125+
authorization cache is only a fallback for a client that does not.
102126

103127
## Provider Compatibility
104128

105129
OIDC mode works out of the box with providers that issue **JWT access
106130
tokens** — Authentik and Keycloak are known to work without extra
107131
configuration.
108132

109-
Providers that issue **opaque access tokens** (not JWTs) need
110-
`OIDC_VERIFY_ID_TOKEN=true` so FastMCP verifies the ID token instead of the
111-
access token:
112-
- **Google** always issues opaque access tokens.
133+
ha-mcp always requests the required `openid` scope. Providers that issue
134+
**opaque access tokens** (not JWTs) also need `OIDC_VERIFY_ID_TOKEN=true` so
135+
FastMCP verifies the returned ID token instead of trying to parse the access
136+
token as a JWT:
137+
138+
- **Authelia** issues opaque access tokens by default. Allow `openid` in the
139+
client's `scopes` and set `OIDC_VERIFY_ID_TOKEN=true` for ha-mcp. You do not
140+
need to change Authelia's `access_token_signed_response_alg` to enable JWT
141+
access tokens.
113142
- **Auth0** issues opaque access tokens unless the client requests a
114143
configured API audience.
115144

0 commit comments

Comments
 (0)