test(oidc): test auto_redirect_if_standalone_oidc and RP iniiate logout

This commit is contained in:
Edouard Vanbelle
2026-08-03 21:35:54 +02:00
parent 921cbef152
commit ac32595846
7 changed files with 583 additions and 17 deletions
@@ -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 <test@oxicloud.local>'
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
+36 -17
View File
@@ -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
+15
View File
@@ -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,
+195
View File
@@ -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."
+69
View File
@@ -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}}&"
+11
View File
@@ -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
+193
View File
@@ -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.
# ─────────────────────────────────────────────────────────────