diff --git a/tests/common/server-with-oidc-only-no-policy.env b/tests/common/server-with-oidc-only-no-policy.env new file mode 100644 index 00000000..5f1013c6 --- /dev/null +++ b/tests/common/server-with-oidc-only-no-policy.env @@ -0,0 +1,64 @@ +# OxiCloud test-server env file for the SSO-only, NO-auto-redirect posture. +# +# Same as server-with-oidc-only.env EXCEPT OXICLOUD_AUTH_POLICIES is not +# set. Proves the /login middleware is opt-in — with AUTH_METHODS=oidc +# alone the SPA still renders at /login (with just the SSO button) and +# the middleware falls through. Under this posture the user has to click +# the button to start the OIDC flow instead of being auto-redirected. +# +# Paired with tests/oidc/sso-only-no-policy.hurl; both driven by the +# `run-sso-only.sh` runner in a two-phase sequence. + +# ── 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 + +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) ─────────────────── +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 +OXICLOUD_OIDC_REDIRECT_URI=http://localhost:8090/api/auth/oidc/callback +OXICLOUD_OIDC_SCOPES="openid profile email" +OXICLOUD_OIDC_FRONTEND_URL=http://localhost:8090 +OXICLOUD_OIDC_AUTO_PROVISION=true +OXICLOUD_OIDC_PROVIDER_NAME=MockSSO-NoPolicy +OXICLOUD_OIDC_ADMIN_GROUPS=admin-users + +# Same modern SSO-only mechanism as server-with-oidc-only.env. +OXICLOUD_AUTH_METHODS=oidc + +# ── The DELIBERATE OMISSION ──────────────────────────────────────────────── +# OXICLOUD_AUTH_POLICIES is NOT set here. This is the whole point of the +# test — with AUTH_METHODS=oidc alone, the login middleware in +# src/interfaces/web/mod.rs::oidc_standalone_login_redirect must fall +# through (SPA shell served at /login) instead of returning 302. The +# with-policy variant (server-with-oidc-only.env) proves the flip side. + +OXICLOUD_REQUIRE_VERIFIED_EMAIL=false diff --git a/tests/common/server-with-oidc-only.env b/tests/common/server-with-oidc-only.env index 7a198000..87862495 100644 --- a/tests/common/server-with-oidc-only.env +++ b/tests/common/server-with-oidc-only.env @@ -1,18 +1,25 @@ -# OxiCloud test-server env file for the MANUAL SSO-only auto-redirect test. +# OxiCloud test-server env file for the 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 BOTH runners on port 8090 / IdP 1081: +# * tests/oidc/run-sso-only.sh — automated, drives Hurl assertions +# (server-side /login 302 + RP-initiated logout post_logout_url shape). +# * tests/oidc/run-manual-sso-only.sh — human-run browser eyeball to +# confirm zero login-form flash before the redirect fires. # -# 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. +# What makes it "SSO-only": +# * OXICLOUD_AUTH_METHODS=oidc — allowlist is [Oidc] only. Password +# and magic-link are both hard-off at the deployment level; the +# fail-fast validator in config.rs refuses to boot if `oidc` is in +# the list without OXICLOUD_OIDC_ENABLED=true (or vice versa in a +# future major). +# * OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc — the +# policy switch that makes GET /login return a server-side 302 to +# /api/auth/oidc/authorize BEFORE the SPA loads (no form flash). +# Interception lives in src/interfaces/web/mod.rs. +# +# 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 @@ -70,9 +77,21 @@ 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 +# Modern SSO-only mechanism (preferred over legacy +# OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true, which still works but is a +# per-flag toggle instead of the composable allowlist below). +# +# AUTH_METHODS=oidc restricts the effective allowlist to [Oidc]. Password +# and magic-link both refuse at the endpoint layer. Combined with the +# OIDC master rule (magic-link hard-off whenever OIDC is enabled) this +# closes every non-SSO login path. +OXICLOUD_AUTH_METHODS=oidc + +# AUTH_POLICIES: additive switches to auth behavior. The +# auto_redirect_if_standalone_oidc token makes GET /login return a 302 +# to /api/auth/oidc/authorize (server-side, via web-layer middleware) so +# the SPA never renders. Loop-guards are built in — a Location or +# ?error= query on /login falls through to the SPA shell. +OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc OXICLOUD_REQUIRE_VERIFIED_EMAIL=false diff --git a/tests/oidc/fake_idp/server.js b/tests/oidc/fake_idp/server.js index 5efb2567..96ea36cc 100644 --- a/tests/oidc/fake_idp/server.js +++ b/tests/oidc/fake_idp/server.js @@ -115,6 +115,15 @@ const configuration = { // (all-device) revocation. backchannel_logout_uri: `${OXICLOUD_BASE_URL}/api/auth/oidc/backchannel-logout`, backchannel_logout_session_required: true, + // RP-initiated logout — required for tests/oidc/sso-only.hurl to + // exercise the `post_logout_url` shape returned by OxiCloud's + // /api/auth/logout when the session is OIDC-backed. The `/login` + // URLs on both automated (8087) and manual (8090) ports are + // registered so both runners can drive the flow. + post_logout_redirect_uris: [ + 'http://localhost:8087/login', + 'http://localhost:8090/login', + ], }, ], @@ -181,6 +190,12 @@ const configuration = { // and POSTs it to OxiCloud. That's the same wire shape a real // IdP produces, so OxiCloud's validator is exercised end-to-end. backchannelLogout: { enabled: true }, + // RP-Initiated Logout 1.0. Turning it on advertises + // `end_session_endpoint` in discovery so OxiCloud's + // `build_end_session_url` (invoked from POST /api/auth/logout) + // returns a real URL instead of None. Without this the SSO-only + // Hurl assertion `post_logout_url is present` fails silently. + rpInitiatedLogout: { enabled: true }, }, // Put scope-implied claims (name, given_name, family_name, diff --git a/tests/oidc/run-sso-only.sh b/tests/oidc/run-sso-only.sh new file mode 100755 index 00000000..bb2d9b82 --- /dev/null +++ b/tests/oidc/run-sso-only.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# AUTOMATED SSO-only integration test — two phases. +# +# Both phases run against the SAME fake IdP + SAME database (spawned +# once) but restart OxiCloud between them so the boot config differs: +# +# Phase A — server-with-oidc-only-no-policy.env +# OXICLOUD_AUTH_METHODS=oidc, no AUTH_POLICIES +# → GET /login must return 200 (SPA shell, no redirect) +# → providers.auto_redirect_to_oidc == false +# Driven by sso-only-no-policy.hurl. +# +# Phase B — server-with-oidc-only.env +# OXICLOUD_AUTH_METHODS=oidc AND +# OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc +# → GET /login must return 307 to /api/auth/oidc/authorize +# → providers.auto_redirect_to_oidc == true +# → full OIDC dance + RP-initiated logout assertions +# Driven by sso-only.hurl. +# +# Phase order matters: A runs first because it doesn't touch DB state +# (no admin bootstrap). B runs second and does the admin bootstrap via +# JIT provisioning. Restarting OxiCloud between phases is cheap +# (~500ms) and cleaner than a hot config reload. +# +# Sibling script tests/oidc/run-manual-sso-only.sh runs Phase B only +# and stops after "server ready" so a human can eyeball the browser +# flow — keep both: this script proves the wire contract, the manual +# one proves the UX. +# +# Ports: OxiCloud on 8090, fake IdP on 1081 (distinct from 8087 / 1080 +# so this can run alongside `just api-test` or a local dev server). +# +# Prerequisites: docker, cargo, node >= 20, npm, hurl >= 4.0. +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" + +# shellcheck source=sso-only.env +source "$OIDC_DIR/sso-only.env" + +SERVER_PORT="${base_url##*:}" +IDP_PORT="${oidc_issuer##*:}" + +# ── Helpers ──────────────────────────────────────────────────────────────── +log() { echo "[sso-only] $*"; } +die() { echo "[sso-only] 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 + 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 +} + +# ── Server process management ────────────────────────────────────────────── +SERVER_PID="" + +start_oxicloud() { + local env_file="$1" + set -a + # shellcheck disable=SC1090 + source "$env_file" + OXICLOUD_SERVER_PORT=$SERVER_PORT + OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/oidc/storage-sso-only" + set +a + log "Starting OxiCloud (config: $(basename "$env_file"))..." + "$OXICLOUD_BIN" --config "$env_file" & + SERVER_PID=$! + wait_for_http "$base_url/ready" 120 + log "Server is ready (pid $SERVER_PID)." +} + +stop_oxicloud() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID="" + # Give the OS a moment to release the port; without this a fast + # restart occasionally loses the bind on macOS. + sleep 0.3 + fi +} + +# ── Teardown (always runs on exit) ───────────────────────────────────────── +cleanup() { + stop_oxicloud + 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" \ + OXICLOUD_BASE_URL_FOR_BCL="$base_url" \ + node "$FAKE_IDP_DIR/server.js" > /tmp/fake-idp-sso-only.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-sso-only.log)" + +# ── 3. Wipe storage once ─────────────────────────────────────────────────── +export OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/oidc/storage-sso-only" +# 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/) ──────────────────────────── +# Both phases hit /login and expect the SPA shell response (Phase A as +# the primary assertion, Phase B as the loop-guard fallthrough). Without +# static-dist/ the ServeDir fallback would 404 those calls. +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. Build OxiCloud once ───────────────────────────────────────────────── +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 + +# ══════════════════════════════════════════════════════════════════════════ +# Phase A — SSO-only, NO auto-redirect policy +# ══════════════════════════════════════════════════════════════════════════ +log "" +log "════════════ Phase A: SSO-only, no auto-redirect ════════════" +start_oxicloud "$COMMON/server-with-oidc-only-no-policy.env" +log "Running sso-only-no-policy.hurl..." +hurl --variables-file "$OIDC_DIR/sso-only.env" \ + --file-root "$REPO_ROOT/tests" \ + --test --jobs 1 \ + "$OIDC_DIR/sso-only-no-policy.hurl" +log "Phase A passed." +stop_oxicloud + +# ══════════════════════════════════════════════════════════════════════════ +# Phase B — SSO-only, auto_redirect_if_standalone_oidc policy on +# ══════════════════════════════════════════════════════════════════════════ +log "" +log "════════════ Phase B: SSO-only, auto-redirect ON ════════════" +start_oxicloud "$COMMON/server-with-oidc-only.env" +log "Running sso-only.hurl..." +hurl --variables-file "$OIDC_DIR/sso-only.env" \ + --file-root "$REPO_ROOT/tests" \ + --test --jobs 1 \ + "$OIDC_DIR/sso-only.hurl" +log "Phase B passed." + +log "" +log "SSO-only tests (both phases) passed." diff --git a/tests/oidc/sso-only-no-policy.hurl b/tests/oidc/sso-only-no-policy.hurl new file mode 100644 index 00000000..fbe33367 --- /dev/null +++ b/tests/oidc/sso-only-no-policy.hurl @@ -0,0 +1,69 @@ +# ============================================================= +# OxiCloud — SSO-only WITHOUT auto-redirect policy +# ============================================================= +# Sibling to tests/oidc/sso-only.hurl. Both run against +# server-with-oidc-only-no-policy.env (OXICLOUD_AUTH_METHODS=oidc but +# OXICLOUD_AUTH_POLICIES unset) and prove the SPECIFIC posture difference +# the auto_redirect_if_standalone_oidc policy makes: +# +# * with policy (sso-only.hurl): GET /login → 307 to /api/auth/oidc/authorize +# * without policy (this file): GET /login → 200 (SPA shell) +# +# In this posture the SPA renders the login page with the SSO button; +# the user clicks it to start the OIDC flow. Everything else about the +# OIDC surface is identical, so we DON'T re-run the full OIDC dance — +# that's covered by sso-only.hurl + oidc.hurl. This file only asserts +# what actually differs. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Providers reports SSO-only but WITHOUT auto-redirect. +# The `auto_redirect_to_oidc` field on the DTO comes from +# AuthApplicationService::auto_redirect_to_oidc(), which +# requires the policy in the vector. Without it → false. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/providers + +HTTP 200 +[Asserts] +jsonpath "$.enabled" == true +jsonpath "$.password_login_enabled" == false +jsonpath "$.magic_link_login_enabled" == false +# The load-bearing difference from sso-only.hurl: +jsonpath "$.auto_redirect_to_oidc" == false + + +# ───────────────────────────────────────────────────────────── +# Step 2 — GET /login must NOT redirect. The middleware's +# `should_redirect` predicate evaluates false because +# `auto_redirect_to_oidc()` returns false (policy absent), +# so the request falls through to the SPA fallback service. +# Result: 200 with the SPA shell HTML. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/login +[Options] +location: false + +HTTP 200 +# Deliberately no Location-header assertion — we're proving its ABSENCE +# by way of the 200 status. If the middleware had incorrectly fired the +# redirect this would be a 307. + + +# ───────────────────────────────────────────────────────────── +# Step 3 — The authorize endpoint still works (user clicks the SSO +# button → SPA fetches this URL → server redirects to IdP). +# Same shape as sso-only.hurl Step 4; here we only assert +# the FIRST hop returns a valid IdP URL, which is enough to +# prove the OIDC surface is functional under this posture. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: false + +HTTP 307 +[Asserts] +# Location points at the fake IdP's /auth endpoint with the OAuth2 +# response_type / client_id / redirect_uri / PKCE dance. +header "Location" matches "^{{oidc_issuer}}/auth\\?response_type=code&client_id={{oidc_client_id}}&" diff --git a/tests/oidc/sso-only.env b/tests/oidc/sso-only.env new file mode 100644 index 00000000..842d9404 --- /dev/null +++ b/tests/oidc/sso-only.env @@ -0,0 +1,11 @@ +# Variables fed to Hurl for the SSO-only integration test. +# +# Ports distinct from test.env (8087 / 1080) so this can run alongside +# `just api-test` or a concurrent OIDC suite without collision. +base_url=http://localhost:8090 +oidc_issuer=http://localhost:1081 +oidc_authorize_endpoint=http://localhost:1081/auth +# The OIDC client the fake IdP registers — same client_id whether the +# SSO-only test or the plain OIDC test drives it. Used to assert the +# `client_id=` param in the RP-initiated logout URL. +oidc_client_id=oxicloud-test diff --git a/tests/oidc/sso-only.hurl b/tests/oidc/sso-only.hurl new file mode 100644 index 00000000..87a33c0b --- /dev/null +++ b/tests/oidc/sso-only.hurl @@ -0,0 +1,193 @@ +# ============================================================= +# OxiCloud — SSO-only posture: server-side /login 302 + RP-initiated logout +# ============================================================= +# Complements tests/oidc/oidc.hurl (which runs with OXICLOUD_AUTH_METHODS +# accepting password + oidc and never fires the auto-redirect middleware). +# This suite runs against tests/common/server-with-oidc-only.env which +# sets: +# * OXICLOUD_AUTH_METHODS=oidc +# * OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc +# +# What it proves the plain OIDC suite can't: +# 1. GET /api/auth/oidc/providers reports the standalone-OIDC posture +# correctly (auto_redirect_to_oidc=true, password + magic-link off). +# 2. GET /login returns a server-side 302 to /api/auth/oidc/authorize +# BEFORE the SPA loads (interception lives in web/mod.rs, wired via +# an axum middleware layer). +# 3. GET /login?error=… falls through to the SPA shell (loop-guard so +# an IdP failure doesn't put the browser in an infinite redirect). +# 4. POST /api/auth/logout on an OIDC-backed session returns +# `post_logout_url` shaped exactly like the RP-initiated logout URL +# Keycloak / other IdPs expect: end_session_endpoint + +# id_token_hint + post_logout_redirect_uri + client_id. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Providers discovery reports the standalone-OIDC posture. +# The SPA no longer reads auto_redirect_to_oidc (server-side +# redirect handles it), but the field is still exposed for +# diagnostics / future clients. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/providers + +HTTP 200 +[Asserts] +jsonpath "$.enabled" == true +jsonpath "$.password_login_enabled" == false +# Magic-link is hard-off whenever OIDC is enabled (OIDC master rule) +# regardless of what AUTH_METHODS says. Belt-and-braces with the +# allowlist which also excludes it. +jsonpath "$.magic_link_login_enabled" == false +# The policy is on, no other method is live, so the flag resolves true. +jsonpath "$.auto_redirect_to_oidc" == true + + +# ───────────────────────────────────────────────────────────── +# Step 2 — /login returns 302 to /api/auth/oidc/authorize. +# location: false so we assert on the header rather than +# following. The middleware intercepts BEFORE ServeDir would +# hand out the SPA shell, so no HTML body is produced. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/login +[Options] +location: false + +HTTP 307 +[Asserts] +# axum::response::Redirect::temporary → 307 with the target as Location. +header "Location" == "/api/auth/oidc/authorize" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Loop-guard: /login?error=… must NOT redirect. The IdP +# bounces here on failure (Keycloak returns to +# post_logout_redirect_uri with ?error= on some flows); a +# middleware that redirected regardless would ping-pong the +# browser between OxiCloud and the failing IdP forever. +# Falling through to the SPA lets the login page render the +# error banner. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/login?error=access_denied +[Options] +location: false + +HTTP 200 +# No Location header — the ServeDir fallback served the SPA shell. +# We don't assert on the body (the shell is minimal HTML) because the +# 200 status alone proves the middleware fell through instead of +# returning a redirect. + + +# ───────────────────────────────────────────────────────────── +# Step 3b — Loop-guard: /login?oidc_code=… must also NOT redirect. +# This is the callback landing URL — the SPA reads the code +# from the query string and swaps it for a session. If the +# middleware redirected on this we'd never complete the login. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/login?oidc_code=deadbeef +[Options] +location: false + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Full OIDC dance. No local admin exists yet — SSO-only means +# the first admin bootstraps by logging in via OIDC and getting +# the admin role via the group mapping (OXICLOUD_OIDC_ADMIN_GROUPS +# matches the fake IdP's `admin-users` group claim). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: false + +HTTP 307 +[Captures] +idp_url: header "Location" + + +GET {{idp_url}} +[Options] +location: true +location-trusted: true + +HTTP 200 +[Captures] +oidc_code: url regex "oidc_code=([a-f0-9]+)" + + +POST {{base_url}}/api/auth/oidc/exchange +Content-Type: application/json +{ "code": "{{oidc_code}}" } + +HTTP 200 +[Asserts] +jsonpath "$.user.username" == "oidc_user" +# Group-to-role mapping worked — this is now the admin (and the only +# user). +jsonpath "$.user.role" == "admin" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Confirm the session is live before we log out. Load-bearing +# for Step 6: without proving /me works first, a 401 in Step 6 +# could mean "logout worked" OR "session was never live". +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.username" == "oidc_user" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — RP-initiated logout returns the end_session URL. The backend +# reads the OIDC id_token from the session row, calls the OIDC +# service to build the URL from discovery's end_session_endpoint +# + id_token_hint + post_logout_redirect_uri + client_id. +# The SPA reads `post_logout_url` and window.location.replace's +# to it — see AppShell.svelte::onLogout. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/logout +Content-Type: application/json +{} + +HTTP 200 +[Asserts] +# Field is present. +jsonpath "$.post_logout_url" isString +# Points at the IdP's end_session_endpoint (oidc-provider mounts it at +# /session/end by default). +jsonpath "$.post_logout_url" matches "^{{oidc_issuer}}/session/end\\?" +# id_token_hint is present and non-empty (JWT-shaped: three dot-separated +# base64url segments). +jsonpath "$.post_logout_url" matches "id_token_hint=[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+" +# post_logout_redirect_uri points back at /login on this deployment. +# The value is URL-encoded so we look for the encoded form. +jsonpath "$.post_logout_url" contains "post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A8090%2Flogin" +# client_id echoes the configured OIDC client. Real IdPs (Keycloak +# post-19) use this to fall back to the registered post-logout redirect +# when the id_token_hint has expired. +jsonpath "$.post_logout_url" contains "client_id={{oidc_client_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Local session gone. The backend cleared the auth cookies +# alongside returning post_logout_url; the browser normally +# proceeds to navigate to the IdP, but we skip that hop here +# and verify locally that the cookies + session row are dead. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me + +HTTP 401 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Non-OIDC-session logout returns {} (no post_logout_url). +# We can't easily manufacture a password/magic-link session +# under SSO-only posture (both are refused at the endpoint +# layer). Left as a note; unit test in +# auth_application_service covers the `Ok(None)` return branch +# when session.oidc_id_token IS NULL. +# ─────────────────────────────────────────────────────────────