diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py index 75549943..ccc34024 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -38,8 +38,9 @@ def test_index_html_loads(page: Page, radicale_server: str) -> None: page.on("console", lambda msg: console_msgs.append(msg.text)) page.goto(radicale_server) expect(page).to_have_title("Radicale Web Interface") - # There should be no errors on the console - assert len(console_msgs) == 0 + # There should be no errors on the console, except for the expected 401 from auto-login check + errors = [msg for msg in console_msgs if "401 (Unauthorized)" not in msg] + assert len(errors) == 0 def test_user_login_works(page: Page, radicale_server: str) -> None: diff --git a/integ_tests/test_x_remote_user.py b/integ_tests/test_x_remote_user.py new file mode 100644 index 00000000..f41f7836 --- /dev/null +++ b/integ_tests/test_x_remote_user.py @@ -0,0 +1,163 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Max Berger +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Integration test for X-Remote-User authentication +""" + +import os +import pathlib +import socket +import subprocess +import sys +import time +from typing import Any, Generator + +import pytest +from playwright.sync_api import BrowserContext, Page, expect + +from integ_tests.common import create_collection, get_free_port + + +def start_radicale_server_remote(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + port = get_free_port() + config_path = tmp_path / "config" + storage_path = tmp_path / "collections" + + # Create a local config file with http_x_remote_user auth + with open(config_path, "w") as f: + f.write( + f"""[server] +hosts = 127.0.0.1:{port} +[storage] +filesystem_folder = {storage_path} +[auth] +type = http_x_remote_user +[web] +type = internal +[headers] +Content-Security-Policy = default-src 'self'; object-src 'none' +[sharing] +type = csv +collection_by_map = true +collection_by_token = true +permit_create_token = true +permit_create_map = true +permit_properties_overlay = true +collection_by_bday = true +permit_create_bday = true + +""" + ) + + env = os.environ.copy() + # Ensure the radicale package is in PYTHONPATH + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") + + # Run the server + process = subprocess.Popen( + [sys.executable, "-m", "radicale", "--config", str(config_path)], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Wait for the server to start listening + start_time = time.time() + while time.time() - start_time < 10: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except (OSError, ConnectionRefusedError): + if process.poll() is not None: + _stdout, stderr = process.communicate() + raise RuntimeError( + f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" + ) + time.sleep(0.1) + else: + process.terminate() + process.wait() + raise RuntimeError("Timeout waiting for Radicale to start") + + yield f"http://127.0.0.1:{port}" + + # Cleanup + process.terminate() + process.wait() + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server_remote(tmp_path) + + +def test_index_html_loads( + context: BrowserContext, page: Page, radicale_server: str +) -> None: + """Test that the index.html loads from the server with remote user.""" + context.set_extra_http_headers({"X-Remote-User": "admin"}) + console_msgs: list[str] = [] + page.on("console", lambda msg: console_msgs.append(msg.text)) + page.goto(radicale_server) + expect(page).to_have_title("Radicale Web Interface") + # There should be no errors on the console, except for the expected 401 from initial auto-login check + errors = [msg for msg in console_msgs if "401 (Unauthorized)" not in msg] + assert len(errors) == 0 + + +def test_user_authenticated( + context: BrowserContext, page: Page, radicale_server: str +) -> None: + """Test that the user is automatically authenticated via X-Remote-User.""" + context.set_extra_http_headers({"X-Remote-User": "admin"}) + page.goto(radicale_server) + + # The login page should be skipped entirely if authenticated. + # After auto-login, we should see the collections list (which is empty) + expect( + page.locator( + '#logoutview span[data-name="user"]', has_text="admin's Collections" + ) + ).to_be_visible() + expect(page.locator('#logoutview a[data-name="logout"]')).to_be_hidden() + + +def test_create_collection_works( + context: BrowserContext, page: Page, radicale_server: str +) -> None: + """Test creating a collection with remote user.""" + context.set_extra_http_headers({"X-Remote-User": "admin"}) + page.goto(radicale_server) + + # Wait for auto-login + expect( + page.locator( + '#logoutview span[data-name="user"]', has_text="admin's Collections" + ) + ).to_be_visible() + + create_collection(page, radicale_server) + + # Verify that the new collection exists afterwards + # By default it's called "Untitled Collection" or similar? + # Let's check what create_collection does. + # It clicks .fabcontainer a[data-name="new"] and then #createcollectionscene button[data-name="submit"] + + expect(page.locator("article:not(.hidden)")).to_have_count(1) + # The title might be the HREF if no display name is set + expect(page.locator("article:not(.hidden) .title")).to_be_visible() diff --git a/radicale/web/internal_data/js/api/api.js b/radicale/web/internal_data/js/api/api.js index d061523f..c371e07e 100644 --- a/radicale/web/internal_data/js/api/api.js +++ b/radicale/web/internal_data/js/api/api.js @@ -22,7 +22,7 @@ import { COLOR_RE, ROOT_PATH, SERVER } from "../constants.js"; import { Collection, CollectionType } from "../models/collection.js"; import { escape_xml } from "../utils/misc.js"; -import { to_error_message } from "./common.js"; +import { create_request, to_error_message } from "./common.js"; /** * Find the principal collection. @@ -32,8 +32,7 @@ import { to_error_message } from "./common.js"; * @return {XMLHttpRequest} */ export function get_principal(user, password, callback) { - let request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + ROOT_PATH, true, user, encodeURIComponent(password)); + let request = create_request("PROPFIND", SERVER + ROOT_PATH, user, password); request.onreadystatechange = function () { if (request.readyState !== 4) { return; @@ -78,8 +77,7 @@ export function get_principal(user, password, callback) { * @return {XMLHttpRequest} */ export function get_collections(user, password, collection, callback) { - let request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + collection.href, true, user, encodeURIComponent(password)); + let request = create_request("PROPFIND", SERVER + collection.href, user, password); request.setRequestHeader("depth", "1"); request.onreadystatechange = function () { if (request.readyState !== 4) { @@ -201,8 +199,7 @@ export function get_collections(user, password, collection, callback) { * @return {XMLHttpRequest} */ export function upload_collection(user, password, collection_href, file, callback) { - let request = new XMLHttpRequest(); - request.open("PUT", SERVER + collection_href, true, user, encodeURIComponent(password)); + let request = create_request("PUT", SERVER + collection_href, user, password); request.onreadystatechange = function () { if (request.readyState !== 4) { return; @@ -226,8 +223,7 @@ export function upload_collection(user, password, collection_href, file, callbac * @return {XMLHttpRequest} */ export function delete_collection(user, password, collection, callback) { - let request = new XMLHttpRequest(); - request.open("DELETE", SERVER + collection.href, true, user, encodeURIComponent(password)); + let request = create_request("DELETE", SERVER + collection.href, user, password); request.onreadystatechange = function () { if (request.readyState !== 4) { return; @@ -251,8 +247,7 @@ export function delete_collection(user, password, collection, callback) { * @return {XMLHttpRequest} */ function create_edit_collection(user, password, collection, create, callback) { - let request = new XMLHttpRequest(); - request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, encodeURIComponent(password)); + let request = create_request(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, user, password); request.onreadystatechange = function () { if (request.readyState !== 4) { return; diff --git a/radicale/web/internal_data/js/api/common.js b/radicale/web/internal_data/js/api/common.js index c69637e9..e17f8e9d 100644 --- a/radicale/web/internal_data/js/api/common.js +++ b/radicale/web/internal_data/js/api/common.js @@ -30,4 +30,21 @@ export function to_error_message(request) { } return request.status + " " + request.statusText; +} + +/** + * @param {string} method + * @param {string} url + * @param {?string} user + * @param {?string} password + * @returns {XMLHttpRequest} + */ +export function create_request(method, url, user, password) { + let request = new XMLHttpRequest(); + if (user !== null && password !== null) { + request.open(method, url, true, user, encodeURIComponent(password)); + } else { + request.open(method, url, true); + } + return request; } \ No newline at end of file diff --git a/radicale/web/internal_data/js/api/sharing.js b/radicale/web/internal_data/js/api/sharing.js index 1d3feedc..30739e0f 100644 --- a/radicale/web/internal_data/js/api/sharing.js +++ b/radicale/web/internal_data/js/api/sharing.js @@ -18,7 +18,7 @@ import { ROOT_PATH, SERVER } from "../constants.js"; import { CollectionType } from "../models/collection.js"; -import { to_error_message } from "./common.js"; +import { create_request, to_error_message } from "./common.js"; /** * @typedef {Object} SharingFeatures @@ -56,13 +56,11 @@ function call_sharing_api( on_not_found = null, on_error = null, ) { - let request = new XMLHttpRequest(); - request.open( + let request = create_request( "POST", SERVER + ROOT_PATH + ".sharing/v1/" + path, - true, user, - encodeURIComponent(password), + password, ); request.onreadystatechange = function () { if (request.readyState !== 4) { diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index d671487d..de0a0829 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -20,6 +20,7 @@ */ import { get_principal } from "../api/api.js"; +import { ROOT_PATH, SERVER } from "../constants.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; import { FormValidator, validate_non_empty } from "../utils/form_validator.js"; @@ -57,6 +58,47 @@ export class LoginScene { password_form.value = ""; } + /** + * @param {string} p_user + * @param {string} p_password + */ + function perform_login(p_user, p_password) { + user = p_user; + // setup logout + logout_view.classList.remove("hidden"); + if (p_password === null) { + logout_btn.classList.add("hidden"); + } else { + logout_btn.classList.remove("hidden"); + } + logout_btn.onclick = onlogout; + refresh_btn.onclick = refresh; + logout_user_form.textContent = user + "'s Collections"; + // Fetch principal + let loading_scene = new LoadingScene(); + push_scene(loading_scene); + principal_req = get_principal(user, p_password, function (principal_collection, error1) { + if (!is_current_scene(loading_scene)) { + return; + } + principal_req = null; + if (error1) { + errorHandler.setError(error1); + pop_scene(); + } else { + // show collections + let saved_user = user; + user = ""; + let collections_scene = new CollectionsScene( + saved_user, p_password, principal_collection, function (error1) { + errorHandler.setError(error1); + user = saved_user; + }); + replace_scene(collections_scene); + } + }); + } + function onlogin() { try { collectionsCache.invalidate(); @@ -65,34 +107,7 @@ export class LoginScene { if (!validator.validate()) { return false; } - // setup logout - logout_view.classList.remove("hidden"); - logout_btn.onclick = onlogout; - refresh_btn.onclick = refresh; - logout_user_form.textContent = user + "'s Collections"; - // Fetch principal - let loading_scene = new LoadingScene(); - push_scene(loading_scene); - principal_req = get_principal(user, password, function (principal_collection, error1) { - if (!is_current_scene(loading_scene)) { - return; - } - principal_req = null; - if (error1) { - errorHandler.setError(error1); - pop_scene(); - } else { - // show collections - let saved_user = user; - user = ""; - let collections_scene = new CollectionsScene( - saved_user, password, principal_collection, function (error1) { - errorHandler.setError(error1); - user = saved_user; - }); - replace_scene(collections_scene); - } - }); + perform_login(user, password); } catch (err) { console.error(err); } @@ -132,6 +147,32 @@ export class LoginScene { form.onsubmit = onlogin; html_scene.classList.remove("hidden"); user_form.focus(); + + // Probe for existing authentication (e.g. X-Remote-User) + // Use fetch with credentials: 'omit' to avoid browser login prompt on 401 + if (window.fetch) { + fetch(SERVER + ROOT_PATH, { + method: 'PROPFIND', + headers: { 'Depth': '0' }, + credentials: 'omit' + }).then(function (response) { + if (response.ok) { + // Authenticated! Now it's safe to call get_principal + get_principal(null, null, function (principal_collection, error) { + if (!error && principal_collection) { + let authenticated_user = principal_collection.displayname; + if (!authenticated_user) { + let href = principal_collection.href.replace(/\/+$/, ""); + authenticated_user = href.substring(href.lastIndexOf("/") + 1); + } + perform_login(authenticated_user, null); + } + }); + } + })["catch"](function () { + // Ignore error: we are not authenticated or something else went wrong + }); + } }; this.hide = function () { read_form();