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
6 changes: 3 additions & 3 deletions pyoverkiz/auth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ def is_expired(self, *, skew_seconds: int = 5) -> bool:
if not self.expires_at:
return False

return datetime.datetime.now() >= self.expires_at - datetime.timedelta(
seconds=skew_seconds
)
return datetime.datetime.now(
datetime.UTC
) >= self.expires_at - datetime.timedelta(seconds=skew_seconds)


class AuthStrategy(Protocol):
Expand Down
28 changes: 22 additions & 6 deletions pyoverkiz/auth/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ async def _post_login(self, data: Mapping[str, Any]) -> None:
f"Login failed for {self.server.name}: {response.status}"
)

# A 204 No Content response cannot have a body, so skip JSON parsing.
if response.status == 204:
return

result = await response.json()
if not result.get("success"):
raise BadCredentialsException("Login failed: bad credentials")
Expand Down Expand Up @@ -200,9 +204,9 @@ async def _request_access_token(
self.context.refresh_token = token.get("refresh_token")
expires_in = token.get("expires_in")
if expires_in:
self.context.expires_at = datetime.datetime.now() + datetime.timedelta(
seconds=cast(int, expires_in) - 5
)
self.context.expires_at = datetime.datetime.now(
datetime.UTC
) + datetime.timedelta(seconds=cast(int, expires_in) - 5)


class CozytouchAuthStrategy(SessionLoginStrategy):
Expand Down Expand Up @@ -394,6 +398,18 @@ async def _exchange_token(self, payload: Mapping[str, str]) -> None:
) as response:
token = await response.json()

# Handle OAuth error responses explicitly before accessing the access token.
error = token.get("error")
if error:
description = token.get("error_description") or token.get("message")
if description:
raise InvalidTokenException(
f"Error retrieving Rexel access token: {description}"
)
raise InvalidTokenException(
f"Error retrieving Rexel access token: {error}"
)

access_token = token.get("access_token")
if not access_token:
raise InvalidTokenException("No Rexel access token provided.")
Expand All @@ -403,9 +419,9 @@ async def _exchange_token(self, payload: Mapping[str, str]) -> None:
self.context.refresh_token = token.get("refresh_token")
expires_in = token.get("expires_in")
if expires_in:
self.context.expires_at = datetime.datetime.now() + datetime.timedelta(
seconds=cast(int, expires_in) - 5
)
self.context.expires_at = datetime.datetime.now(
datetime.UTC
) + datetime.timedelta(seconds=cast(int, expires_in) - 5)

@staticmethod
def _ensure_consent(access_token: str) -> None:
Expand Down
7 changes: 6 additions & 1 deletion pyoverkiz/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,12 @@ def __init__(
if self.server_config.type == APIType.LOCAL and verify_ssl:
# To avoid security issues while authentication to local API, we add the following authority to
# our HTTPS client trust store: https://ca.overkiz.com/overkiz-root-ca-2048.crt
self._ssl = SSL_CONTEXT_LOCAL_API
# Create a copy of the SSL context to avoid mutating the shared global context
self._ssl = ssl.SSLContext(SSL_CONTEXT_LOCAL_API.protocol)
self._ssl.load_verify_locations(
cafile=os.path.dirname(os.path.realpath(__file__))
+ "/overkiz-root-ca-2048.crt"
)
Comment on lines +169 to +174
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating an SSLContext with only the protocol doesn't preserve the security defaults from create_default_context(). The original SSL_CONTEXT_LOCAL_API used ssl.create_default_context() which sets important defaults like check_hostname, verify_mode, and other security settings.

Instead of ssl.SSLContext(SSL_CONTEXT_LOCAL_API.protocol), use ssl.create_default_context(cafile=...) to create a properly configured context for each client instance.

Suggested change
# Create a copy of the SSL context to avoid mutating the shared global context
self._ssl = ssl.SSLContext(SSL_CONTEXT_LOCAL_API.protocol)
self._ssl.load_verify_locations(
cafile=os.path.dirname(os.path.realpath(__file__))
+ "/overkiz-root-ca-2048.crt"
)
# Create a dedicated SSL context with secure defaults for this client instance
ca_file = (
os.path.dirname(os.path.realpath(__file__))
+ "/overkiz-root-ca-2048.crt"
)
self._ssl = ssl.create_default_context(cafile=ca_file)

Copilot uses AI. Check for mistakes.

# Disable strict validation introduced in Python 3.13, which doesn't
# work with Overkiz self-signed gateway certificates
Expand Down
9 changes: 5 additions & 4 deletions pyoverkiz/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,16 @@ def create_server_config(
configuration_url: str | None = None,
) -> ServerConfig:
"""Generate server configuration with the provided endpoint and metadata."""
# TODO fix: ServerConfig.__init__ handles the enum conversion, but mypy doesn't recognize
# this due to attrs @define decorator generating __init__ with stricter signatures,
# so we need type: ignore comments.
return ServerConfig(
server=server
if isinstance(server, Server) or server is None
else Server(server),
server=server, # type: ignore[arg-type]
name=name,
endpoint=endpoint,
manufacturer=manufacturer,
configuration_url=configuration_url,
type=type if isinstance(type, APIType) else APIType(type),
type=type, # type: ignore[arg-type]
)


Expand Down
Loading
Loading