fix(api): validate app password against Nextcloud using loginName, not UID
provision_app_password validated the supplied app password by calling the OCS cloud/user endpoint with BasicAuth as the *path user_id* (the UID). Nextcloud keys app-password BasicAuth on the loginName, which differs from the UID for OIDC-provisioned accounts whose UID is their display name (UID "Chris Coutinho", loginName "chris@coutinho.io"). Authenticating as the UID is rejected with HTTP 401 ("App password validation failed"), so provisioning never completes. Parse the request body up front and authenticate the OCS validation as the body's `username` (the Nextcloud loginName), falling back to the path user_id for legacy callers where UID == loginName. The OCS-returned account id is still checked against the path user_id (the UID), and the password is still stored keyed by UID with the loginName alongside. Note this is not an encoding issue: BasicAuth places the user-id literally in the header (RFC 7617, no URL-encoding); %20/+/literal-space forms of the UID all fail — only the loginName authenticates. Adds a regression test asserting the OCS BasicAuth uses the loginName while storage is keyed by the UID, plus a backward-compat assertion that callers without a loginName fall back to the UID. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
76a8313717
commit
0df3fb4a19
@@ -248,6 +248,64 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
||||
stored_password = await temp_storage.get_app_password("testuser")
|
||||
assert stored_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
|
||||
# Legacy callers send no loginName in the body → the OCS validation falls
|
||||
# back to authenticating as the UID (here UID == loginName).
|
||||
_, get_kwargs = mock_client.get.call_args
|
||||
assert get_kwargs["auth"] == ("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
|
||||
async def test_provision_app_password_uses_loginname_not_uid(temp_storage, mocker):
|
||||
"""Regression: when the Nextcloud UID differs from the loginName (e.g.
|
||||
OIDC-provisioned users whose UID is their display name — UID
|
||||
"Chris Coutinho", loginName "chris@coutinho.io"), the OCS BasicAuth
|
||||
validation must authenticate as the loginName from the request body, not
|
||||
the UID. Authenticating as the UID is rejected by Nextcloud with HTTP 401.
|
||||
"""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.get_settings",
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# OCS validation succeeds and reports the UID as the account id.
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"ocs": {"data": {"id": "Chris Coutinho"}}}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.nextcloud_httpx_client",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
client = TestClient(app)
|
||||
|
||||
pw = "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
# A literal space in the path is encoded by the client and decoded back to
|
||||
# the UID; the BasicAuth username matches that UID.
|
||||
response = client.post(
|
||||
"/api/v1/users/Chris Coutinho/app-password",
|
||||
headers={"Authorization": create_basic_auth_header("Chris Coutinho", pw)},
|
||||
json={"username": "chris@coutinho.io"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
# The OCS BasicAuth used the loginName from the body, not the UID.
|
||||
_, get_kwargs = mock_client.get.call_args
|
||||
assert get_kwargs["auth"] == ("chris@coutinho.io", pw)
|
||||
|
||||
# Stored under the UID (the identity key).
|
||||
assert await temp_storage.get_app_password("Chris Coutinho") == pw
|
||||
|
||||
|
||||
async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||
"""Test that failed Nextcloud validation returns 401."""
|
||||
|
||||
Reference in New Issue
Block a user