✨(scheduling) add callback from caldav to django for imip
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
246
src/backend/core/tests/test_caldav_scheduling.py
Normal file
246
src/backend/core/tests/test_caldav_scheduling.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user