(scheduling) add callback from caldav to django for imip

This commit is contained in:
Sylvain Zimmer
2026-01-11 03:52:43 +01:00
parent bc801d3007
commit 3ed52ca5d0
15 changed files with 636 additions and 41 deletions

View File

@@ -72,6 +72,16 @@ class Base(Configuration):
"http://caldav:80", environ_name="CALDAV_URL", environ_prefix=None
)
# CalDAV API keys for bidirectional authentication
# INBOUND: API key for authenticating requests FROM CalDAV server TO Django
CALDAV_INBOUND_API_KEY = values.Value(
None, environ_name="CALDAV_INBOUND_API_KEY", environ_prefix=None
)
# OUTBOUND: API key for authenticating requests FROM Django TO CalDAV server
CALDAV_OUTBOUND_API_KEY = values.Value(
None, environ_name="CALDAV_OUTBOUND_API_KEY", environ_prefix=None
)
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = SecretFileValue(None)

View File

@@ -1,6 +1,7 @@
"""CalDAV proxy views for forwarding requests to CalDAV server."""
import logging
import secrets
from django.conf import settings
from django.http import HttpResponse
@@ -61,7 +62,7 @@ class CalDAVProxyView(View):
target_url = f"{caldav_url}{base_uri_path}/"
# Prepare headers for CalDAV server
# CalDAV server Apache backend reads REMOTE_USER, which we set via X-Forwarded-User
# CalDAV server uses custom auth backend that requires X-Forwarded-User header and API key
headers = {
"Content-Type": request.content_type or "application/xml",
"X-Forwarded-User": user_principal,
@@ -70,11 +71,18 @@ class CalDAVProxyView(View):
"X-Forwarded-Proto": request.scheme,
}
# CalDAV server authentication: Apache backend reads REMOTE_USER
# We send the username via X-Forwarded-User header
# For HTTP Basic Auth, we use the email as username with empty password
# CalDAV server converts X-Forwarded-User to REMOTE_USER
auth = (user_principal, "")
# API key is required for authentication
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key:
logger.error("CALDAV_OUTBOUND_API_KEY is not configured")
return HttpResponse(
status=500, content="CalDAV authentication not configured"
)
headers["X-Api-Key"] = outbound_api_key
# No Basic Auth - our custom backend uses X-Forwarded-User header and API key
auth = None
# Copy relevant headers from the original request
if "HTTP_DEPTH" in request.META:
@@ -91,8 +99,7 @@ class CalDAVProxyView(View):
try:
# Forward the request to CalDAV server
# Use HTTP Basic Auth with username (email) and empty password
# CalDAV server will authenticate based on X-Forwarded-User header (converted to REMOTE_USER)
# CalDAV server authenticates via X-Forwarded-User header and API key
logger.debug(
"Forwarding %s request to CalDAV server: %s (user: %s)",
request.method,
@@ -169,7 +176,56 @@ class CalDAVDiscoveryView(View):
# Clients need to discover the CalDAV URL before authenticating
# Return redirect to CalDAV server base URL
caldav_base_url = f"/api/v1.0/caldav/"
caldav_base_url = f"/api/{settings.API_VERSION}/caldav/"
response = HttpResponse(status=301)
response["Location"] = caldav_base_url
return response
@method_decorator(csrf_exempt, name="dispatch")
class CalDAVSchedulingCallbackView(View):
"""
Endpoint for receiving CalDAV scheduling messages (iMip) from sabre/dav.
This endpoint receives scheduling messages (invites, responses, cancellations)
from the CalDAV server and processes them. Authentication is via API key.
See: https://sabre.io/dav/scheduling/
"""
def dispatch(self, request, *args, **kwargs):
"""Handle scheduling messages from CalDAV server."""
# Authenticate via API key
api_key = request.headers.get("X-Api-Key", "").strip()
expected_key = settings.CALDAV_INBOUND_API_KEY
if not expected_key or not secrets.compare_digest(api_key, expected_key):
logger.warning(
"CalDAV scheduling callback request with invalid API key. "
"Expected: %s..., Got: %s...",
expected_key[:10] if expected_key else "None",
api_key[:10] if api_key else "None",
)
return HttpResponse(status=401)
# Extract headers
sender = request.headers.get("X-CalDAV-Sender", "")
recipient = request.headers.get("X-CalDAV-Recipient", "")
method = request.headers.get("X-CalDAV-Method", "")
# For now, just log the scheduling message
logger.info(
"Received CalDAV scheduling callback: %s -> %s (method: %s)",
sender,
recipient,
method,
)
# Log message body (first 500 chars)
if request.body:
body_preview = request.body[:500].decode("utf-8", errors="ignore")
logger.info("Scheduling message body (first 500 chars): %s", body_preview)
# TODO: Process the scheduling message (send email, update calendar, etc.)
# For now, just return success
return HttpResponse(status=200, content_type="text/plain")

View File

@@ -30,10 +30,8 @@ class CalDAVClient:
"""
Get a CalDAV client for the given user.
The CalDAV server uses Apache authentication backend which reads REMOTE_USER.
We pass the X-Forwarded-User header which the server converts to REMOTE_USER.
The caldav library requires username/password for Basic Auth, but we use
empty password since authentication is handled via headers.
The CalDAV server requires API key authentication via Authorization header
and X-Forwarded-User header for user identification.
"""
# CalDAV server base URL - include the base URI path that sabre/dav expects
# Remove trailing slash from base_url and base_uri_path to avoid double slashes
@@ -41,14 +39,26 @@ class CalDAVClient:
base_uri_clean = self.base_uri_path.rstrip("/")
caldav_url = f"{base_url_clean}{base_uri_clean}/"
# Prepare headers
# API key is required for authentication
headers = {
"X-Forwarded-User": user.email,
}
outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY
if not outbound_api_key:
raise ValueError("CALDAV_OUTBOUND_API_KEY is not configured")
headers["X-Api-Key"] = outbound_api_key
# No username/password needed - authentication is via API key and X-Forwarded-User header
# Pass None to prevent the caldav library from trying Basic auth
return DAVClient(
url=caldav_url,
username=user.email,
password="", # Empty password - server uses X-Forwarded-User header
username=None,
password=None,
timeout=self.timeout,
headers={
"X-Forwarded-User": user.email,
},
headers=headers,
)
def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str:

View File

@@ -0,0 +1,246 @@
"""Tests for CalDAV scheduling callback integration."""
import http.server
import logging
import secrets
import socket
import threading
import time
from datetime import datetime, timedelta
from django.conf import settings
import pytest
from caldav.lib.error import NotFoundError
from core import factories
from core.services.caldav_service import CalendarService
logger = logging.getLogger(__name__)
class CallbackHandler(http.server.BaseHTTPRequestHandler):
"""HTTP request handler for capturing CalDAV scheduling callbacks in tests."""
def __init__(self, callback_data, *args, **kwargs):
self.callback_data = callback_data
super().__init__(*args, **kwargs)
def do_POST(self):
"""Handle POST requests (scheduling callbacks)."""
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else b""
# Store callback data
self.callback_data["called"] = True
self.callback_data["request_data"] = {
"headers": dict(self.headers),
"body": body.decode("utf-8", errors="ignore") if body else "",
}
# Send success response
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
def log_message(self, format, *args):
"""Suppress default logging."""
pass
def create_test_server() -> tuple:
"""Create a test HTTP server that captures callbacks.
Returns:
Tuple of (server, port, callback_data)
"""
callback_data = {"called": False, "request_data": None}
def handler_factory(*args, **kwargs):
return CallbackHandler(callback_data, *args, **kwargs)
# Use fixed port 8001 - accessible from other Docker containers
port = 8001
# Create server with SO_REUSEADDR to allow quick port reuse
server = http.server.HTTPServer(("0.0.0.0", port), handler_factory)
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
actual_port = server.server_address[1]
return server, actual_port, callback_data
@pytest.mark.django_db
class TestCalDAVScheduling:
"""Tests for CalDAV scheduling callback when creating events with attendees."""
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_scheduling_callback_received_when_creating_event_with_attendee(self):
"""Test that creating an event with an attendee triggers scheduling callback.
This test verifies that when an event is created with an attendee via CalDAV,
the HttpCallbackIMipPlugin sends a scheduling message to the Django callback endpoint.
The test starts a local HTTP server to receive the callback, and passes the server URL
to the CalDAV server via the X-CalDAV-Callback-URL header.
"""
# Create users: organizer
# Note: attendee should be external (not in CalDAV server) to trigger scheduling
organizer = factories.UserFactory(email="organizer@example.com")
# Create calendar for organizer
service = CalendarService()
calendar = service.create_calendar(
organizer, name="Test Calendar", color="#ff0000"
)
# Start test HTTP server to receive callbacks
# Use fixed port 8001 - accessible from other Docker containers
server, port, callback_data = create_test_server()
# Start server in a separate thread
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
# Give the server a moment to start listening
time.sleep(0.5)
# Verify server is actually listening
test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
test_socket.connect(("127.0.0.1", port))
test_socket.close()
except Exception as e:
pytest.fail(f"Test server failed to start on port {port}: {e}")
# Use the named test container hostname
# The test container is created with --name backend-test in bin/pytest
# Docker Compose networking allows containers to reach each other by name
callback_url = f"http://backend-test:{port}/"
try:
# Create an event with an attendee
client = service.caldav._get_client(organizer)
calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}"
# Add custom callback URL header to the client
# The CalDAV server will use this URL for the callback
client.headers["X-CalDAV-Callback-URL"] = callback_url
try:
caldav_calendar = client.calendar(url=calendar_url)
# Create event with attendee using iCalendar format
# We need to create the event with attendees to trigger scheduling
# Note: sabre/dav's scheduling plugin only sends messages for external attendees
# (attendees that don't have a principal in the same CalDAV server)
dtstart = datetime.now() + timedelta(days=1)
dtend = dtstart + timedelta(hours=1)
# Use a clearly external attendee email (not in the CalDAV server)
external_attendee = "external-attendee@external-domain.com"
# Create iCalendar event with attendee
ical_content = f"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test Client//EN
BEGIN:VEVENT
UID:test-event-{datetime.now().timestamp()}
DTSTART:{dtstart.strftime("%Y%m%dT%H%M%SZ")}
DTEND:{dtend.strftime("%Y%m%dT%H%M%SZ")}
SUMMARY:Test Event with Attendee
ORGANIZER;CN=Organizer:mailto:{organizer.email}
ATTENDEE;CN=External Attendee;RSVP=TRUE:mailto:{external_attendee}
END:VEVENT
END:VCALENDAR"""
# Save event to trigger scheduling
event = caldav_calendar.save_event(ical_content)
# Give the callback a moment to be called (scheduling may be async)
# sabre/dav processes scheduling synchronously during the request
time.sleep(2)
# Verify callback was called
assert callback_data["called"], (
"Scheduling callback was not called when creating event with attendee. "
"This may indicate that sabre/dav's scheduling plugin is not working correctly. "
"Check CalDAV server logs for scheduling errors."
)
# Verify callback request details
request_data = callback_data["request_data"]
assert request_data is not None
# Verify API key authentication
api_key = request_data["headers"].get("X-Api-Key", "")
expected_key = settings.CALDAV_INBOUND_API_KEY
assert expected_key and secrets.compare_digest(api_key, expected_key), (
f"Callback request missing or invalid X-Api-Key header. "
f"Expected: {expected_key[:10]}..., Got: {api_key[:10] if api_key else 'None'}..."
)
# Verify scheduling headers
assert "X-CalDAV-Sender" in request_data["headers"], (
"Missing X-CalDAV-Sender header"
)
assert "X-CalDAV-Recipient" in request_data["headers"], (
"Missing X-CalDAV-Recipient header"
)
assert "X-CalDAV-Method" in request_data["headers"], (
"Missing X-CalDAV-Method header"
)
# Verify sender is the organizer
sender = request_data["headers"]["X-CalDAV-Sender"]
assert (
organizer.email in sender or f"mailto:{organizer.email}" in sender
), f"Expected sender to be {organizer.email}, got {sender}"
# Verify recipient is the attendee
recipient = request_data["headers"]["X-CalDAV-Recipient"]
assert (
external_attendee in recipient
or f"mailto:{external_attendee}" in recipient
), f"Expected recipient to be {external_attendee}, got {recipient}"
# Verify method is REQUEST (for new invitations)
method = request_data["headers"]["X-CalDAV-Method"]
assert method == "REQUEST", (
f"Expected method to be REQUEST for new invitation, got {method}"
)
# Verify iCalendar content is present
assert request_data["body"], "Callback request body is empty"
assert "BEGIN:VCALENDAR" in request_data["body"], (
"Callback body should contain iCalendar content"
)
assert "VEVENT" in request_data["body"], (
"Callback body should contain VEVENT"
)
# Normalize iCalendar body to handle line folding (CRLF + space/tab)
# iCalendar format folds long lines at 75 characters, so we need to remove folding
# Line folding: CRLF followed by space or tab indicates continuation
body = request_data["body"]
# Remove line folding: replace CRLF+space and CRLF+tab with nothing
normalized_body = body.replace("\r\n ", "").replace("\r\n\t", "")
# Also handle Unix-style line endings
normalized_body = normalized_body.replace("\n ", "").replace("\n\t", "")
assert external_attendee in normalized_body, (
f"Callback body should contain attendee email {external_attendee}. "
f"Normalized body (first 500 chars): {normalized_body[:500]}"
)
except NotFoundError:
pytest.skip("Calendar not found - CalDAV server may not be running")
except Exception as e:
pytest.fail(f"Failed to create event with attendee: {str(e)}")
finally:
# Shutdown server
server.shutdown()
server.server_close()

View File

@@ -22,9 +22,9 @@ class TestCalDAVClient:
dav_client = client._get_client(user)
# Verify the client is configured correctly
assert dav_client.username == user.email
# Password should be empty (None or empty string) for external auth
assert not dav_client.password or dav_client.password == ""
# Username and password should be None to prevent Basic auth
assert dav_client.username is None
assert dav_client.password is None
# Verify the X-Forwarded-User header is set
# The caldav library stores headers as a CaseInsensitiveDict

View File

@@ -7,7 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.api.viewsets_caldav import CalDAVProxyView
from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView
from core.external_api import viewsets as external_api_viewsets
# - Main endpoints
@@ -31,6 +31,12 @@ urlpatterns = [
CalDAVProxyView.as_view(),
name="caldav-proxy",
),
# CalDAV scheduling callback endpoint (separate from caldav proxy)
path(
"caldav-scheduling-callback/",
CalDAVSchedulingCallbackView.as_view(),
name="caldav-scheduling-callback",
),
]
),
),