Authentication

Afero accounts use OpenID Connect with refresh tokens. aioafero handles the full login handshake in AferoAuth — separate from AferoBridgeV1, which only needs tokens at runtime.

Import from the v1 namespace:

from aioafero import v1

AferoAuth, TokenData, and OTPRequired are exported on aioafero.v1 (not the top-level aioafero package) so future API versions can ship their own auth flows.

Overview

There are two ways to construct AferoAuth (both require session):

Credential login (one-time or rare)

Use for_login() when you have a username and password. Call login() (or submit_otp() after OTP) to obtain TokenData. Persist token_data.refresh_token — the password is not needed again.

Runtime session (normal operation)

Pass a saved refresh_token (and optionally a still-valid bearer token) via AferoAuth(session, username, refresh_token, token=..., token_expiration=...) or AferoBridgeV1. When passing token, also pass token_expiration (Unix timestamp) if you know when it expires; otherwise the library treats the bearer as stale and refreshes on first use. OAuth responses still use expires_in minus a 2-second buffer.

        flowchart TD
    login["for_login(session, user, password)"]
    submit["login() / submit_otp()"]
    tokens["TokenData"]
    persist["persist refresh_token"]
    bridge["AferoBridgeV1(user, refresh_token, session)"]
    init["bridge.initialize()"]
    api["auth.token() on each API call"]

    login --> submit --> tokens --> persist --> bridge --> init --> api
    submit -.->|"OTP: user enters emailed code"| submit
    

HTTP session

AferoAuth uses an aiohttp.ClientSession for OpenID and token-endpoint requests. Pass the same session to AferoBridgeV1 so login and API traffic share one connection pool:

import aiohttp
from aioafero import v1

session = aiohttp.ClientSession()
auth = v1.AferoAuth.for_login(session, USERNAME, PASSWORD)
token_data = await auth.login()

bridge = v1.AferoBridgeV1(USERNAME, token_data.refresh_token, session=session)
await bridge.initialize()
# ...
await bridge.close()
await session.close()

Both AferoAuth and AferoBridgeV1 require an aiohttp.ClientSession at construction. Create one (as above) and pass it to login and the bridge. The bridge does not close a session you pass in — call await session.close() after await bridge.close().

Alternatively, open() may create a session when session is omitted; that owned session is closed by await bridge.close().

Credential login

Accounts without OTP — a single login() call returns TokenData:

import aiohttp
from aioafero import v1

session = aiohttp.ClientSession()
auth = v1.AferoAuth.for_login(session, "user@example.com", "password")
token_data = await auth.login()

print(token_data.refresh_token)  # save for next run

Accounts with OTP — login is a two-phase flow. login() validates the password, Afero emails a one-time code to the account, and submit_otp() sends that code to complete the handshake. aioafero does not read email or generate the code; your application must collect it from the user (config-flow step, CLI prompt, form field, etc.) and pass it to submit_otp.

import aiohttp
from aioafero import v1

session = aiohttp.ClientSession()
auth = v1.AferoAuth.for_login(session, "user@example.com", "password")

try:
    token_data = await auth.login()
except v1.OTPRequired:
    # Wait for the user to read the email and enter the code.
    # Keep the same auth instance — it holds partial login state in memory.
    code = input("Enter the code from your email: ")
    token_data = await auth.submit_otp(code.strip())

print(token_data.refresh_token)

login() and submit_otp() both return TokenData. Store at least the refresh token; you may also save the bearer token field to skip one refresh on the next startup if it has not expired.

After the credential POST, aioafero clears the password from the AferoAuth instance. Discard that login object once you have token_data — do not keep a for_login instance around for runtime. Your own local password variable is outside the library’s scope.

OTP

When OTP is enabled on the account, login() raises OTPRequired after the password is accepted. The library has already stored the partial OpenID session on that AferoAuth instance (_otp_data); call submit_otp on the same instance once you have the code — do not create a new for_login unless login timed out or failed.

What aioafero does not do:

  • Send or receive email

  • Poll an inbox for the code

  • Block until a code arrives

What you must provide:

  • A way for the user to enter the emailed code (integrations typically add a second setup step after OTPRequired)

  • The same AferoAuth object between login() and submit_otp()

On a wrong code, InvalidOTP is raised. The partial session is updated so you can prompt again without restarting for_login.

TokenData

TokenData is a NamedTuple returned by login and refresh operations:

  • token — bearer ID token sent in API Authorization headers

  • access_token — OAuth access token from the token endpoint

  • refresh_token — long-lived token used to obtain new bearer tokens

  • expiration — Unix timestamp when the bearer token should be refreshed

The bridge reads tokens through its internal AferoAuth. After a running session you can read the latest refresh token from bridge.refresh_token (it may rotate on refresh).

Runtime tokens

When you already have a refresh token, construct the bridge directly — no password and no for_login:

import aiohttp
from aioafero import v1

session = aiohttp.ClientSession()
bridge = v1.AferoBridgeV1(
    "user@example.com",
    saved_refresh_token,
    session=session,
    token=saved_bearer_token,  # optional
)
await bridge.initialize()
# ...
await bridge.close()
await session.close()

If token is provided and not expired, the first API call skips a refresh round-trip. Otherwise AferoAuth.token() exchanges the refresh token for a new bearer token automatically.

Restore or update tokens on an existing bridge with set_token_data().

Token refresh

token() returns the current bearer token, refreshing it when expiration has passed. Refresh uses only the stored refresh token — there is no password fallback.

If the refresh token is rejected, InvalidAuth is raised and the caller must run a full login again via for_login.

Bridge integration

AferoBridgeV1 creates an internal AferoAuth from username, refresh_token, session, and optional token. You do not need a separate auth object for normal bridge use after the initial login.

Typical integration pattern (e.g. Home Assistant):

  1. Setup — for_login → login() / submit_otp() → save refresh_token in config storage.

  2. Runtime — AferoBridgeV1(username, refresh_token, session) on every start; never persist the password.

  3. Re-auth — if InvalidAuth occurs at runtime, prompt the user to log in again and replace the stored refresh token.

See Bridge configuration and lifecycle for bridge construction options and lifecycle.

Local session cache (scripts)

For offline testing, TokenData.to_session_dict / TokenData.from_session_dict serialize tokens without putting filesystem paths on the bridge. Helper scripts use a local afero.yaml:

cp scripts/afero.yaml.example afero.yaml
uv sync --extra cli
uv run --extra cli python scripts/afero_login.py    # writes session file
uv run --extra cli python scripts/afero_bridge.py   # opens the bridge

afero.yaml and .aioafero-session.json are gitignored. Password is never stored — use AFERO_PASSWORD or an interactive prompt for login.

Configuration

AferoAuth.for_login and runtime construction accept:

  • afero_client — "hubspace" (default; only supported client today)

  • hide_secrets — redact tokens from debug logs via securelogging (default True)

  • client_name — substituted into the User-Agent string (default "aioafero")

Errors

See Troubleshooting for operational guidance.

API reference

Autodoc: aioafero.v1 (AferoAuth, TokenData are exported on the package; implementation module aioafero.v1.auth).