Files
Oxicloud/tests/oidc/sso-only.hurl
T
2026-08-21 23:56:25 +02:00

194 lines
10 KiB
Plaintext

# =============================================================
# 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.full.user.username" == "oidc_user"
# Group-to-role mapping worked — this is now the admin (and the only
# user).
jsonpath "$.user.full.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 "$.full.user.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.
# ─────────────────────────────────────────────────────────────