Skip to content

Commit c59a360

Browse files
authored
feat: Support header login as a fallback in case server returns invalid-header (#44)
Thanks @bobokun for the bug report. Closes #43
1 parent 86aac7f commit c59a360

File tree

4 files changed

+58
-7
lines changed

4 files changed

+58
-7
lines changed

Diff for: actual/api/__init__.py

+25-6
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,34 @@ def __init__(
5050
# finally call validate
5151
self.validate()
5252

53-
def login(self, password: str) -> LoginDTO:
54-
"""Logs in on the Actual server using the password provided. Raises `AuthorizationError` if it fails to
55-
authenticate the user."""
53+
def login(self, password: str, method: Literal["password", "header"] = "password") -> LoginDTO:
54+
"""
55+
Logs in on the Actual server using the password provided. Raises `AuthorizationError` if it fails to
56+
authenticate the user.
57+
58+
:param password: password of the Actual server.
59+
:param method: the method used to authenticate with the server. Check
60+
https://actualbudget.org/docs/advanced/http-header-auth/ for information.
61+
"""
5662
if not password:
5763
raise AuthorizationError("Trying to login but not password was provided.")
58-
response = requests.post(f"{self.api_url}/{Endpoints.LOGIN}", json={"password": password})
64+
if method == "password":
65+
response = requests.post(f"{self.api_url}/{Endpoints.LOGIN}", json={"password": password})
66+
else:
67+
response = requests.post(
68+
f"{self.api_url}/{Endpoints.LOGIN}",
69+
json={"loginMethod": method},
70+
headers={"X-ACTUAL-PASSWORD": password},
71+
)
72+
response_dict = response.json()
5973
if response.status_code == 400 and "invalid-password" in response.text:
6074
raise AuthorizationError("Could not validate password on login.")
61-
response.raise_for_status()
75+
elif response.status_code == 200 and "invalid-header" in response.text:
76+
# try the same login with the header
77+
return self.login(password, "header")
78+
elif response_dict["status"] == "error":
79+
# for example, when not trusting the proxy
80+
raise AuthorizationError(f"Something went wrong on login: {response_dict['reason']}")
6281
login_response = LoginDTO.model_validate(response.json())
6382
# older versions do not return 400 but rather return empty tokens
6483
if login_response.data.token is None:
@@ -84,7 +103,7 @@ def info(self) -> InfoDTO:
84103
return InfoDTO.model_validate(response.json())
85104

86105
def validate(self) -> ValidateDTO:
87-
"""Validates"""
106+
"""Validates if the user is valid and logged in, and if the token is also valid and bound to a session."""
88107
response = requests.get(f"{self.api_url}/{Endpoints.ACCOUNT_VALIDATE}", headers=self.headers())
89108
response.raise_for_status()
90109
return ValidateDTO.model_validate(response.json())

Diff for: actual/api/models.py

+6
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,18 @@ class BankSyncs(enum.Enum):
4646

4747
class StatusCode(enum.Enum):
4848
OK = "ok"
49+
ERROR = "error"
4950

5051

5152
class StatusDTO(BaseModel):
5253
status: StatusCode
5354

5455

56+
class ErrorStatusDTO(BaseModel):
57+
status: StatusCode
58+
reason: Optional[str] = None
59+
60+
5561
class TokenDTO(BaseModel):
5662
token: Optional[str]
5763

Diff for: tests/test_api.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
from unittest.mock import patch
2+
13
import pytest
24

35
from actual import Actual
4-
from actual.exceptions import ActualError, UnknownFileId
6+
from actual.exceptions import ActualError, AuthorizationError, UnknownFileId
57
from actual.protobuf_models import Message
8+
from tests.conftest import RequestsMock
69

710

811
def test_api_apply(mocker):
@@ -25,3 +28,11 @@ def test_rename_delete_budget_without_file():
2528
actual.delete_budget()
2629
with pytest.raises(UnknownFileId, match="No current file loaded"):
2730
actual.rename_budget("foo")
31+
32+
33+
@patch("requests.post", return_value=RequestsMock({"status": "error", "reason": "proxy-not-trusted"}))
34+
def test_api_login_unknown_error(_post):
35+
actual = Actual.__new__(Actual)
36+
actual.api_url = "localhost"
37+
with pytest.raises(AuthorizationError, match="Something went wrong on login"):
38+
actual.login("foo")

Diff for: tests/test_integration.py

+15
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,18 @@ def test_models(actual_server):
142142
assert (
143143
column_name in __TABLE_COLUMNS_MAP__[table_name]["columns"]
144144
), f"Missing column '{column_name}' at table '{table_name}'."
145+
146+
147+
def test_header_login():
148+
with DockerContainer("actualbudget/actual-server:24.7.0").with_env(
149+
"ACTUAL_LOGIN_METHOD", "header"
150+
).with_exposed_ports(5006) as container:
151+
port = container.get_exposed_port(5006)
152+
wait_for_logs(container, "Listening on :::5006...")
153+
with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True):
154+
pass
155+
# make sure we can log in
156+
actual = Actual(f"http://localhost:{port}", password="mypass")
157+
response_login = actual.login("mypass")
158+
response_header_login = actual.login("mypass", "header")
159+
assert response_login.data.token == response_header_login.data.token

0 commit comments

Comments
 (0)