pyisyox.auth module

Authentication strategies for IoX 6 controllers.

Two modes share a single Auth protocol so the HTTP client can be auth-agnostic:

  • LocalAuth — HTTP basic against :8443/rest/* with the local admin account. Offline by construction. Feature-degraded: no /api/triggers AST, no /api/variables names; must use the legacy XML /rest/nodes for structure. Recommended only when the user refuses to use a portal account.

  • PortalAuth — JWT bearer obtained from POST :443/api/login. Auto-refreshes via POST :443/api/jwt/refresh. Verified offline-safe on 2026-05-07: the eisy validates portal credentials and signs the JWT locally; no my.isy.io round-trip during steady-state. Recommended default — unlocks the modern /api/* JSON surface.

The PortalAuth flow leaks the PG3 MQTT TLS keypair under data.ssl in the login response. redact_sensitive() (see pyisyox.redactor) MUST be applied to any debug-level logging of the response body.

Endpoint discovery and shape verification: POST /api/login body {"username": "<email>", "password": "<password>"}; response {"successful": true, "data": {"accessToken": "<es256-jwt>", "refreshToken": "<es256-jwt>", "ssl": {...}, ...}}. Refresh body {"refreshToken": "<rt>"} returns the same data shape.

exception AuthError[source]

Bases: Exception

Authentication failure (login rejected, refresh failed, etc.).

class TokenPair(access_token, refresh_token, access_expires_at=0.0, refresh_expires_at=0.0)[source]

Bases: object

JWT access + refresh tokens with decoded expiry for proactive refresh.

Variables:
  • access_token (str) – Short-lived bearer token (default 1 h TTL).

  • refresh_token (str) – Long-lived token used to mint a new access token (default 30 d TTL).

  • access_expires_at (float) – Unix timestamp at which access_token expires. Decoded from the JWT exp claim. 0 if the token couldn’t be decoded.

  • refresh_expires_at (float) – Unix timestamp at which refresh_token expires. 0 if undecoded.

Parameters:
  • access_token (str)

  • refresh_token (str)

  • access_expires_at (float)

  • refresh_expires_at (float)

access_token: str
refresh_token: str
access_expires_at: float
refresh_expires_at: float
classmethod from_response(data)[source]

Build a TokenPair from a /api/login or /api/jwt/refresh response data dict.

Parameters:

data (dict[str, Any])

Return type:

TokenPair

access_expires_within(seconds, now=None)[source]

True if the access token expires within seconds of now.

Used to trigger a proactive refresh before a request, avoiding the cost of one round-trip + one 401 + one refresh + one retry.

Parameters:
Return type:

bool

class Auth(*args, **kwargs)[source]

Bases: Protocol

Auth strategy protocol shared by LocalAuth and PortalAuth.

Not @runtime_checkableisinstance(x, Auth) against an unrelated class that happens to share these attribute names would pass without verifying coroutine signatures, which masks bugs. Tests construct the concrete classes directly.

The HTTP client calls authenticate() once during connect, then request_kwargs() before every request to obtain the kwargs to splat into session.get(...)/session.post(...). On a 401 response, the client calls handle_unauthorized(); if it returns True, the client retries the original request once.

async authenticate(session, base_url)[source]

Perform any one-time authentication setup (e.g., login POST).

Parameters:
Return type:

None

async request_kwargs(session, base_url)[source]

Return kwargs for session.request() (auth, headers, etc.).

Parameters:
Return type:

dict[str, Any]

async handle_unauthorized(session, base_url)[source]

Handle a 401 response. Return True if re-auth succeeded and the original request should be retried; False if the auth state cannot recover (caller should propagate the 401 as a permanent error).

Parameters:
Return type:

bool

async close(session, base_url)[source]

Release any auth-held resources (e.g., explicit logout).

session and base_url are passed so implementations can make a final logout call to invalidate server-side state (PortalAuth posts /api/logout); LocalAuth ignores them since basic-auth has no server-side session.

Parameters:
Return type:

None

class LocalAuth(username, password)[source]

Bases: object

HTTP basic auth against :8443/rest/* with the local admin account.

No login round-trip is needed — credentials are passed on every request. A 401 on this path means the credentials are wrong, so re-auth cannot recover.

Parameters:
  • username (str)

  • password (str)

async authenticate(session, base_url)[source]

No-op — basic auth attaches per request.

Parameters:
Return type:

None

async request_kwargs(session, base_url)[source]

Return kwargs that attach HTTP basic auth.

Parameters:
Return type:

dict[str, Any]

async handle_unauthorized(session, base_url)[source]

Cannot recover from 401 with basic auth — credentials are wrong.

Parameters:
Return type:

bool

async close(session, base_url)[source]

No-op — basic auth has no server-side session to tear down.

Parameters:
Return type:

None

class PortalAuth(email, password)[source]

Bases: object

JWT bearer auth from POST :443/api/login.

Maintains an in-memory TokenPair with proactive refresh. On 401, attempts one refresh; if refresh fails (or has expired), falls back to a fresh login.

Login URL: {base_url}/api/login. Refresh URL: {base_url}/api/jwt/refresh. Logout URL (optional, on close()): {base_url}/api/jwt/logout. Verified against eisy 1.0.3 — POST /api/jwt/logout returns 200 with {"successful": true, "data": null}. (Pre-2026-05-12 versions of this module used /api/logout, which 404s.)

Parameters:
LOGIN_PATH
REFRESH_PATH
LOGOUT_PATH
PROACTIVE_REFRESH_LEEWAY

Number of seconds before access-token expiry at which we proactively refresh.

property tokens: TokenPair | None

Currently held tokens, or None if not yet authenticated.

Tests use this to assert state without forcing a real network round-trip.

async authenticate(session, base_url)[source]

Perform POST /api/login and store the returned token pair.

Concurrent calls collapse onto a single login round-trip via the instance lock; the second caller observes the tokens already set and returns without making a network request.

Raises:

AuthError – When the login response is not successful: true or lacks tokens.

Parameters:
Return type:

None

async request_kwargs(session, base_url)[source]

Return Authorization: Bearer <accessToken> headers.

Refreshes the token proactively if it expires within PROACTIVE_REFRESH_LEEWAY seconds, avoiding the cost of an in-flight 401 + refresh + retry round. Concurrent callers that observe an expiring token both queue on the auth lock; the winner refreshes once, the runners-up re-check and skip.

Parameters:
Return type:

dict[str, Any]

async handle_unauthorized(session, base_url)[source]

Handle 401: try refresh, then re-login.

Concurrent 401s from in-flight requests all enter _refresh_or_relogin; the first runs the refresh, subsequent callers re-check the cached token (which has just been updated) and skip the network round-trip. Returns True if re-auth succeeded and the caller should retry the original request; False if both refresh and login failed.

Parameters:
Return type:

bool

async close(session, base_url)[source]

Best-effort logout against POST /api/jwt/logout, then clear the in-memory tokens.

If we don’t tell the eisy we’re done, the refresh token stays live for its full 30-day TTL — useful only to attackers who somehow obtain it. The logout call is best-effort: any error (network down, controller already gone, stale token) is logged at debug level and swallowed. The local token state is cleared regardless so the consumer can construct a fresh PortalAuth and re-authenticate.

Parameters:
Return type:

None