Skip to content

Commit 4cc0635

Browse files
authored
feat(auth): Rexel authentication (OAuth2 PKCE + externally-managed tokens) (#2090)
## Summary Adds support for Rexel authentication in two modes, plus the gateway discovery/selection plumbing Rexel requires: - **OAuth2 PKCE flow** — full interactive login for Rexel accounts (`pyoverkiz/pkce.py`, `RexelAuthStrategy`). - **Externally-managed tokens** — `RexelTokenCredentials` + `RexelTokenAuthStrategy` let a host (e.g. Home Assistant) supply and refresh tokens it manages itself. - **Gateway discovery & selection** — Rexel exposes multiple gateways via the enduser directory; the client now discovers candidates (`GatewayCandidate`, `SupportsGatewaySelection`), auto-selects a sole gateway at login, and injects the `gatewayId` header per request, guarding requests made before selection (`NoGatewaySelectedError`). ### Breaking change - `auth_headers` is now **async** across all auth strategies. See `docs/migration-v2.md`. ### Docs - Getting-started tab for the externally-managed token mode. - Core-concepts note on the two Rexel auth modes. - SDK reference renders the auth credentials module. - Migration guide records the async `auth_headers` change. ## Test Plan - [x] `pytest` — 488 passed (run in devcontainer) - [ ] Verify interactive OAuth2 PKCE login against a real Rexel account - [ ] Verify externally-managed token flow end-to-end from Home Assistant
1 parent 6c4bcba commit 4cc0635

16 files changed

Lines changed: 1091 additions & 54 deletions

docs/core-concepts.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,31 @@ States are name/value pairs that represent the current device status, such as cl
7979

8080
The API uses an event listener that you register once per session. Fetching events drains the server-side buffer. Events include execution state changes, device state updates, and other notifications.
8181

82+
## Authentication strategies
83+
84+
The library supports multiple authentication methods depending on the server:
85+
86+
- **Username/Password**: Most cloud servers (Somfy, Cozytouch, Hitachi, Nexity)
87+
- **Bearer Token**: Cloud servers with pre-issued tokens
88+
- **Local Token**: Somfy Developer Mode (local gateways)
89+
- **OAuth2 with PKCE**: Rexel (Azure AD B2C)
90+
91+
Each server automatically selects the appropriate authentication strategy based on the credentials provided.
92+
93+
Rexel supports two authentication modes:
94+
95+
- **Library-driven code exchange** (`RexelOAuthCodeCredentials`): pyoverkiz
96+
performs the PKCE code exchange and refreshes its own tokens. Best for
97+
standalone use.
98+
- **Externally-managed token** (`RexelTokenCredentials`): the OAuth2 lifecycle
99+
is owned outside the library (e.g. Home Assistant's `application_credentials`
100+
platform). Supply an async `access_token_callback` or a static `access_token`.
101+
Best when a host application already manages OAuth.
102+
103+
Both Rexel modes support multiple gateways: after `login()`, call
104+
`discover_gateways()` and `select_gateway()` to scope requests (a sole gateway
105+
is auto-selected).
106+
82107
## Relationship diagram
83108

84109
```

docs/getting-started.md

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,31 +150,115 @@ Use a cloud server when you want to connect through the vendor’s public API. U
150150
asyncio.run(main())
151151
```
152152

153-
<!-- TODO: Rexel OAuth2 flow not fully working yet
154153
=== "Rexel (cloud)"
155154

156-
Authentication to the Rexel cloud uses OAuth2 authorization code flow.
157-
You need an authorization code and redirect URI obtained from the Rexel OAuth2 consent flow.
155+
Authentication to the Rexel cloud uses OAuth2 with PKCE (Proof Key for Code Exchange).
158156

159-
Use `Server.REXEL` with `RexelOAuthCodeCredentials` to authenticate.
157+
**Step 1: Generate PKCE parameters and authorization URL**
160158

161159
```python
162-
import asyncio
160+
import secrets
161+
from pyoverkiz.pkce import generate_pkce_pair
162+
from pyoverkiz.utils import build_rexel_authorization_url
163+
164+
# Generate PKCE code verifier and challenge
165+
code_verifier, code_challenge = generate_pkce_pair()
166+
167+
# Generate authorization URL (user must visit this in browser)
168+
state = secrets.token_urlsafe(16) # For CSRF protection
169+
auth_url = build_rexel_authorization_url(code_challenge, state)
170+
171+
print(f"Visit this URL to authorize: {auth_url}")
172+
```
173+
174+
**Step 2: Redirect user to authorization URL**
163175

176+
Direct the user to the `auth_url`. After successful login, they will be redirected to:
177+
```
178+
https://my.home-assistant.io/redirect/oauth?code=AUTHORIZATION_CODE&state=STATE_VALUE
179+
```
180+
181+
**Step 3: Exchange authorization code for access token**
182+
183+
```python
184+
import asyncio
164185
from pyoverkiz.auth.credentials import RexelOAuthCodeCredentials
165186
from pyoverkiz.client import OverkizClient
166187
from pyoverkiz.enums import Server
188+
from pyoverkiz.const import REXEL_OAUTH_REDIRECT_URI
167189

168190
async def main() -> None:
191+
# Use the authorization code from the redirect
169192
async with OverkizClient(
170193
server=Server.REXEL,
171194
credentials=RexelOAuthCodeCredentials(
172-
code="your-authorization-code",
173-
redirect_uri="https://your-redirect-uri",
195+
code="AUTHORIZATION_CODE_FROM_REDIRECT",
196+
redirect_uri=REXEL_OAUTH_REDIRECT_URI,
197+
code_verifier=code_verifier, # From step 1
174198
),
175199
) as client:
176200
await client.login()
201+
# Client is now authenticated and ready to use
177202

178203
asyncio.run(main())
179204
```
180-
-->
205+
206+
=== "Rexel (externally-managed token)"
207+
208+
Use this when an external system already owns the OAuth2 lifecycle — for
209+
example the Home Assistant `application_credentials` platform, which
210+
authorizes, exchanges, refreshes, and persists tokens for you. pyoverkiz
211+
then only needs the *current* access token and the Rexel gateway selection.
212+
213+
Supply a token in one of two ways:
214+
215+
**Async callback (recommended for long-running apps).** pyoverkiz calls it
216+
before each request, so the owner can refresh and persist transparently.
217+
218+
```python
219+
import asyncio
220+
from pyoverkiz.auth.credentials import RexelTokenCredentials
221+
from pyoverkiz.client import OverkizClient
222+
from pyoverkiz.enums import Server
223+
224+
225+
async def get_access_token() -> str:
226+
# Return a currently-valid access token (refresh upstream as needed).
227+
...
228+
229+
230+
async def main() -> None:
231+
async with OverkizClient(
232+
server=Server.REXEL,
233+
credentials=RexelTokenCredentials(
234+
access_token_callback=get_access_token,
235+
),
236+
) as client:
237+
await client.login() # discovers + auto-selects a sole gateway
238+
239+
gateways = await client.discover_gateways()
240+
if len(gateways) > 1:
241+
client.select_gateway(gateways[0].gateway_id)
242+
243+
setup = await client.get_setup()
244+
print(f"{len(setup.devices)} device(s)")
245+
246+
asyncio.run(main())
247+
```
248+
249+
**Static token (simplest, for quick standalone or test use).** No refresh —
250+
when the token expires you construct a new client.
251+
252+
```python
253+
credentials = RexelTokenCredentials(access_token="YOUR_ACCESS_TOKEN")
254+
```
255+
256+
**Reload without re-discovering.** Persist the chosen `gateway_id` and pass
257+
it back on the next run; `login()` applies it directly and skips discovery:
258+
259+
```python
260+
credentials = RexelTokenCredentials(
261+
access_token_callback=get_access_token,
262+
gateway_id="STORED_GATEWAY_ID",
263+
)
264+
```

docs/migration-v2.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,26 @@ These are not breaking, but worth knowing about when migrating:
411411
- **Reference endpoints** — query server metadata: `get_reference_ui_classes()`, `get_reference_ui_widgets()`, `get_reference_ui_profile()`, `get_reference_controllable_types()`, etc.
412412
- **Firmware management**`get_devices_not_up_to_date()`, `get_device_firmware_status()`, `update_device_firmware()`.
413413
- **Optional Nexity dependencies**`boto3` and `warrant-lite` are no longer installed by default. Install them with `pip install "pyoverkiz[nexity]"` if you use the Nexity server. A clear `ImportError` is raised at login time if the extra is missing.
414+
415+
## `auth_headers` is now async
416+
417+
`AuthStrategy.auth_headers()` and every concrete strategy's implementation are
418+
now coroutines. This only affects code that calls `auth_headers()` directly or
419+
implements a custom `AuthStrategy` — normal `OverkizClient` use is unaffected.
420+
421+
=== "Before"
422+
423+
```python
424+
headers = strategy.auth_headers(path)
425+
```
426+
427+
=== "After"
428+
429+
```python
430+
headers = await strategy.auth_headers(path)
431+
```
432+
433+
Custom strategies must change the method signature to `async def auth_headers`.
434+
This change lets token-supplied strategies (such as Rexel's
435+
`RexelTokenAuthStrategy`) await an externally-supplied access-token callback per
436+
request instead of caching token state.

docs/sdk-reference.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,14 @@
1111
::: pyoverkiz.exceptions
1212
options:
1313
show_source: false
14+
15+
::: pyoverkiz.auth.credentials
16+
options:
17+
show_source: false
18+
19+
::: pyoverkiz.auth.base
20+
options:
21+
show_source: false
22+
members:
23+
- GatewayCandidate
24+
- SupportsGatewaySelection

pyoverkiz/auth/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22

33
from __future__ import annotations
44

5-
from pyoverkiz.auth.base import AuthContext, AuthStrategy
5+
from pyoverkiz.auth.base import (
6+
AuthContext,
7+
AuthStrategy,
8+
GatewayCandidate,
9+
SupportsGatewaySelection,
10+
)
611
from pyoverkiz.auth.credentials import (
712
Credentials,
813
LocalTokenCredentials,
914
RexelOAuthCodeCredentials,
15+
RexelTokenCredentials,
1016
TokenCredentials,
1117
UsernamePasswordCredentials,
1218
)
@@ -16,8 +22,11 @@
1622
"AuthContext",
1723
"AuthStrategy",
1824
"Credentials",
25+
"GatewayCandidate",
1926
"LocalTokenCredentials",
2027
"RexelOAuthCodeCredentials",
28+
"RexelTokenCredentials",
29+
"SupportsGatewaySelection",
2130
"TokenCredentials",
2231
"UsernamePasswordCredentials",
2332
"build_auth_strategy",

pyoverkiz/auth/base.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import datetime
66
from collections.abc import Mapping
77
from dataclasses import dataclass, field
8-
from typing import Any, Protocol
8+
from typing import Any, Protocol, runtime_checkable
99

1010

1111
@dataclass(slots=True)
@@ -47,8 +47,37 @@ async def login(self) -> None:
4747
async def refresh_if_needed(self) -> bool:
4848
"""Refresh tokens if they are expired. Return True if refreshed."""
4949

50-
def auth_headers(self, path: str | None = None) -> Mapping[str, str]:
50+
async def auth_headers(self, path: str | None = None) -> Mapping[str, str]:
5151
"""Generate authentication headers for requests."""
5252

53+
@property
54+
def endpoint(self) -> str:
55+
"""Return the base API endpoint for requests."""
56+
5357
async def close(self) -> None:
5458
"""Clean up any resources held by the strategy."""
59+
60+
61+
@dataclass(slots=True)
62+
class GatewayCandidate:
63+
"""A selectable Overkiz gateway behind a multi-account directory."""
64+
65+
gateway_id: str
66+
home_id: str | None = None
67+
label: str | None = None
68+
external_id: str | None = None
69+
70+
71+
@runtime_checkable
72+
class SupportsGatewaySelection(Protocol):
73+
"""Optional capability: discover and select among multiple gateways."""
74+
75+
async def discover_gateways(self) -> list[GatewayCandidate]:
76+
"""Return all selectable gateways for the authenticated account."""
77+
78+
def select_gateway(self, gateway_id: str) -> None:
79+
"""Select the gateway to scope subsequent requests to."""
80+
81+
@property
82+
def selected_gateway(self) -> str | None:
83+
"""Return the currently selected gateway id, or None."""

pyoverkiz/auth/credentials.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from abc import ABC
6+
from collections.abc import Awaitable, Callable
67
from dataclasses import dataclass, field
78

89

@@ -32,7 +33,30 @@ class LocalTokenCredentials(TokenCredentials):
3233

3334
@dataclass(slots=True)
3435
class RexelOAuthCodeCredentials(Credentials):
35-
"""Credentials using Rexel OAuth2 authorization code."""
36+
"""Credentials using Rexel OAuth2 authorization code with PKCE."""
3637

3738
code: str = field(repr=False)
3839
redirect_uri: str
40+
code_verifier: str
41+
42+
43+
@dataclass(slots=True)
44+
class RexelTokenCredentials(Credentials):
45+
"""Rexel credentials backed by an externally-managed access token.
46+
47+
Use this when the OAuth2 lifecycle is owned outside pyoverkiz (for example
48+
Home Assistant's ``application_credentials`` platform). Supply either an
49+
async ``access_token_callback`` (called before each request, so the owner
50+
can refresh and persist tokens) or a static ``access_token`` for simple
51+
standalone or test use. ``gateway_id`` pre-selects a gateway on reload,
52+
skipping discovery.
53+
"""
54+
55+
access_token_callback: Callable[[], Awaitable[str]] | None = None
56+
access_token: str | None = field(default=None, repr=False)
57+
gateway_id: str | None = None
58+
59+
def __post_init__(self) -> None:
60+
"""Require at least one access-token source."""
61+
if not self.access_token_callback and not self.access_token:
62+
raise ValueError("Provide either access_token_callback or access_token.")

pyoverkiz/auth/factory.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Credentials,
1111
LocalTokenCredentials,
1212
RexelOAuthCodeCredentials,
13+
RexelTokenCredentials,
1314
TokenCredentials,
1415
UsernamePasswordCredentials,
1516
)
@@ -20,6 +21,7 @@
2021
LocalTokenAuthStrategy,
2122
NexityAuthStrategy,
2223
RexelAuthStrategy,
24+
RexelTokenAuthStrategy,
2325
SessionLoginStrategy,
2426
SomfyAuthStrategy,
2527
)
@@ -66,6 +68,10 @@ def build_auth_strategy(
6668
)
6769

6870
if server == Server.REXEL:
71+
if isinstance(credentials, RexelTokenCredentials):
72+
return RexelTokenAuthStrategy(
73+
credentials, session, server_config, ssl_context
74+
)
6975
return RexelAuthStrategy(
7076
_ensure_credentials(credentials, RexelOAuthCodeCredentials),
7177
session,

0 commit comments

Comments
 (0)