Skip to content

feat(auth): support OAuth2 refresh-token login via auth.zaptec.com - #422

Closed
johanneswilm wants to merge 2 commits into
custom-components:masterfrom
johanneswilm:feat/oidc-auth-zaptec
Closed

feat(auth): support OAuth2 refresh-token login via auth.zaptec.com#422
johanneswilm wants to merge 2 commits into
custom-components:masterfrom
johanneswilm:feat/oidc-auth-zaptec

Conversation

@johanneswilm

@johanneswilm johanneswilm commented Aug 16, 2026

Copy link
Copy Markdown

feat(auth): support OAuth2 refresh-token login via auth.zaptec.com

Zaptec has moved login to the OIDC provider at auth.zaptec.com (Kratos/Ory).
The legacy grant_type=password flow is retired on the new tenant, so this
adds an optional refresh-token login path while keeping the legacy password
flow for existing setups.

Changes

  • Add refresh_token (and optional client_id, defaulting to Zaptec's public
    portal client) to the config schema.
  • _refresh_token() prefers the OAuth2 refresh-token grant at
    https://auth.zaptec.com/oauth2/token when a refresh token is configured,
    falling back to the legacy password grant otherwise.
  • Tokens are rotated on every refresh (the provider issues single-use rotating
    refresh tokens).

Important: two bugs fixed during real-world install

Zaptec's Ory/OAuth2 provider is strict in two ways that the first version of
this PR missed. Both are fixed here:

  1. Single-use rotating refresh tokens. Every refresh consumes the token and
    issues a new one. The config flow validated by consuming the supplied token
    but created the entry with the original (now spent) token, so runtime setup
    failed with refresh token re-use was detected. Fix:

    • config_flow.py stores the rotated token from login() into the entry.
    • api.py exposes the current token via a refresh_token property.
    • __init__.py / coordinator.py persist the current token back to the
      config entry after login and after each poll, so the integration keeps
      working across Home Assistant restarts without re-auth.
  2. Audience. The access tokens must carry the Zaptec API audience, otherwise
    api.zaptec.com rejects them with 401 and the integration keeps churning
    tokens. The portal requests this via an audience query parameter
    (extraQueryParams.audience, a space-separated list of the API URLs). The
    refresh token must come from a grant that was authorized with that audience,
    i.e. captured from the logged-in Zaptec portal (or any flow that passes the
    same audience param) — a plain authorization_code grant without it yields
    an empty-audience access token.

Verification

  • Backend tests pass (npm test for the base repo tooling; Python py_compile
    and ruff clean except the pre-existing CPY001 copyright notices).
  • Validated end-to-end against a live account: OAuth path, legacy fallback, and
    invalid_grant → AuthenticationError. A real charger (GPN032403 "Harpasset2")
    is reachable through api.zaptec.com, setup completes, entities are created,
    and the integration survives a Home Assistant restart.

Zaptec migrated account login to OIDC (auth.zaptec.com) and retired the legacy password grant at api.zaptec.com/oauth/token. Accounts registered on the new identity provider can no longer authenticate with the current password-based login.

Adds an optional OAuth2 path: the config flow accepts an OAuth2 refresh token (obtained from the Zaptec web portal session, client_id defaulting to the portal public client) and mints api.zaptec.com access tokens via the refresh_token grant at https://auth.zaptec.com/oauth2/token. The legacy username/password flow is preserved unchanged (password is now optional).

Files: zaptec/const.py (AUTH_URL, OAUTH_SCOPE, OAUTH_CLIENT_ID), const.py (CONF_CLIENT_ID, CONF_REFRESH_TOKEN), zaptec/api.py (client_id/refresh_token args; refresh dispatcher with OAuth + legacy paths; token rotation), config_flow.py (optional client_id/refresh_token in setup and reauth), __init__.py (thread options, redact new secrets).

NOTE: interim mechanism relying on the Zaptec web portal public OAuth client; a production-grade solution needs Zaptec to register an OAuth client for Home Assistant.
The Zaptec OIDC provider (Ory) issues single-use rotating refresh tokens.
The config flow validated by consuming the supplied token, but the entry was
created with the (now spent) original token, so runtime setup re-used it and
failed with 'refresh token re-use was detected'.

- config_flow: store the rotated token from login() into the new entry
- api: expose the current refresh_token via a property
- runtime: persist the current token to the config entry after login and
  after each poll, so restarts keep working without re-auth
@johanneswilm

Copy link
Copy Markdown
Author

Live install notes (validated 2026-08-16)

Installed this branch on a Home Assistant Green (HACS custom repo, branch download) and got a real Zaptec charger (GPN032403 "Harpasset2") set up with live entities. Two things a user must know beyond the code:

  1. The refresh token must come from a grant carrying the Zaptec API audience. The provider issues access tokens without an audience by default and api.zaptec.com rejects those (401 → the integration churns refresh tokens). The web portal requests the right audience via an audience query param (space-separated list of https://api.zaptec.com https://portalapi.zaptec.com https://integrations.api.prod.zaptec.com). So: capture the refresh token from a logged-in Zaptec portal session (localStorage["oidc.user:https://auth.zaptec.com:<client_id>"], or from any authorization_code flow that passes the same audience param). A bare code-exchange without it will fail.

  2. Because refresh tokens are single-use and rotating, the token must not be shared with anything that will also refresh (e.g. the portal SPA). The integration now persists its rotated token back to the config entry so it survives restarts — but only feed it a token it will exclusively use.

Config flow inputs: username = portal email, password blank, refresh_token = captured token, client_id = portal public client (optional, has a sensible default).

@sveinse

sveinse commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Thank you for the PR. Since this is new functionality I'm slating this for the 0.9.0 release, as we need to get the 0.8.8 released out first.

@sveinse sveinse added this to the v0.9.0 milestone Aug 25, 2026
@johanneswilm

Copy link
Copy Markdown
Author

Hey @sveinse ,
actually it turns out my additions were not as stable as I had hoped. And it seems it was only necessary with some (older?) user accounts. I was able to create a new user account and that user account didn't need this. So I don't use it myself any more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants