diff --git a/.gitignore b/.gitignore index 689de6d5..763da59d 100644 --- a/.gitignore +++ b/.gitignore @@ -100,7 +100,9 @@ tests/e2e/test-results/ tests/e2e/blob-report/ tests/e2e/playwright/.cache/ tests/e2e/playwright/.auth/ + tests/webdav/storage-litmus/ +tests/oidc-manual/ tests/caldav/storage/ tests/caldav/.venv/ tests/caldav/__pycache__/ diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index df48ec0f..d7166627 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -249,6 +249,19 @@ } } + // Shared by onMount step 4 and onSetup: true + navigates away iff OIDC is + // the only login method. Centralised so the guard can't drift between the + // two call sites (only the `?error=` loop-guard, checked at onMount time, + // doesn't apply post-setup — a freshly created admin can't have bounced + // off the IdP yet). + function tryAutoRedirectToIdp(): boolean { + if (oidc.enabled && oidc.password_login_enabled === false && oidc.authorize_endpoint) { + window.location.replace(oidc.authorize_endpoint); + return true; + } + return false; + } + async function onSetup(e: SubmitEvent) { e.preventDefault(); setupError = ''; @@ -260,10 +273,14 @@ busy = true; try { await setupAdmin(setupEmail, setupPassword); - setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.'); setupEmail = setupPassword = setupConfirm = ''; // Admin now exists — fold the setup affordance away and return to login. setupAvailable = false; + // OIDC-only: the login page would immediately redirect on the next + // visit anyway — skip the "you can now sign in" detour and forward + // straight to the IdP instead of leaving a dead-end local form. + if (tryAutoRedirectToIdp()) return; + setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.'); setTimeout(() => { mode = 'login'; setupSuccess = ''; @@ -325,14 +342,7 @@ // 4) Auto-redirect: when OIDC is the only auth method, skip the login page. // Guard against loops: if the IdP returned ?error=, fall through to the UI. - if ( - oidc.enabled && - oidc.password_login_enabled === false && - oidc.authorize_endpoint && - !setupAvailable && - !page.url.searchParams.has('error') - ) { - window.location.replace(oidc.authorize_endpoint); + if (!setupAvailable && !page.url.searchParams.has('error') && tryAutoRedirectToIdp()) { return; } diff --git a/justfile b/justfile index 345fed49..e8870ce0 100644 --- a/justfile +++ b/justfile @@ -180,6 +180,12 @@ front-design: # --config server-with-oidc.env so the # api and webdav suites stay on the # OIDC-off config. +# * tests/oidc/run-manual-sso-only.sh — NOT part of this chain (see +# `oidc-manual-sso-only` below): a +# http://localhost:8090/files/1bf4713c-891e-46fb-acf0-b10231fe32c8 human-run check that OIDC-as-only- +# login-method actually redirects a +# real browser, which the curl-driven +# suite above can't observe. # # Same chain runs in CI under the `api-test` job in # .github/workflows/ci.yml; keep the order in sync so a local pass means @@ -228,6 +234,13 @@ test-caldav: cargo build ./tests/caldav/run-pycaldav.sh +# Manual, human-run: launches OxiCloud with OIDC as the ONLY login method +# (fake IdP on :1081, server on :8090) and waits for you to eyeball the +# /login auto-redirect in a real browser. Not part of `just api-test` — +# there's no automated assertion here, it's a visual check. Ctrl-C to stop. +#oidc-manual-sso-only: +# bash tests/oidc/run-manual-sso-only.sh + # --------------------------------------------------------------------------- # SvelteKit frontend (frontend/) — the only frontend. These `fe-*` recipes # drive its dev server, build, lint and tests. diff --git a/src/main.rs b/src/main.rs index d2d59541..f57fb08a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -923,12 +923,44 @@ async fn run() -> Result<(), Box> { next: axum::middleware::Next, ) -> axum::response::Response { let mut res = next.run(req).await; + // A 304 Not Modified carries no entity headers (no Content-Type) since + // there's no body — `is_html` would read `None` and misclassify it as + // "not html", attaching the strict headerless CSP below. Browsers merge + // a 304's headers into the cached document's effective response, so + // that stray header would then stack with (and defeat) the SPA's own + // hash-based `` CSP on every revalidated repeat visit — this was + // a real bug (see git blame): a browser tab reopened at `/login` after + // the first, freshly-fetched visit got permanently stuck behind the + // boot spinner because its now-conditionally-cached `200` picked up an + // extra hash-less `script-src 'self'` header from the 304 that + // revalidated it, blocking the app's own inline hydration script. + // Nothing to add on a 304 regardless — its headers must only carry + // caching metadata, never a fresh policy decision. + if res.status() == axum::http::StatusCode::NOT_MODIFIED { + return res; + } let is_html = res .headers() .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .is_some_and(|v| v.starts_with("text/html")); - if !is_html { + if is_html { + // `no-store` (not just `no-cache`) on the SPA shell: Chrome/Firefox/ + // Safari all treat `no-store` as an explicit opt-out of the + // back-forward cache (bfcache), which is a full in-memory snapshot + // of the page that bypasses HTTP revalidation entirely — `no-cache` + // alone does NOT prevent it. Without this, a shell instance loaded + // before a deploy can be resurrected byte-for-byte (old inline + // hydration script + old CSP hash) after navigating away and back — + // e.g. the OIDC login round-trip's two full-page navigations — and + // the resurrected page's old CSP `` no longer matches assets + // referenced by the current build, leaving the app permanently + // stuck behind the boot spinner until a hard reload. + res.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ); + } else { res.headers_mut().insert( axum::http::header::CONTENT_SECURITY_POLICY, HeaderValue::from_static( diff --git a/tests/common/server-with-oidc-only.env b/tests/common/server-with-oidc-only.env new file mode 100644 index 00000000..47f04d5a --- /dev/null +++ b/tests/common/server-with-oidc-only.env @@ -0,0 +1,79 @@ +# OxiCloud test-server env file for the MANUAL SSO-only auto-redirect test. +# +# Layered on top of server-with-oidc.env: identical EXCEPT +# OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true, which makes OIDC the ONLY +# login method (magic-link is already hard-disabled whenever OIDC is +# enabled, per the "OIDC master rule" — see example.env). This is the +# config the frontend's login-page auto-redirect guard +# (frontend/src/routes/login/+page.svelte) actually fires under — +# tests/common/server-with-oidc.env keeps password login on, so the +# automated tests/oidc/oidc.hurl suite never exercises the redirect. +# +# Used by tests/oidc/run-manual-sso-only.sh (human-run, not CI). Distinct +# ports (8090 / IdP 1081) so it doesn't collide with a concurrently running +# `just api-test` (which uses 8087 / IdP 1080) or a local `cargo run` dev +# server. +# +# `--config` makes the binary read THIS file verbatim — there is no +# auto-merge with server.env, so every variable the server needs has +# to be repeated here (same rationale as server-with-oidc.env). + +# ── Shared test config (mirrors server.env) ──────────────────────────────── +DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_STATIC_PATH=./static +OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars +OXICLOUD_ENABLE_AUTH=true +OXICLOUD_ENABLE_TRASH=true +OXICLOUD_ENABLE_SEARCH=true +OXICLOUD_ENABLE_FILE_SHARING=true +OXICLOUD_ENABLE_MUSIC=true +OXICLOUD_EXPOSE_SYSTEM_USERS=true +OXICLOUD_WOPI_ENABLED=false +OXICLOUD_NEXTCLOUD_ENABLED=true +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +RUST_LOG="warn,audit=info,oxicloud::infrastructure::services::oidc_service=info,oxicloud::application::services::auth_application_service=info" + +OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600 +OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600 +OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600 +OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 + +# Mock SMTP — kept wired even though magic-link login is disabled under the +# OIDC master rule, so the invite/mail transport doesn't 503 unconfigured. +OXICLOUD_SMTP_MOCK=true +OXICLOUD_SMTP_HOST=localhost +OXICLOUD_SMTP_PORT=25 +OXICLOUD_SMTP_FROM='OxiCloud Tests ' +OXICLOUD_SMTP_TLS=none +OXICLOUD_ALLOW_EXTERNAL_USERS=true + +# ── OIDC client wired at the fake-idp sidecar (SSO-only) ─────────────────── +# tests/oidc/fake_idp/server.js (panva/node-oidc-provider) publishes the +# issuer at the root URL; discovery is at /.well-known/openid-configuration +# under it. Update the `clients[0].client_id` field there in tandem if you +# rename the client. +OXICLOUD_OIDC_ENABLED=true +OXICLOUD_OIDC_ISSUER_URL=http://localhost:1081 +OXICLOUD_OIDC_CLIENT_ID=oxicloud-test +OXICLOUD_OIDC_CLIENT_SECRET=test-client-secret-not-used-in-prod +# The IdP redirects back to this exact URL after auto-approving; must +# match the OxiCloud server's actual host + port. +OXICLOUD_OIDC_REDIRECT_URI=http://localhost:8090/api/auth/oidc/callback +OXICLOUD_OIDC_SCOPES="openid profile email" +# Frontend redirect target after a successful callback. The backend +# appends `/login?oidc_code=…` to this base, so the value here is the +# SPA origin only. +OXICLOUD_OIDC_FRONTEND_URL=http://localhost:8090 +OXICLOUD_OIDC_AUTO_PROVISION=true +OXICLOUD_OIDC_PROVIDER_NAME=MockSSO-Only +# Group-to-role mapping — same fake-idp claim shape as server-with-oidc.env. +OXICLOUD_OIDC_ADMIN_GROUPS=admin-users + +# The single flag that makes OIDC the ONLY login method: is_password_login_allowed() +# is exactly `!disable_password_login` (auth_application_service.rs). Magic-link +# is already hard-disabled whenever OIDC is enabled, regardless of AUTH_METHODS. +OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true + +OXICLOUD_REQUIRE_VERIFIED_EMAIL=false diff --git a/tests/oidc/fake_idp/server.js b/tests/oidc/fake_idp/server.js index f54adf5f..a7c0ea27 100644 --- a/tests/oidc/fake_idp/server.js +++ b/tests/oidc/fake_idp/server.js @@ -67,7 +67,12 @@ const configuration = { { client_id: 'oxicloud-test', client_secret: 'test-client-secret-not-used-in-prod', - redirect_uris: ['http://localhost:8087/api/auth/oidc/callback'], + // 8087: automated tests/oidc/oidc.hurl suite. 8090: human-run + // tests/oidc/run-manual-sso-only.sh (SSO-only auto-redirect check). + redirect_uris: [ + 'http://localhost:8087/api/auth/oidc/callback', + 'http://localhost:8090/api/auth/oidc/callback', + ], grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_post', diff --git a/tests/oidc/run-manual-sso-only.sh b/tests/oidc/run-manual-sso-only.sh new file mode 100755 index 00000000..02f426aa --- /dev/null +++ b/tests/oidc/run-manual-sso-only.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# MANUAL, human-run SSO-only auto-redirect check. NOT part of `just +# api-test` / CI — there is no automated assertion here, this launches a +# real server + real fake IdP and waits for a human to open a browser and +# eyeball the behavior. +# +# What it proves that the automated suites can't: +# * tests/oidc/oidc.hurl drives the OIDC flow via curl against +# tests/common/server-with-oidc.env, which keeps password login +# enabled — the frontend's login-page auto-redirect guard +# (frontend/src/routes/login/+page.svelte) never fires there. +# * The Vitest coverage for that guard (frontend/src/routes/login/ +# page.test.ts) mocks getOidcProviders() and stubs +# window.location.replace — it proves the logic is right, not that a +# real browser actually navigates away when the backend is genuinely +# OIDC-only. +# +# This script starts OxiCloud with tests/common/server-with-oidc-only.env +# (OIDC is the ONLY login method) against the same fake IdP used by the +# automated suite, then blocks until you Ctrl-C. +# +# Ports (deliberately distinct from tests/oidc/run.sh's 8087 / 1080, so +# this can run alongside `just api-test` or a local `cargo run` dev +# server): OxiCloud on 8090, fake IdP on 1081. +# +# Prerequisites: docker, cargo, node >= 20, npm. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +OIDC_DIR="$REPO_ROOT/tests/oidc" +FAKE_IDP_DIR="$OIDC_DIR/fake_idp" + +SERVER_PORT=8090 +IDP_PORT=1081 +base_url="http://localhost:$SERVER_PORT" +oidc_issuer="http://localhost:$IDP_PORT" + +# ── Helpers ──────────────────────────────────────────────────────────────── +log() { echo "[oidc-manual] $*"; } +die() { echo "[oidc-manual] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-60}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 0.5 + done +} + +# ── Fake-IdP process management (mirrors tests/oidc/run.sh) ──────────────── +kill_fake_idp() { + pkill -f "tests/oidc/fake_idp/server.js" 2>/dev/null || true + pkill -f "node.*server.js" 2>/dev/null || true + if command -v lsof >/dev/null 2>&1; then + local pids + pids=$(lsof -ti :"$IDP_PORT" 2>/dev/null || true) + if [[ -n "$pids" ]]; then + # shellcheck disable=SC2086 + kill -9 $pids 2>/dev/null || true + fi + fi +} + +# ── Teardown (always runs on exit) ───────────────────────────────────────── +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + log "Stopping fake-idp..." + kill_fake_idp + bash "$COMMON/stop-db.sh" || true +} +trap cleanup EXIT + +# ── 1. Postgres ──────────────────────────────────────────────────────────── +bash "$COMMON/spawn-db.sh" + +# ── 2. Fake IdP (Node) ───────────────────────────────────────────────────── +log "Installing fake-idp dependencies..." +if [[ -f "$FAKE_IDP_DIR/package-lock.json" ]]; then + (cd "$FAKE_IDP_DIR" && npm ci --silent --no-audit --no-fund) +else + (cd "$FAKE_IDP_DIR" && npm install --silent --no-audit --no-fund) +fi + +log "Sweeping any orphan fake-idp processes from prior runs..." +kill_fake_idp +sleep 0.3 + +log "Starting fake-idp on port $IDP_PORT..." +FAKE_IDP_ISSUER="$oidc_issuer" FAKE_IDP_PORT="$IDP_PORT" \ + node "$FAKE_IDP_DIR/server.js" > /tmp/fake-idp-manual.log 2>&1 & +log "Waiting for fake-idp discovery endpoint..." +wait_for_http "$oidc_issuer/.well-known/openid-configuration" 30 +log "fake-idp is ready (logs: /tmp/fake-idp-manual.log)" + +# ── 3. Load shared server env (SSO-only) ──────────────────────────────────── +set -a +# shellcheck source=../common/server-with-oidc-only.env +source "$COMMON/server-with-oidc-only.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/oidc-manual/storage" +set +a + +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" + +# ── 3.5. Ensure the SPA is built (static-dist/) ──────────────────────────── +# The auto-redirect only fires against the production SPA bundle; without +# it `resolve_static_path` falls back to OXICLOUD_STATIC_PATH=./static, +# which doesn't have it. The frontend is a pure CSR SPA (prerender=false in +# +layout.ts) — there is only ONE shell file, static-dist/index.html, that +# every route (including /login) falls back to. Check for that, not a +# per-route file (one never gets emitted; checking for it would force a +# full rebuild on every single invocation). +DIST_DIR="$REPO_ROOT/static-dist" +if [[ ! -f "$DIST_DIR/index.html" ]]; then + log "Building SvelteKit SPA (static-dist/index.html missing)..." + (cd "$REPO_ROOT/frontend" \ + && npm ci --silent --no-audit --no-fund \ + && npm run build) || die "Frontend build failed; static-dist/ is required" +fi + +# ── 4. Start OxiCloud server with OIDC-only config ────────────────────────── +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +if [[ ! -x "$OXICLOUD_BIN" ]]; then + log "Building OxiCloud server ($BUILD_TARGET)..." + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;; + *) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;; + esac +fi + +log "Starting OxiCloud server with OIDC-only config on port $SERVER_PORT..." +"$OXICLOUD_BIN" --config "$COMMON/server-with-oidc-only.env" & +SERVER_PID=$! +log "Waiting for server at $base_url..." +wait_for_http "$base_url/ready" 120 +log "Server is ready." + +# ── 5. Hand off to the human ──────────────────────────────────────────────── +cat < must NOT redirect (loop guard); shows the login form. + * First run / no admin yet (already handled above by wiping + storage) -> shows the setup wizard, not a redirect, until + you complete it once via the IdP. + +Press Ctrl-C to stop the server and tear down. +========================================================== + +EOF + +wait "$SERVER_PID"