Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@
Changelog
=========

Version 4.8.0 (2025-10-27)
==========================

Added
-----

- **Token Restoration Support**: Enable session persistence across application restarts

- Added ``stored_tokens`` parameter to ``NavienAuthClient.__init__()`` for restoring saved tokens
- Added ``AuthTokens.to_dict()`` method for serializing tokens (includes ``issued_at`` timestamp)
- Enhanced ``AuthTokens.from_dict()`` to support both API responses (camelCase) and stored data (snake_case)
- Modified ``NavienAuthClient.__aenter__()`` to skip authentication when valid stored tokens are provided
- Automatically refreshes expired JWT tokens or re-authenticates if AWS credentials expired
- Added 7 new tests for token serialization, deserialization, and restoration flows
- Added ``examples/token_restoration_example.py`` demonstrating save/restore workflow
- Updated authentication documentation with token restoration guide

- **Benefits**: Reduces API load, improves startup time, prevents rate limiting for frequently restarting applications (e.g., Home Assistant)

Version 4.7.1 (2025-10-27)
==========================

Expand Down
106 changes: 105 additions & 1 deletion docs/python_api/auth_client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ API Reference
NavienAuthClient
----------------

.. py:class:: NavienAuthClient(email=None, password=None, base_url=API_BASE_URL)
.. py:class:: NavienAuthClient(email=None, password=None, base_url=API_BASE_URL, stored_tokens=None)

JWT-based authentication client for Navien Smart Control API.

Expand All @@ -70,6 +70,8 @@ NavienAuthClient
:type password: str or None
:param base_url: API base URL
:type base_url: str
:param stored_tokens: Previously saved tokens to restore session
:type stored_tokens: AuthTokens or None

**Example:**

Expand All @@ -81,11 +83,24 @@ NavienAuthClient
# From environment variables
auth = NavienAuthClient()

# With stored tokens (skip re-authentication)
stored = AuthTokens.from_dict(saved_data)
auth = NavienAuthClient(
"email@example.com",
"password",
stored_tokens=stored
)

# Always use as context manager
async with auth:
# Authenticated
pass

.. note::
If ``stored_tokens`` are provided and still valid, the initial
sign-in is skipped. If tokens are expired, they're automatically
refreshed or re-authenticated as needed.

Authentication Methods
----------------------

Expand Down Expand Up @@ -318,13 +333,47 @@ AuthTokens
:param access_key_id: AWS access key (for MQTT)
:param secret_key: AWS secret key (for MQTT)
:param session_token: AWS session token (for MQTT)
:param issued_at: Token issue timestamp (auto-set if not provided)

**Properties:**

* ``expires_at`` - Expiration timestamp
* ``is_expired`` - Check if expired
* ``time_until_expiry`` - Time remaining
* ``bearer_token`` - Formatted bearer token
* ``are_aws_credentials_expired`` - Check if AWS credentials expired

**Methods:**

.. py:method:: from_dict(data)
:classmethod:

Create AuthTokens from dictionary (API response or saved data).

:param data: Token data dictionary
:type data: dict[str, Any]
:return: AuthTokens instance
:rtype: AuthTokens

Supports both camelCase keys (API response) and snake_case keys (saved data).

.. py:method:: to_dict()

Serialize tokens to dictionary for storage.

:return: Dictionary with all token data including issued_at timestamp
:rtype: dict[str, Any]

**Example:**

.. code-block:: python

# Save tokens
tokens = auth.current_tokens
token_data = tokens.to_dict()

# Later, restore tokens
restored = AuthTokens.from_dict(token_data)

AuthenticationResponse
----------------------
Expand Down Expand Up @@ -418,6 +467,61 @@ Example 4: Long-Running Application
# Sleep
await asyncio.sleep(3600)

Example 5: Token Restoration (Skip Re-authentication)
------------------------------------------------------

.. code-block:: python

import json
from nwp500 import NavienAuthClient
from nwp500.auth import AuthTokens

async def save_tokens():
"""Save tokens for later reuse."""
async with NavienAuthClient(email, password) as auth:
tokens = auth.current_tokens

# Serialize tokens to dictionary
token_data = tokens.to_dict()

# Save to file (or database, cache, etc.)
with open('tokens.json', 'w') as f:
json.dump(token_data, f)

print("Tokens saved for future use")

async def restore_tokens():
"""Restore authentication from saved tokens."""
# Load saved tokens
with open('tokens.json') as f:
token_data = json.load(f)

# Deserialize tokens
stored_tokens = AuthTokens.from_dict(token_data)

# Initialize client with stored tokens
# This skips initial authentication if tokens are still valid
async with NavienAuthClient(
email, password,
stored_tokens=stored_tokens
) as auth:
# If tokens were expired, they're automatically refreshed
# If AWS credentials expired, re-authentication occurs
print(f"Authenticated (from stored tokens): {auth.user_email}")

# Always save updated tokens after refresh
new_tokens = auth.current_tokens
if new_tokens.issued_at != stored_tokens.issued_at:
token_data = new_tokens.to_dict()
with open('tokens.json', 'w') as f:
json.dump(token_data, f)
print("Tokens were refreshed and re-saved")

.. note::
Token restoration is especially useful for applications that restart
frequently (like Home Assistant) to avoid unnecessary authentication
requests on every restart.

Error Handling
==============

Expand Down
154 changes: 154 additions & 0 deletions examples/token_restoration_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Example demonstrating token restoration/persistence.

This example shows how to save and restore authentication tokens to avoid
re-authenticating on every application restart. This is especially useful
for applications like Home Assistant that restart frequently.

Usage:
# First run - authenticate and save tokens
python3 token_restoration_example.py --save

# Subsequent runs - restore from saved tokens
python3 token_restoration_example.py --restore
"""

import argparse
import asyncio
import json
import logging
import os
from pathlib import Path

from nwp500 import NavienAuthClient

# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)

# Token storage file
TOKEN_FILE = Path.home() / ".navien_tokens.json"


async def save_tokens_example():
"""Authenticate and save tokens for future use."""
email = os.getenv("NAVIEN_EMAIL")
password = os.getenv("NAVIEN_PASSWORD")

if not email or not password:
raise ValueError(
"Please set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables"
)

logger.info("Authenticating with Navien API...")

# Authenticate normally
async with NavienAuthClient(email, password) as auth_client:
tokens = auth_client.current_tokens
if not tokens:
raise RuntimeError("Failed to obtain tokens")

logger.info("✓ Authentication successful")
logger.info(f"Token expires at: {tokens.expires_at}")

# Serialize tokens to dictionary
token_data = tokens.to_dict()

# Save to file
with open(TOKEN_FILE, "w") as f:
json.dump(token_data, f, indent=2)

logger.info(f"✓ Tokens saved to {TOKEN_FILE}")
logger.info("You can now use --restore to skip authentication on future runs")


async def restore_tokens_example():
"""Restore authentication from saved tokens."""
if not TOKEN_FILE.exists():
raise FileNotFoundError(
f"Token file not found: {TOKEN_FILE}\n"
"Please run with --save first to authenticate and save tokens"
)

email = os.getenv("NAVIEN_EMAIL")
password = os.getenv("NAVIEN_PASSWORD")

if not email or not password:
raise ValueError(
"Please set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables"
)

# Load saved tokens
with open(TOKEN_FILE) as f:
token_data = json.load(f)

logger.info(f"Loading tokens from {TOKEN_FILE}...")

# Import after getting token_data to avoid circular import issues
from nwp500.auth import AuthTokens

stored_tokens = AuthTokens.from_dict(token_data)

logger.info(f"Stored tokens issued at: {stored_tokens.issued_at}")
logger.info(f"Stored tokens expire at: {stored_tokens.expires_at}")

if stored_tokens.is_expired:
logger.warning("⚠ Stored tokens are expired, will refresh...")
elif stored_tokens.are_aws_credentials_expired:
logger.warning("⚠ AWS credentials expired, will re-authenticate...")
else:
logger.info("✓ Stored tokens are still valid")

# Use stored tokens to initialize client
async with NavienAuthClient(
email, password, stored_tokens=stored_tokens
) as auth_client:
tokens = auth_client.current_tokens
if not tokens:
raise RuntimeError("Failed to restore authentication")

logger.info("✓ Successfully authenticated using stored tokens")
logger.info(f"Current token expires at: {tokens.expires_at}")

# If tokens were refreshed, save them
if tokens.issued_at != stored_tokens.issued_at:
logger.info("Tokens were refreshed, updating stored copy...")
token_data = tokens.to_dict()
with open(TOKEN_FILE, "w") as f:
json.dump(token_data, f, indent=2)
logger.info(f"✓ Updated tokens saved to {TOKEN_FILE}")


async def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Token restoration example for nwp500-python"
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--save",
action="store_true",
help="Authenticate and save tokens for future use",
)
group.add_argument(
"--restore",
action="store_true",
help="Restore authentication from saved tokens",
)

args = parser.parse_args()

try:
if args.save:
await save_tokens_example()
else:
await restore_tokens_example()
except Exception as e:
logger.error(f"Error: {e}")
raise


if __name__ == "__main__":
asyncio.run(main())
Loading