fix(webhooks): authenticate deliveries via WEBHOOK_SECRET; review nits

Adds optional shared-secret authentication for /webhooks/nextcloud,
addressing the security follow-up flagged in #747.

Behavior:
- WEBHOOK_SECRET set: registrations pass authMethod="header" with
  authData={"Authorization": "Bearer <secret>"} (encrypted at-rest in
  Nextcloud's DB and forwarded on every delivery). The receiver
  validates the same header with hmac.compare_digest before parsing
  any payload; missing/invalid → 401.
- WEBHOOK_SECRET unset: registrations stay on authMethod="none" and
  the receiver accepts unauthenticated POSTs (logging a one-time
  startup warning). Backward compatible — operators can roll out at
  their own pace.

Implementation notes:
- WebhooksClient.create_webhook gains an `auth_data` parameter mapped
  to NC's `authData` body field; this is distinct from the existing
  `headers` parameter (`headers` is plaintext static request headers,
  `authData` is encrypted at-rest in NC and only emitted when
  authMethod="header"). The previous `auth_method="bearer"` mention in
  the docstring was incorrect — NC supports only "none" and "header".
- A small `webhook_auth_pair()` helper in auth/webhook_routes.py
  centralises the secret→(auth_method, auth_data) resolution so the
  preset flow and the Astrolabe-facing /api/v1/webhooks endpoint stay
  in sync.

Also addresses the smaller review points from #747:
- f-string → lazy %s formatting in webhook_receiver.py and
  webhook_routes.py.
- Move `int(time)` inside webhook_parser's try/except so a malformed
  `time` field returns None instead of raising ValueError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-30 03:50:28 +02:00
co-authored by Claude Opus 4.7
parent 2e2a098bee
commit 224428fca5
10 changed files with 306 additions and 12 deletions
+34
View File
@@ -169,6 +169,40 @@ async def test_create_webhook_with_auth_headers(webhooks_client, mocker):
assert call_args[1]["json"]["headers"] == {"Authorization": "Bearer secret-token"}
@pytest.mark.unit
async def test_create_webhook_with_auth_data(webhooks_client, mocker):
"""``auth_data`` lands in the OCS body as ``authData`` so NC encrypts
the credentials at-rest and merges them in at delivery time."""
mock_response = mocker.Mock()
mock_response.json.return_value = {
"ocs": {
"data": {
"id": 126,
"uri": "http://example.com/webhook",
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"authMethod": "header",
}
}
}
mock_make_request = mocker.patch.object(
WebhooksClient, "_make_request", return_value=mock_response
)
await webhooks_client.create_webhook(
event="OCP\\Files\\Events\\Node\\NodeCreatedEvent",
uri="http://example.com/webhook",
auth_method="header",
auth_data={"Authorization": "Bearer supersecret"},
)
call_args = mock_make_request.call_args
assert call_args[1]["json"]["authMethod"] == "header"
assert call_args[1]["json"]["authData"] == {"Authorization": "Bearer supersecret"}
# The static `headers` field must NOT be set when only auth_data is passed.
assert "headers" not in call_args[1]["json"]
@pytest.mark.unit
async def test_delete_webhook(webhooks_client, mocker):
"""Test deleting a webhook registration."""