Skip to content
Open
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
24 changes: 17 additions & 7 deletions jaraco/abode/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
import logging
import uuid

from jaraco.collections import Everything
from jaraco.functools import retry
from jaraco.itertools import always_iterable
from jaraco.net.http import cookies
from more_itertools import consume
from requests.exceptions import RequestException
from requests_toolbelt import sessions

import jaraco
from jaraco.collections import Everything
from jaraco.functools import retry
from jaraco.itertools import always_iterable
from jaraco.net.http import cookies

from . import config, settings
from .automation import Automation
Expand Down Expand Up @@ -314,11 +314,21 @@ def _send_request(self, method, path, headers, data):

try:
response = getattr(self._session, method)(path, headers=headers, json=data)

if response and response.status_code < 400:
return response
except RequestException:
log.info("Abode connection reset...")
raise jaraco.abode.Exception(ERROR.REQUEST)

# Surface authentication failures as a dedicated exception so callers can
# trigger reauthentication. This covers auth status codes (400/401/403)
# as well as auth-error payloads that Abode occasionally returns with an
# HTTP 200. AuthenticationException is a subclass of the general
# ``jaraco.abode.Exception`` and is still trapped by ``send_request``'s
# single retry (re-login), so transient token expiry keeps recovering.
if AuthenticationException.detect(response):
raise AuthenticationException.from_response(response)

if response.status_code < 400:
return response

raise jaraco.abode.Exception(ERROR.REQUEST)

Expand Down
68 changes: 66 additions & 2 deletions jaraco/abode/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
import builtins
from http import HTTPStatus

import requests

#: HTTP status codes Abode uses to signal an authentication/authorization failure.
AUTH_STATUS_CODES = frozenset({
HTTPStatus.BAD_REQUEST,
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
})

#: Abode application-level error codes that are returned (sometimes with an
#: HTTP 200) when the stored authentication is expired or otherwise invalid.
AUTH_ERROR_CODES = frozenset({11002, 13027})

_AUTH_MESSAGE_MARKERS = ('unauthorized', 'invalid credentials')


def _looks_like_auth_message(message):
"""Return True if a free-form message string indicates an auth failure."""
message = str(message or '').lower()
return any(marker in message for marker in _AUTH_MESSAGE_MARKERS) or (
'password' in message and 'match' in message
)


class Exception(builtins.Exception):
"""Class to throw general abode exception."""
Expand Down Expand Up @@ -30,10 +52,52 @@ def raise_for(cls, response):
except requests.exceptions.HTTPError as exc:
raise cls((response.status_code, cls.best_message(response))) from exc

@classmethod
def detect(cls, response):
"""Return True if ``response`` indicates an authentication failure.

Abode signals authentication problems both through HTTP status codes
(400/401/403) and, in some cases, through an error payload returned
alongside an HTTP 200. Detecting the latter lets callers trigger
reauthentication instead of treating the bogus payload as a success.
"""
if response.status_code in AUTH_STATUS_CODES:
return True
if response.status_code != HTTPStatus.OK:
return False
return cls._auth_error_in_payload(response)

@classmethod
def from_response(cls, response):
"""Build an :class:`AuthenticationException` from ``response``."""
return cls((response.status_code, cls.best_message(response)))

@classmethod
def _auth_error_in_payload(cls, response):
# Check the content type first rather than blindly parsing the body.
content_type = response.headers.get('Content-Type', '')
if 'application/json' not in content_type.lower():
return False
try:
payload = response.json()
except ValueError:
return False
if not isinstance(payload, dict):
return False
return payload.get('errorCode') in AUTH_ERROR_CODES or _looks_like_auth_message(
payload.get('message')
)

@staticmethod
def best_message(response):
if response.headers.get('Content-Type') == 'application/json':
return response.json()['message']
content_type = response.headers.get('Content-Type', '')
if 'application/json' in content_type.lower():
try:
payload = response.json()
except ValueError:
return response.text
if isinstance(payload, dict) and 'message' in payload:
return payload['message']
return response.text


Expand Down
1 change: 1 addition & 0 deletions newsfragments/+runtime-auth.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``Client.send_request`` now raises ``AuthenticationException`` when a request fails because authentication is no longer valid -- including auth-error payloads that Abode sometimes returns with an HTTP 200 status. This lets downstream consumers such as Home Assistant reliably trigger reauthentication instead of treating the failure as a generic error. New ``AuthenticationException.detect`` and ``AuthenticationException.from_response`` helpers expose this behavior.
131 changes: 131 additions & 0 deletions tests/test_auth_handling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
Tests for runtime authentication-failure handling in ``Client.send_request``.

These cover the dedicated ``AuthenticationException`` that is raised when a
request fails because authentication is no longer valid, including the case
where Abode returns an auth-error payload alongside an HTTP 200 status.
"""

from http import HTTPStatus

import pytest

import jaraco.abode
from jaraco.abode.exceptions import AuthenticationException
from jaraco.abode.helpers import urls

from . import mock as MOCK
from .mock import devices as DEVICES
from .mock import login as LOGIN
from .mock import oauth_claims as OAUTH_CLAIMS
from .mock import panel as PANEL


class _FakeResponse:
"""Minimal stand-in for ``requests.Response`` for unit-testing helpers."""

def __init__(self, status_code, *, content_type='application/json', payload=None):
self.status_code = status_code
self.headers = {'Content-Type': content_type}
self._payload = payload
self.text = '' if payload is None else str(payload)

def json(self):
if self._payload is None:
raise ValueError("No JSON payload")
return self._payload


@pytest.mark.parametrize(
"status_code",
[HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN],
)
def test_detect_auth_status_codes(status_code):
"""Auth status codes are detected regardless of body."""
assert AuthenticationException.detect(_FakeResponse(status_code)) is True


def test_detect_non_auth_status_code():
"""Non-auth errors (e.g. 500) are not treated as auth failures."""
response = _FakeResponse(HTTPStatus.INTERNAL_SERVER_ERROR)
assert AuthenticationException.detect(response) is False


@pytest.mark.parametrize(
"payload",
[
{"errorCode": 11002},
{"errorCode": 13027},
{"message": "Unauthorized token"},
{"message": "Invalid credentials"},
{"message": "Username and password do not match"},
],
)
def test_detect_auth_like_200_payload(payload):
"""Auth-error payloads returned with HTTP 200 are detected."""
response = _FakeResponse(HTTPStatus.OK, payload=payload)
assert AuthenticationException.detect(response) is True


@pytest.mark.parametrize(
"response",
[
_FakeResponse(HTTPStatus.OK, payload={"message": "ok"}),
_FakeResponse(HTTPStatus.OK, payload=["not", "a", "dict"]),
_FakeResponse(HTTPStatus.OK, content_type="text/plain"),
_FakeResponse(HTTPStatus.OK, payload=None),
],
)
def test_detect_ok_responses_are_not_auth(response):
"""Ordinary 200 responses (and unparseable bodies) are not auth failures."""
assert AuthenticationException.detect(response) is False


def test_from_response_carries_status_and_message():
"""``from_response`` preserves the status code and best message."""
response = _FakeResponse(
HTTPStatus.UNAUTHORIZED, payload={"message": "Unauthorized token"}
)
exc = AuthenticationException.from_response(response)
assert isinstance(exc, AuthenticationException)
assert exc.errcode == HTTPStatus.UNAUTHORIZED
assert exc.message == "Unauthorized token"


class TestRuntimeAuthHandling:
"""Integration tests around ``send_request`` raising the dedicated error."""

def test_runtime_forbidden_raises_authentication(self, m):
"""A persistent 403 at runtime raises AuthenticationException."""
m.post(urls.LOGIN, json=LOGIN.post_response_ok())
m.get(urls.OAUTH_TOKEN, json=OAUTH_CLAIMS.get_response_ok())
m.get(urls.DEVICES, json=MOCK.response_forbidden(), status=403)

with pytest.raises(jaraco.abode.AuthenticationException):
self.client.get_devices()

def test_auth_error_payload_with_http_200(self, m):
"""An auth-error payload returned with HTTP 200 raises AuthenticationException."""
m.post(urls.LOGIN, json=LOGIN.post_response_ok())
m.get(urls.OAUTH_TOKEN, json=OAUTH_CLAIMS.get_response_ok())
m.get(
urls.DEVICES,
json={"errorCode": 11002, "message": "Unauthorized token"},
status=200,
)

with pytest.raises(jaraco.abode.AuthenticationException):
self.client.get_devices()

def test_transient_token_expiry_still_recovers(self, m):
"""A single 403 followed by success still recovers via re-login."""
new_token = "REFRESHED"
m.post(urls.LOGIN, json=LOGIN.post_response_ok(auth_token=new_token))
m.get(urls.OAUTH_TOKEN, json=OAUTH_CLAIMS.get_response_ok())
m.get(urls.DEVICES, json=MOCK.response_forbidden(), status=403)
m.get(urls.DEVICES, json=DEVICES.EMPTY_DEVICE_RESPONSE)
m.get(urls.PANEL, json=PANEL.get_response_ok())

self.client.get_devices()

assert self.client._token == new_token