fix: dedupe IdP auto-redirect and fix CSP/bfcache bug on the SPA shell
- Extract tryAutoRedirectToIdp() on the login page so onMount's redirect guard and the post-setup flow share one check instead of drifting. - Fix a real bug: 304 Not Modified responses carry no Content-Type, so is_html misclassified them and attached the strict headerless CSP, which browsers merge into the cached 200's effective headers and defeat the SPA's hash-based CSP on revalidated repeat visits. - Add Cache-Control: no-store on the SPA shell to opt out of bfcache, preventing a pre-deploy shell (stale inline hydration script + CSP hash) from being resurrected byte-for-byte across the OIDC redirect's full-page navigations. - Add a manual, human-run SSO-only script/env (ports 8090/1081) since the automated oidc.hurl suite keeps password login enabled and never exercises the auto-redirect guard.
This commit is contained in:
@@ -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__/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+33
-1
@@ -923,12 +923,44 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 `<meta>` 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 `<meta>` 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(
|
||||
|
||||
@@ -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 <test@oxicloud.local>'
|
||||
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
|
||||
@@ -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',
|
||||
|
||||
Executable
+176
@@ -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 <<EOF
|
||||
|
||||
==========================================================
|
||||
SSO-ONLY MANUAL TEST — server ready at $base_url
|
||||
==========================================================
|
||||
Open $base_url/login in a browser.
|
||||
|
||||
Expected: the page redirects immediately to the fake IdP
|
||||
($oidc_issuer/...) with no login form flash. The fake IdP
|
||||
auto-approves — you should land back on
|
||||
$base_url/login?oidc_code=... and then on the authenticated app.
|
||||
|
||||
Edge cases to also eyeball:
|
||||
* $base_url/login?error=access_denied
|
||||
-> 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"
|
||||
Reference in New Issue
Block a user