From b91f2fab2ba3299d98e33ae261fd85edf1a8fae1 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 00:11:49 +0200 Subject: [PATCH 1/9] feat(oidc): add oidc method in OXICLOUD_AUTH_METHODS permit an admin to specify `oidc` only as the only method to login/register note that if OIDC is enabled, the engine always append oidc in OXICLOUD_AUTH_METHODS we could move to an explicit declaration in a major release --- docs/architecture/auth-model.md | 10 ++-- docs/config/authentication.md | 21 +++++-- docs/config/env.md | 2 +- example.env | 65 ++++++++++++++-------- src/common/config.rs | 97 +++++++++++++++++++++++++-------- src/main.rs | 9 ++- 6 files changed, 144 insertions(+), 60 deletions(-) diff --git a/docs/architecture/auth-model.md b/docs/architecture/auth-model.md index fab5b421..b339543b 100644 --- a/docs/architecture/auth-model.md +++ b/docs/architecture/auth-model.md @@ -46,18 +46,20 @@ The same dispatch applies to `POST /api/auth/magic-link/send` — its `email` fi ## Deployment auth policy -Two env vars control the self-service auth surface, orthogonal to OIDC: +Two env vars control the auth surface. OIDC is a first-class allowlist token, no longer orthogonal: -- `OXICLOUD_AUTH_METHODS` — allowlist of enabled methods (`password`, `magic_link`, or both). Default: both. Removing one produces distinct error_type codes so the SPA can render specific UX: +- `OXICLOUD_AUTH_METHODS` — allowlist of enabled methods (`password`, `magic_link`, `oidc`, or any comma-separated combination). Default (unset): `password,magic_link`. Removing a token produces distinct error_type codes so the SPA can render specific UX: - Removing `password` → `POST /api/auth/login` → 403 `PasswordLoginDisabled`; password-based `register` → 403 `PasswordRegistrationDisabled`. - Removing `magic_link` → `magic-link/send` → 403 `MagicLinkLoginDisabled`; login-purpose token redemption refuses. - - **Startup gate:** magic-link-only + no SMTP wired → server refuses to start (main.rs panics). + - Setting to just `oidc` → SSO-only posture, local login surface disabled. + - **Fail-fast on boot:** unknown token, empty allowlist, or `oidc` listed without `OXICLOUD_OIDC_ENABLED=true` all panic startup. + - **Startup gate:** `magic_link` as the only working method (no `password`, no `oidc`) with no SMTP wired → server refuses to start. - `OXICLOUD_AUTH_POLICIES` — additive policy switches. Today: `permit_magic_link_for_password_users`. Future variants (`Require...`, `Deny...`) reuse the same vector-shaped env var — no per-policy env-var proliferation. - `OXICLOUD_REQUIRE_VERIFIED_EMAIL` — when true, `POST /api/auth/login` returns 403 `EmailNotVerified` for accounts with `email_verified_at IS NULL`. Checked AFTER password validation (anti-enum — an attacker without the password can't probe verification state). **Admin accounts are exempt** from this gate to prevent a config flip from locking pre-existing admins out of their own instance. **Verification piggyback.** When the `EmailNotVerified` branch fires (password OK + email unverified), the login handler auto-sends a verification magic-link to the account via a distinct service method that bypasses the `has_password` eligibility gate — the password itself just proved identity, so mailbox-only trust isn't being extended beyond what the password already established. Response is 403 `EmailNotVerified` with "check your inbox"; re-submitting the same login re-triggers the send. This is why there is no unauthenticated "resend verification" endpoint — one would leak `has_password` state to unauthenticated callers. -**OIDC-master rule.** When `OXICLOUD_OIDC_ENABLED=true`, magic-link login is hard-off regardless of `OXICLOUD_AUTH_METHODS`. Magic-link would bypass any 2FA / step-up the IdP enforces. +**OIDC-master rule.** When OIDC is enabled (either explicitly in `OXICLOUD_AUTH_METHODS` or via `OXICLOUD_OIDC_ENABLED=true`), magic-link login is hard-off regardless of what the allowlist says. Magic-link would bypass any 2FA / step-up the IdP enforces. ## Login paths diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 8d3e20eb..2aea8f04 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -38,21 +38,32 @@ OxiCloud ships with JWT-based authentication and Argon2id password hashing for l ## Configuring which methods are offered -Two environment variables control the self-service surface (OIDC is orthogonal — see `OXICLOUD_OIDC_ENABLED`). +Two environment variables control the auth surface. OIDC is a first-class allowlist token alongside `password` and `magic_link`. ### `OXICLOUD_AUTH_METHODS` -Comma-separated allowlist of `password` and/or `magic_link`. Default `password,magic_link`. +Comma-separated allowlist of `password`, `magic_link`, and/or `oidc`. Default (when unset): `password,magic_link`. | Configuration | Effect | | --- | --- | -| Unset or `password,magic_link` | Both methods allowed (default) | +| Unset | Password + magic-link (OIDC gated separately by `OXICLOUD_OIDC_ENABLED`) | +| `password,magic_link` | Same as unset — both self-service methods | | `password` | Password login OK. Magic-link send / redeem → 403 `MagicLinkLoginDisabled` | | `magic_link` | Password login → 403 `PasswordLoginDisabled`. Password-based `register` → 403 `PasswordRegistrationDisabled`. Email-only signup still works | +| `oidc` | **SSO-only** posture. Requires `OXICLOUD_OIDC_ENABLED=true` + a full OIDC config bucket; local password + magic-link both disabled | +| `password,oidc` | Hybrid: local password + SSO, no magic-link | +| `password,magic_link,oidc` | Everything on | -**Startup gate.** If `magic_link` is the only method allowed AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start with a fatal message. A magic-link-only policy without a working mailer silently locks every user out. +**Fail-fast.** Misconfiguration panics at boot instead of degrading silently: +- Unknown token (e.g. `password,sso2`) → boot panic with `expected: password, magic_link, oidc` +- Empty allowlist (e.g. `OXICLOUD_AUTH_METHODS=`) → boot panic (would lock everyone out otherwise) +- `oidc` listed but `OXICLOUD_OIDC_ENABLED != true` → boot panic (advertising a method the server can't serve) -**OIDC master rule.** When `OXICLOUD_OIDC_ENABLED=true`, magic-link login is **hard-disabled** regardless of this list. The IdP is the identity boundary; magic-link would bypass any 2FA / step-up policy the IdP enforces. The startup gate above does **not** trigger in this case — OIDC provides the login path. +**Loose semantic (documented).** The symmetric case is NOT fatal yet: when `OXICLOUD_AUTH_METHODS` is explicitly set WITHOUT `oidc` but `OXICLOUD_OIDC_ENABLED=true`, OIDC is served in addition to the listed methods — the enabled flag wins. A warning is logged at boot to make the mismatch visible. **Planned for the next major release**: this will escalate to a fail-fast panic so `AUTH_METHODS` becomes the authoritative allowlist for OIDC too. Align configs now (either add `oidc` to the list or set `OXICLOUD_OIDC_ENABLED=false`) to avoid the breaking change. + +**Startup gate.** If `magic_link` is the only working method (no `password`, no `oidc`) AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start with a fatal message. A magic-link-only policy without a working mailer silently locks every user out. + +**OIDC master rule.** When OIDC is enabled, magic-link login is **hard-disabled** regardless of this list. The IdP is the identity boundary; magic-link would bypass any 2FA / step-up policy the IdP enforces. The startup gate above does **not** trigger in this case — OIDC provides the login path. Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the effective allowlist. diff --git a/docs/config/env.md b/docs/config/env.md index 1602d4e2..4dd71e91 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -46,7 +46,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes | | `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts | | `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted on `POST /api/auth/register` (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed. **Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`**: this one gates SELF-registration (public sign-up), the external list gates INVITATIONS (grants + magic-link to third parties). An operator can lock sign-up to their company domain while leaving invitations open. Subdomains must be listed explicitly. Rejected registrations return 403 `RegistrationDomainNotAllowed` and emit an `audit` line. Example: `mycompany.com,mycompany-eu.com`. | -| `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of self-service auth methods (`password`, `magic_link`). OIDC is orthogonal (see `OXICLOUD_OIDC_ENABLED`). Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. **Startup gate**: if `magic_link` is the only method allowed AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start. **OIDC master rule**: when `OXICLOUD_OIDC_ENABLED=true`, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list. | +| `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of auth methods (`password`, `magic_link`, `oidc`). **Fail-fast**: unknown token → boot panic; empty allowlist → boot panic; `oidc` in list without `OXICLOUD_OIDC_ENABLED=true` → boot panic. Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. Setting `OXICLOUD_AUTH_METHODS=oidc` is the cleanest "SSO-only" posture. **Loose semantic (deprecation warning)**: if this list is explicitly set WITHOUT `oidc` but `OXICLOUD_OIDC_ENABLED=true`, OIDC is served regardless — a boot warning is emitted and this will become a fail-fast panic in the next major release. **Startup gate**: if `magic_link` is the only working method (no `password`, no `oidc`) AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start. **OIDC master rule**: when OIDC is enabled, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list. | | `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless). | | `OXICLOUD_REQUIRE_VERIFIED_EMAIL` | `false` | When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for any account whose `email_verified_at` is NULL. Users can prove control by requesting a magic-link (whose redemption stamps `email_verified_at`), so this composes with `magic_link` in `OXICLOUD_AUTH_METHODS` to give users a self-service verification path. Admin-created (`POST /api/admin/users`) and setup-admin (`POST /api/setup`) users are auto-verified. OIDC-JIT users are also stamped verified at creation. | diff --git a/example.env b/example.env index 55093d24..4223e24e 100644 --- a/example.env +++ b/example.env @@ -700,40 +700,57 @@ OXICLOUD_WOPI_ENABLED=false #OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=mycompany.com,mycompany-eu.com # --------------------------------------------------------------------------- -# OXICLOUD_AUTH_METHODS — self-service authentication method allowlist. +# OXICLOUD_AUTH_METHODS — authentication method allowlist. # --------------------------------------------------------------------------- -# Comma-separated list of `password` and/or `magic_link`. Controls which -# self-service authentication methods this deployment offers on the login -# page and accepts at the corresponding endpoints. OIDC is orthogonal — -# use `OXICLOUD_OIDC_ENABLED` for that. +# Comma-separated list of `password`, `magic_link`, and/or `oidc`. Controls +# which authentication methods this deployment offers on the login page +# and accepts at the corresponding endpoints. +# +# FAIL-FAST semantics — misconfiguration crashes the server at boot with +# a specific error, never silently degrades: +# * Unknown token (e.g. `password,sso2`) → panic on startup +# * Empty allowlist (e.g. `OXICLOUD_AUTH_METHODS=`) → panic +# * `oidc` in the list but `OXICLOUD_OIDC_ENABLED != true` → panic +# ("advertising a login method the server can't serve") +# +# LOOSE SEMANTIC (documented) — the reverse of the last bullet is NOT +# fatal today: when this list is explicitly set WITHOUT `oidc` but +# `OXICLOUD_OIDC_ENABLED=true`, OIDC is served in addition to the +# listed methods. The enabled flag wins. A warning is logged at boot +# telling the admin to reconcile. This will escalate to a fail-fast +# panic in the next major release — align configs now to avoid the +# breaking change. # # Semantics per configuration: -# * Empty (unset) or `password,magic_link` — both methods allowed -# (default). Matches pre-flag behaviour. -# * `password` — `POST /api/auth/login` OK, -# magic-link send / redeem -# return 403 `MagicLinkLoginDisabled`. -# * `magic_link` — `POST /api/auth/login` -# returns 403 `PasswordLoginDisabled`; -# password-based `register` -# returns 403 `PasswordRegistrationDisabled`. +# * Unset — permissive default +# (password + magic_link; +# OIDC gated by its own flag). +# * `password` — password login only. +# * `magic_link` — magic-link login only +# (requires SMTP; see gate below). +# * `oidc` — OIDC only, no local login. +# Cleanest "SSO-only" posture. +# * `password,oidc` — hybrid: local + SSO, +# no magic-link. +# * `password,magic_link,oidc` — everything on. # -# SECURITY — startup gate. When `magic_link` is the ONLY method allowed -# but no SMTP transport is configured, the server refuses to start with a -# fatal message. A magic-link-only policy without a mail sender silently -# locks every user out of the deployment. +# SECURITY — startup gate. When `magic_link` is the ONLY working method +# (no `password`, no `oidc`) but no SMTP transport is configured, the +# server refuses to start. Prevents silently locking every user out. # -# SECURITY — OIDC master rule. When `OXICLOUD_OIDC_ENABLED=true`, magic- -# link login is HARD-disabled regardless of what this list says. OIDC is -# the master identity provider; magic-link would sidestep any 2FA / step- -# up the IdP enforces. The startup gate above does NOT trigger in this -# case (OIDC provides a login path). +# SECURITY — OIDC master rule. When OIDC is enabled (either explicitly +# in this list or via `OXICLOUD_OIDC_ENABLED=true`), magic-link login is +# HARD-disabled regardless of what this list says. OIDC is the master +# identity provider; magic-link would sidestep any 2FA / step-up the +# IdP enforces. # # Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes # `password` from this list. New deployments should prefer this env var. # -# Default: password,magic_link +# Default (when unset): password + magic_link. #OXICLOUD_AUTH_METHODS=password,magic_link +#OXICLOUD_AUTH_METHODS=oidc # OIDC-only (needs OIDC_ENABLED=true) +#OXICLOUD_AUTH_METHODS=password,oidc # hybrid local + SSO # --------------------------------------------------------------------------- # OXICLOUD_REQUIRE_VERIFIED_EMAIL — gate login on email verification. diff --git a/src/common/config.rs b/src/common/config.rs index 9856e28f..723bebd4 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -1452,21 +1452,26 @@ pub struct AuthConfig { /// Self-service auth method. Exposed as `AuthConfig::allowed_auth_methods` /// and parsed from `OXICLOUD_AUTH_METHODS` (comma-separated). OIDC is -/// deliberately excluded — it lives in `OidcConfig` with its own gate. +/// a first-class allowlist token: `OXICLOUD_AUTH_METHODS=oidc` = OIDC +/// only (needs `OXICLOUD_OIDC_ENABLED=true` + a full OIDC config bucket +/// or the boot rejects — cross-validation lives in `AppConfig::from_env`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthMethod { Password, MagicLink, + Oidc, } impl AuthMethod { /// Case-insensitive parse: accepts `password`, `magic_link`, and the - /// dash form `magic-link` (some operators habitually use dashes). - /// Unknown token returns `None` so the caller can log-and-skip. + /// dash form `magic-link` (some operators habitually use dashes), + /// plus `oidc`. Unknown token returns `None`; the caller (env + /// parser) treats that as a fatal boot error rather than a warning. pub fn parse(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "password" => Some(Self::Password), "magic_link" | "magic-link" | "magiclink" => Some(Self::MagicLink), + "oidc" | "sso" => Some(Self::Oidc), _ => None, } } @@ -2653,28 +2658,72 @@ impl AppConfig { // operator wrote `OXICLOUD_AUTH_METHODS=nope`), we restore the // default — a zero-method allowlist would refuse every login. if let Ok(v) = env::var("OXICLOUD_AUTH_METHODS") { - let methods: Vec = v - .split(',') - .filter_map(|s| { - let parsed = AuthMethod::parse(s); - if parsed.is_none() && !s.trim().is_empty() { - eprintln!( - "⚠️ OXICLOUD_AUTH_METHODS: ignoring unknown token '{}' \ - (expected: password, magic_link)", - s.trim() - ); - } - parsed - }) - .collect(); - if methods.is_empty() { - eprintln!( - "⚠️ OXICLOUD_AUTH_METHODS parsed to an empty allowlist; \ - falling back to default (password, magic_link)" - ); - } else { - config.auth.allowed_auth_methods = methods; + // Fail-fast on operator error: an unknown token, an empty + // allowlist, or a listed method whose infrastructure isn't + // wired all indicate a misconfiguration that would silently + // change auth surface behaviour (per memory + // `feedback_fail_fast_config`: boot panic > silent skip + // for anything a mistyped env var could break). + let mut methods: Vec = Vec::new(); + for raw in v.split(',') { + let token = raw.trim(); + if token.is_empty() { + continue; + } + match AuthMethod::parse(token) { + Some(m) => methods.push(m), + None => panic!( + "OXICLOUD_AUTH_METHODS: unknown token '{}' — expected any of: \ + password, magic_link, oidc", + token + ), + } } + if methods.is_empty() { + panic!( + "OXICLOUD_AUTH_METHODS is set to '{}' but produced an empty allowlist. \ + Either unset the variable (default = password, magic_link) or list at \ + least one method (password, magic_link, oidc).", + v + ); + } + // Cross-validation A: `oidc` in the allowlist requires OIDC + // to be enabled. Otherwise the login page would advertise a + // method the server can't actually serve. + let oidc_env_enabled = env::var("OXICLOUD_OIDC_ENABLED") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(false); + if methods.contains(&AuthMethod::Oidc) && !oidc_env_enabled { + panic!( + "OXICLOUD_AUTH_METHODS includes 'oidc' but OXICLOUD_OIDC_ENABLED \ + is not 'true'. Either set OXICLOUD_OIDC_ENABLED=true (plus \ + OXICLOUD_OIDC_ISSUER_URL / OXICLOUD_OIDC_CLIENT_ID / \ + OXICLOUD_OIDC_CLIENT_SECRET), or configure OIDC via the admin \ + panel and drop 'oidc' from OXICLOUD_AUTH_METHODS until it's ready." + ); + } + + // Cross-validation B: the reverse — OIDC enabled but the + // admin's explicit AUTH_METHODS list doesn't include `oidc`. + // Today: warn loudly (soft mismatch). PLANNED for the next + // major release: escalate to a fail-fast panic to match the + // symmetric cross-validation A above. The current loose + // behaviour silently serves OIDC in addition to what + // AUTH_METHODS lists — the enabled flag wins — which + // contradicts the "AUTH_METHODS is the authoritative + // allowlist" mental model. + if !methods.contains(&AuthMethod::Oidc) && oidc_env_enabled { + eprintln!( + "⚠️ OXICLOUD_AUTH_METHODS excludes 'oidc' but \ + OXICLOUD_OIDC_ENABLED=true — OIDC will be served \ + regardless. Add 'oidc' to OXICLOUD_AUTH_METHODS to \ + make the allowlist authoritative, or set \ + OXICLOUD_OIDC_ENABLED=false to exclude OIDC. \ + A future release will escalate this to a fatal boot error." + ); + } + config.auth.allowed_auth_methods = methods; } // Legacy alias: OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true still diff --git a/src/main.rs b/src/main.rs index 10a8c213..33e667b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -532,13 +532,18 @@ async fn run() -> Result<(), Box> { .auth .allowed_auth_methods .contains(&common::config::AuthMethod::Password) + && !config + .auth + .allowed_auth_methods + .contains(&common::config::AuthMethod::Oidc) && !config.smtp.is_enabled() { panic!( "FATAL: OXICLOUD_AUTH_METHODS enables `magic_link` as the ONLY \ self-service auth method, but no SMTP transport is configured. \ - Set OXICLOUD_SMTP_HOST (and matching OXICLOUD_SMTP_* settings) \ - or add `password` to OXICLOUD_AUTH_METHODS. Refusing to start." + Set OXICLOUD_SMTP_HOST (and matching OXICLOUD_SMTP_* settings), \ + add `password` or `oidc` to OXICLOUD_AUTH_METHODS, or drop \ + `magic_link` from the list. Refusing to start." ); } From 5ebe2d3bae56fde6603bd6e6542bc84864abf404 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 00:22:19 +0200 Subject: [PATCH 2/9] feat(oidc): add auto-redirect for OIDC add `auto_redirect_if_standalone_oidc` in `OXICLOUD_AUTH_POLICIES` let admin decide to redirect immediately to IdP if OIDC is the only auth method enabled --- docs/config/authentication.md | 1 + docs/config/env.md | 2 +- example.env | 13 +++++ frontend/src/lib/styles/ported/auth.css | 13 +++-- frontend/src/routes/login/+page.svelte | 28 ++-------- src/application/dtos/user_dto.rs | 8 +++ .../services/auth_application_service.rs | 39 ++++++++++++-- src/common/config.rs | 21 ++++++++ src/infrastructure/auth_factory.rs | 1 + src/interfaces/api/handlers/auth_handler.rs | 3 ++ src/interfaces/web/mod.rs | 51 ++++++++++++++++++- src/main.rs | 2 +- 12 files changed, 144 insertions(+), 38 deletions(-) diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 2aea8f04..0fa625df 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -122,6 +122,7 @@ The verification-piggyback flow above deliberately **bypasses the `has_password` | Token | Effect | | --- | --- | | `permit_magic_link_for_password_users` | Allow magic-link login for accounts that also have a password. OIDC-linked users are still refused. | +| `auto_redirect_if_standalone_oidc` | When OIDC is the ONLY working login method (no password, no magic-link — via allowlist or the OIDC-master rule), the login SPA auto-redirects to the IdP on page load instead of showing a click-to-continue SSO button. Off by default to avoid redirect loops on IdP failure and to preserve logout UX (logging out then visiting `/login` would otherwise bounce the user right back in). Silent no-op when other methods are also live. Frontend reads this via `auto_redirect_to_oidc` on `GET /api/auth/oidc/providers`. | Unknown tokens are logged-and-skipped at startup so a typo doesn't silently zero the vector. diff --git a/docs/config/env.md b/docs/config/env.md index 4dd71e91..8fd5be1c 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -47,7 +47,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts | | `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted on `POST /api/auth/register` (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed. **Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`**: this one gates SELF-registration (public sign-up), the external list gates INVITATIONS (grants + magic-link to third parties). An operator can lock sign-up to their company domain while leaving invitations open. Subdomains must be listed explicitly. Rejected registrations return 403 `RegistrationDomainNotAllowed` and emit an `audit` line. Example: `mycompany.com,mycompany-eu.com`. | | `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of auth methods (`password`, `magic_link`, `oidc`). **Fail-fast**: unknown token → boot panic; empty allowlist → boot panic; `oidc` in list without `OXICLOUD_OIDC_ENABLED=true` → boot panic. Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. Setting `OXICLOUD_AUTH_METHODS=oidc` is the cleanest "SSO-only" posture. **Loose semantic (deprecation warning)**: if this list is explicitly set WITHOUT `oidc` but `OXICLOUD_OIDC_ENABLED=true`, OIDC is served regardless — a boot warning is emitted and this will become a fail-fast panic in the next major release. **Startup gate**: if `magic_link` is the only working method (no `password`, no `oidc`) AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start. **OIDC master rule**: when OIDC is enabled, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list. | -| `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless). | +| `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless); `auto_redirect_if_standalone_oidc` (when OIDC is the only working login method, auto-redirect the login page to the IdP instead of showing a click-to-continue button — off by default to avoid redirect loops on IdP failure and preserve logout UX). | | `OXICLOUD_REQUIRE_VERIFIED_EMAIL` | `false` | When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for any account whose `email_verified_at` is NULL. Users can prove control by requesting a magic-link (whose redemption stamps `email_verified_at`), so this composes with `magic_link` in `OXICLOUD_AUTH_METHODS` to give users a self-service verification path. Admin-created (`POST /api/admin/users`) and setup-admin (`POST /api/setup`) users are auto-verified. OIDC-JIT users are also stamped verified at creation. | ### Rate Limiting & Account Lockout diff --git a/example.env b/example.env index 4223e24e..9577001b 100644 --- a/example.env +++ b/example.env @@ -805,8 +805,21 @@ OXICLOUD_WOPI_ENABLED=false # policy — the IdP is the security boundary and may enforce MFA we # shouldn't bypass. # +# auto_redirect_if_standalone_oidc +# When OIDC is the ONLY working login method (no password, no +# magic-link, whether via the allowlist or the OIDC-master rule), +# the login SPA auto-redirects to the OIDC authorize endpoint on +# page load instead of showing a click-to-continue SSO button. +# Off by default because auto-redirect can loop on IdP failure +# (login → IdP error → back to login → auto-redirect again) and +# makes logout-then-visit-login flows feel broken (bounces the +# user right back into the app). Silent no-op when the login page +# has more than one method available (nothing to auto-choose). +# # Example: #OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users +#OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc +#OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users,auto_redirect_if_standalone_oidc # Operator-level kill switch for share-notification emails to internal diff --git a/frontend/src/lib/styles/ported/auth.css b/frontend/src/lib/styles/ported/auth.css index dc7eeb4d..c1ffc91f 100644 --- a/frontend/src/lib/styles/ported/auth.css +++ b/frontend/src/lib/styles/ported/auth.css @@ -212,18 +212,17 @@ margin-top: var(--space-5); } -/* SSO / OIDC button */ +/* SSO / OIDC button — inherits the primary .auth-button visual + (accent gradient + on-accent text), only overriding layout so an + optional provider icon can sit beside the label. The previous + text-color gradient was unreadable in dark mode because the + inherited text color also resolved to a light value. */ .auth-button-oidc { - background: linear-gradient(135deg, var(--color-text) 0%, var(--color-text-secondary) 100%); - box-shadow: 0 4px 12px var(--color-shadow-3xl); display: flex; align-items: center; justify-content: center; gap: var(--space-2-5); -} - -.auth-button-oidc:hover { - box-shadow: 0 6px 20px var(--color-shadow-4xl); + text-decoration: none; } .auth-button-sso { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index d7166627..d938113f 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -249,19 +249,6 @@ } } - // 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 = ''; @@ -276,10 +263,6 @@ 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'; @@ -340,12 +323,11 @@ setupAvailable = !status.initialized; if (setupAvailable) mode = 'setup'; - // 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 (!setupAvailable && !page.url.searchParams.has('error') && tryAutoRedirectToIdp()) { - return; - } - + // Auto-redirect to the IdP in standalone-OIDC posture is enforced + // server-side via the `auto_redirect_if_standalone_oidc` auth policy + // (see interfaces/web/mod.rs::oidc_standalone_login_redirect). Keeping + // a client-side copy would make the policy toggle a no-op — the SPA + // would auto-redirect regardless of what the admin configured. booting = false; }); diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 0bc0aab9..059164af 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -383,6 +383,14 @@ pub struct OidcProviderInfoDto { /// straight after signup. #[serde(default)] pub require_verified_email: bool, + /// True iff the effective allowlist is `[Oidc]` AND the + /// `auto_redirect_if_standalone_oidc` policy is set. Frontend + /// uses this to decide whether to auto-redirect to the authorize + /// endpoint on login-page mount (true) or show a click-to-continue + /// button (false). Default false — the safe posture that avoids + /// redirect loops when the IdP is degraded. + #[serde(default)] + pub auto_redirect_to_oidc: bool, } /// Claims extracted from the validated OIDC ID token diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 1ba1f762..3c1611ef 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -9,7 +9,7 @@ use crate::application::ports::auth_ports::{ use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason}; use crate::application::services::user_lifecycle_service::UserLifecycleService; -use crate::common::config::{AuthMethod, OidcConfig}; +use crate::common::config::{AuthMethod, AuthPolicy, OidcConfig}; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus}; use crate::domain::entities::session::Session; @@ -161,6 +161,11 @@ pub struct AuthApplicationService { /// `is_password_login_allowed()` / `is_magic_link_login_allowed()` /// so callers don't have to reach for the app config. allowed_auth_methods: Vec, + /// Additive auth-policy switches (mirrors `AuthConfig::auth_policies`). + /// Consulted by handlers / providers-info endpoint to compose the + /// login-page UX hints (e.g. `AutoRedirectIfStandaloneOidc`) without + /// reaching into the app config on every call. + auth_policies: Vec, /// Whether `POST /api/auth/login` refuses accounts whose /// `email_verified_at IS NULL`. Mirrors /// `AuthConfig::require_verified_email`. @@ -209,20 +214,24 @@ impl AuthApplicationService { .time_to_live(USER_FLAGS_CACHE_TTL) .build(), allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink], + auth_policies: Vec::new(), require_verified_email: false, } } - /// Populates the auth-method allowlist + `require_verified_email` - /// snapshot from the loaded config. Called by the DI factory. If - /// left uncalled (test builds), defaults are permissive: both - /// methods enabled, verified-email not required. + /// Populates the auth-method allowlist + policy vector + + /// `require_verified_email` snapshot from the loaded config. + /// Called by the DI factory. If left uncalled (test builds), + /// defaults are permissive: both self-service methods enabled, + /// no policies, verified-email not required. pub fn with_auth_policy( mut self, allowed_methods: Vec, + auth_policies: Vec, require_verified_email: bool, ) -> Self { self.allowed_auth_methods = allowed_methods; + self.auth_policies = auth_policies; self.require_verified_email = require_verified_email; self } @@ -264,6 +273,26 @@ impl AuthApplicationService { self.require_verified_email } + /// True iff the login SPA should auto-redirect to the OIDC + /// authorize endpoint on page load (SSO-only, no click needed). + /// + /// Composed to be BOTH policy-set AND effectively-standalone: + /// * `AutoRedirectIfStandaloneOidc` policy is in the vector, AND + /// * OIDC is enabled AND is the only WORKING login method + /// (password + magic-link both refused by the composition of + /// the allowlist + OIDC-master rule). + /// + /// When the policy is set but other methods are also live, this is + /// a silent no-op — the FE renders the multi-method chooser. If + /// the policy is NOT set, this is always false regardless. + pub fn auto_redirect_to_oidc(&self) -> bool { + self.auth_policies + .contains(&AuthPolicy::AutoRedirectIfStandaloneOidc) + && self.oidc_enabled() + && !self.is_password_login_allowed() + && !self.is_magic_link_login_allowed() + } + /// Resolve a login-identifier (username OR email) to the account's /// registered email address. Mirrors the `POST /api/auth/login` /// dispatcher (`@` presence → email lookup, else → username diff --git a/src/common/config.rs b/src/common/config.rs index 723bebd4..dd4b2e6c 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -1493,6 +1493,24 @@ pub enum AuthPolicy { /// Deprecated legacy alias: `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true` /// adds this variant to the vector with a startup warning. PermitMagicLinkForPasswordUsers, + + /// When OIDC is the ONLY auth method available (standalone SSO + /// posture — no password + no magic-link), instruct the login SPA + /// to auto-redirect to the OIDC authorize endpoint on page load + /// instead of showing a click-to-continue button. + /// + /// Opt-in because: + /// + /// - Auto-redirect can create loops on IdP failure (login → IdP + /// error → back to login → auto-redirect again). + /// - Logout followed by "visit login page" would bounce the user + /// right back into the app they just logged out of. + /// + /// Only takes effect when the effective allowlist is `[Oidc]` + /// (or magic-link is off via the OIDC-master rule and password is + /// disabled): if any other method is live the policy is a silent + /// no-op (there's a choice to render, not a single path). + AutoRedirectIfStandaloneOidc, } impl AuthPolicy { @@ -1504,6 +1522,9 @@ impl AuthPolicy { "permit_magic_link_for_password_users" | "permit-magic-link-for-password-users" => { Some(Self::PermitMagicLinkForPasswordUsers) } + "auto_redirect_if_standalone_oidc" | "auto-redirect-if-standalone-oidc" => { + Some(Self::AutoRedirectIfStandaloneOidc) + } _ => None, } } diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index b2ff7f60..d8c64c42 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -57,6 +57,7 @@ pub async fn create_auth_services( // rather than reaching into the app config on every call. auth_app_service = auth_app_service.with_auth_policy( config.auth.allowed_auth_methods.clone(), + config.auth.auth_policies.clone(), config.auth.require_verified_email, ); diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index f47ecdf4..b29a4e9d 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -1062,6 +1062,7 @@ pub async fn oidc_providers( let password_login_enabled = auth_app.is_password_login_allowed(); let magic_link_login_enabled = auth_app.is_magic_link_login_allowed(); let require_verified_email = auth_app.require_verified_email(); + let auto_redirect_to_oidc = auth_app.auto_redirect_to_oidc(); if !auth_app.oidc_enabled() { return Ok(Json(OidcProviderInfoDto { @@ -1071,6 +1072,7 @@ pub async fn oidc_providers( password_login_enabled, magic_link_login_enabled, require_verified_email, + auto_redirect_to_oidc: false, })); } @@ -1083,6 +1085,7 @@ pub async fn oidc_providers( password_login_enabled, magic_link_login_enabled, require_verified_email, + auto_redirect_to_oidc, })) } diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index 2239a1a4..e37a47b5 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -1,7 +1,10 @@ use crate::common::config::AppConfig; use crate::common::di::AppState; use axum::Router; +use axum::extract::{Request, State}; use axum::http::header::{CACHE_CONTROL, HeaderValue}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::get_service; use base64::Engine as _; use sha2::{Digest, Sha256}; @@ -41,7 +44,7 @@ pub fn resolve_static_path(config: &AppConfig) -> PathBuf { /// Caching: content-hashed assets under `/_app/immutable` are cached forever; /// everything else — crucially the `index.html` shell — is `no-cache` so a deploy /// can't leave a stale app pinned in browsers. -pub fn create_web_routes() -> Router> { +pub fn create_web_routes(app_state: Arc) -> Router> { let config = AppConfig::from_env(); let static_path = resolve_static_path(&config); @@ -91,6 +94,52 @@ pub fn create_web_routes() -> Router> { CACHE_CONTROL, HeaderValue::from_static("no-cache"), )) + // Short-circuit `GET /login` to the OIDC authorize endpoint when + // the AutoRedirectIfStandaloneOidc policy resolves. Runs BEFORE + // the SPA shell is served, so there's no form-then-redirect flash. + // The SPA carries the same predicate as belt-and-suspenders for + // deep links / browser-cache hits that skip this hop. + .layer(axum::middleware::from_fn_with_state( + app_state, + oidc_standalone_login_redirect, + )) +} + +/// Intercept `GET /login` and 302 to `/api/auth/oidc/authorize` when OIDC is +/// the only working method (see `AuthApplicationService::auto_redirect_to_oidc`). +/// +/// Loop-guards mirror the SPA: +/// - `?error=…` — the IdP bounced us back; falling through lets the SPA render +/// the error rather than looping straight back to the failing IdP. +/// - `?oidc_code=…` — the callback landing carries the exchange code; the SPA +/// must handle it, not another authorize round-trip. +async fn oidc_standalone_login_redirect( + State(state): State>, + req: Request, + next: Next, +) -> Response { + if req.method() == axum::http::Method::GET && req.uri().path() == "/login" { + let has_loop_guard_param = req + .uri() + .query() + .map(|q| { + q.split('&') + .any(|p| p.starts_with("error=") || p.starts_with("oidc_code=")) + }) + .unwrap_or(false); + + let should_redirect = !has_loop_guard_param + && state + .auth_service + .as_ref() + .map(|svc| svc.auth_application_service.auto_redirect_to_oidc()) + .unwrap_or(false); + + if should_redirect { + return Redirect::temporary("/api/auth/oidc/authorize").into_response(); + } + } + next.run(req).await } /// Build the `content-security-policy` header value served on every response. diff --git a/src/main.rs b/src/main.rs index 33e667b4..11acf93e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -628,7 +628,7 @@ async fn run() -> Result<(), Box> { let api_routes = create_api_routes(&app_state); let public_api_routes = create_public_api_routes(&app_state); let health_routes = create_health_routes(&app_state); - let web_routes = create_web_routes(); + let web_routes = create_web_routes(app_state.clone()); let mut app; From 166b8c4891d5dc413fb9190c67195fd610a53651 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 01:17:16 +0200 Subject: [PATCH 3/9] feat(oidc): RP initiator logout request token invalidation to IdP (OIDC) on logout --- docs/config/authentication.md | 14 +++++- frontend/src/lib/api/endpoints/auth.ts | 23 +++++++++- frontend/src/lib/components/AppShell.svelte | 17 ++++++- .../src/lib/components/CommandPalette.svelte | 9 +++- frontend/src/routes/login/page.test.ts | 13 +++++- .../20261001000000_sessions_oidc_id_token.sql | 18 ++++++++ src/application/ports/auth_ports.rs | 16 +++++++ .../services/auth_application_service.rs | 46 +++++++++++++++++-- src/domain/entities/session.rs | 19 ++++++++ .../repositories/pg/session_pg_repository.rs | 24 +++++++--- src/infrastructure/services/oidc_service.rs | 26 +++++++++++ src/interfaces/api/handlers/auth_handler.rs | 15 ++++-- 12 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 migrations/20261001000000_sessions_oidc_id_token.sql diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 0fa625df..2622d8d2 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -122,10 +122,22 @@ The verification-piggyback flow above deliberately **bypasses the `has_password` | Token | Effect | | --- | --- | | `permit_magic_link_for_password_users` | Allow magic-link login for accounts that also have a password. OIDC-linked users are still refused. | -| `auto_redirect_if_standalone_oidc` | When OIDC is the ONLY working login method (no password, no magic-link — via allowlist or the OIDC-master rule), the login SPA auto-redirects to the IdP on page load instead of showing a click-to-continue SSO button. Off by default to avoid redirect loops on IdP failure and to preserve logout UX (logging out then visiting `/login` would otherwise bounce the user right back in). Silent no-op when other methods are also live. Frontend reads this via `auto_redirect_to_oidc` on `GET /api/auth/oidc/providers`. | +| `auto_redirect_if_standalone_oidc` | When OIDC is the ONLY working login method (no password, no magic-link — via allowlist or the OIDC-master rule), `GET /login` returns a **server-side 302** to `/api/auth/oidc/authorize` before the SPA loads (no click-to-continue button, no flash). Off by default to avoid redirect loops on IdP failure; the interceptor falls through to the SPA when `?error=…` or `?oidc_code=…` are present. Silent no-op when other methods are also live. Pair with the RP-initiated logout setup below so users on shared computers can actually log out. | Unknown tokens are logged-and-skipped at startup so a typo doesn't silently zero the vector. +## RP-initiated OIDC logout + +When a session was minted through OIDC, `POST /api/auth/logout` returns a JSON body containing `post_logout_url`. The SPA reads this and navigates the browser there via `window.location.replace(url)` — the IdP kills its SSO cookie and redirects the browser back to `/login`. Without this hop the IdP session stays alive: the very next `/login` visit would silently re-authenticate through the still-valid SSO cookie, which under `auto_redirect_if_standalone_oidc` looks like the logout button did nothing (shared-computer scenario). + +Requirements: + +- **IdP discovery must advertise `end_session_endpoint`** (OIDC Session Management 1.0). Keycloak does by default. If your IdP doesn't, `post_logout_url` is omitted and the SPA falls back to a local-only logout; the IdP session ends only when it naturally times out. +- **The OIDC client must register `/login` as a valid post-logout redirect URI.** Keycloak calls this field "Valid post logout redirect URIs" on the client's Settings tab. If it's missing, the IdP shows its own error page after logging out instead of returning the user to OxiCloud. +- Backend uses `AppConfig::base_url()` (i.e. `OXICLOUD_BASE_URL` if set, else derived from `server_host` / `server_port`) to build the redirect URI. Set `OXICLOUD_BASE_URL` when the browser reaches OxiCloud through a URL different from what the server binds locally (reverse proxy, Docker, TLS-terminating LB). + +The `id_token` used as `id_token_hint` is captured at login time from the OIDC token-exchange response and persisted on `auth.sessions.oidc_id_token`. Non-OIDC sessions leave the column NULL and `POST /api/auth/logout` returns `{}` (local-only logout). + ## Example Flows ### Register — classic diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index c4e9cc9f..e78b8db7 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -258,11 +258,30 @@ export async function sendMagicLink(email: string): Promise { return 'sent'; } -export async function logout(): Promise { - await apiFetch('/api/auth/logout', { +export interface LogoutResult { + /** + * RP-initiated OIDC logout URL, present only when the session was minted + * through OIDC AND the IdP advertises an `end_session_endpoint`. The + * caller MUST navigate there via `window.location` (not `goto()`) so the + * browser leaves the SPA and hits the IdP; the IdP kills its SSO cookie + * and redirects back to `/login`. Without this hop the IdP session stays + * alive and the next `/login` visit would silently re-authenticate. + */ + postLogoutUrl?: string; +} + +export async function logout(): Promise { + const res = await apiFetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: '{}' }); + if (!res.ok) return {}; + try { + const body = (await res.json()) as { post_logout_url?: unknown }; + return typeof body?.post_logout_url === 'string' ? { postLogoutUrl: body.post_logout_url } : {}; + } catch { + return {}; + } } diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 5dc8f067..b1d4f788 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -488,11 +488,26 @@ } async function onLogout() { + let postLogoutUrl: string | undefined; try { - await logout(); + ({ postLogoutUrl } = await logout()); } catch { /* clear locally regardless */ } + if (postLogoutUrl) { + // Full-page navigation to the IdP end-session endpoint. Do NOT + // touch local session state first: `session.reset()` fires the + // layout $effect guard which races us with a competing + // `goto('/login?redirect=...')`, and any ambient in-flight + // fetch that 401s trips the sessionExpiredHandler with yet + // another navigation to `/login?source=session_expired`. Two + // or three concurrent navigations cancel each other and the + // browser stalls on the current page. The IdP round-trip lands + // us back on `/login` where the SPA reboots fresh from scratch — + // no local cleanup needed here. + window.location.replace(postLogoutUrl); + return; + } session.reset(); await goto(resolve('/login')); } diff --git a/frontend/src/lib/components/CommandPalette.svelte b/frontend/src/lib/components/CommandPalette.svelte index 2a7bb757..f9ef69fc 100644 --- a/frontend/src/lib/components/CommandPalette.svelte +++ b/frontend/src/lib/components/CommandPalette.svelte @@ -165,11 +165,18 @@ icon: 'sign-out-alt', run: async () => { close(); + let postLogoutUrl: string | undefined; try { - await logout(); + ({ postLogoutUrl } = await logout()); } catch { /* clear locally regardless */ } + if (postLogoutUrl) { + // See AppShell::onLogout — `session.reset()` before this + // races the layout $effect guard and the 401 handler. + window.location.replace(postLogoutUrl); + return; + } session.reset(); await goto(resolve('/login')); } diff --git a/frontend/src/routes/login/page.test.ts b/frontend/src/routes/login/page.test.ts index fa347c00..58c1be35 100644 --- a/frontend/src/routes/login/page.test.ts +++ b/frontend/src/routes/login/page.test.ts @@ -198,14 +198,23 @@ it('renders an SSO sign-in link when an OIDC provider is configured', async () = expect(sso.getAttribute('href')).toBe('https://idp.test/auth'); }); -it('auto-redirects to the IdP when OIDC is the only login method', async () => { +// Auto-redirect on standalone OIDC is enforced server-side via the +// `auto_redirect_if_standalone_oidc` policy (see +// src/interfaces/web/mod.rs::oidc_standalone_login_redirect). The SPA no +// longer contains a client-side copy — a duplicate would override the admin's +// policy choice. We keep this test asserting the *negative* to lock in +// "SPA renders the click-to-continue button, no window.location.replace". +it('does not client-side auto-redirect when OIDC is the only login method', async () => { m(auth.getOidcProviders).mockResolvedValue({ enabled: true, password_login_enabled: false, authorize_endpoint: '/api/auth/oidc/authorize' }); render(LoginPage); - await waitFor(() => expect(replaceSpy).toHaveBeenCalledWith('/api/auth/oidc/authorize')); + // Give onMount time to finish its probes; the SSO button must appear + // and `window.location.replace` must NOT have been called. + await screen.findByTestId('login-oidc-btn'); + expect(replaceSpy).not.toHaveBeenCalled(); }); it('does not auto-redirect when password login is also enabled', async () => { diff --git a/migrations/20261001000000_sessions_oidc_id_token.sql b/migrations/20261001000000_sessions_oidc_id_token.sql new file mode 100644 index 00000000..f0fac53f --- /dev/null +++ b/migrations/20261001000000_sessions_oidc_id_token.sql @@ -0,0 +1,18 @@ +-- Persist the OIDC ID token on the session so it can be used as +-- `id_token_hint` in the RP-initiated logout URL sent back to the FE. +-- +-- Without this, OxiCloud logout only clears the local session; the IdP +-- SSO cookie stays alive and — under the `auto_redirect_if_standalone_oidc` +-- posture — the very next `/login` visit silently re-authenticates the +-- user via the IdP session. Shared-computer scenario: a user can't +-- actually log out. +-- +-- Nullable because the column only applies to OIDC-issued sessions; +-- password / magic-link sessions leave it NULL. Stored as-is (unencrypted) +-- because ID tokens are short-lived JWTs whose PII payload (email, name) +-- is already present in cleartext in auth.users — no new exposure. +ALTER TABLE auth.sessions + ADD COLUMN IF NOT EXISTS oidc_id_token TEXT; + +COMMENT ON COLUMN auth.sessions.oidc_id_token IS + 'ID token from the OIDC login exchange, used as id_token_hint for RP-initiated logout. NULL for non-OIDC sessions.'; diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index c6d3378e..393d75fc 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -287,6 +287,22 @@ pub trait OidcServicePort: Send + Sync + 'static { /// Get the OIDC provider display name fn provider_name(&self) -> &str; + + /// Build an RP-initiated logout URL (OIDC Session Management 1.0). + /// + /// Returns `Ok(None)` when the IdP's discovery document does not advertise + /// an `end_session_endpoint` — some providers don't support RP-initiated + /// logout, in which case the caller falls back to a local-only logout. + /// + /// `id_token_hint` is required by most IdPs (Keycloak in particular + /// rejects the request without it) so the server can identify the session + /// to terminate. `post_logout_redirect_uri` must be one of the URIs + /// registered on the OIDC client, else the IdP refuses the redirect. + async fn build_end_session_url( + &self, + id_token_hint: &str, + post_logout_redirect_uri: &str, + ) -> Result, DomainError>; } pub trait SessionStoragePort: Send + Sync + 'static { diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 3c1611ef..3b667cf8 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1233,7 +1233,27 @@ impl AuthApplicationService { }) } - pub async fn logout(&self, user_id: Uuid, refresh_token: &str) -> Result<(), DomainError> { + /// Revoke the caller's session and, when the session was minted through + /// OIDC, build the RP-initiated logout URL so the browser can also end + /// the IdP's SSO session (fixes shared-computer scenario where local + /// logout alone would let the next `/login` visit silently re-auth + /// through a still-valid IdP cookie). + /// + /// Returns `Ok(None)` for: + /// - non-OIDC sessions (password / magic-link) — nothing to propagate; + /// - OIDC sessions where the IdP's discovery doesn't advertise an + /// `end_session_endpoint` — no way to propagate. Callers should still + /// clear local cookies; the IdP session will time out on its own. + /// + /// `post_logout_redirect_uri` MUST be registered on the OIDC client + /// (Keycloak: "Valid post logout redirect URIs"), else the IdP refuses + /// the redirect back and the user is left on the IdP error page. + pub async fn logout( + &self, + user_id: Uuid, + refresh_token: &str, + post_logout_redirect_uri: &str, + ) -> Result, DomainError> { // Get session let session = match self .session_storage @@ -1242,7 +1262,7 @@ impl AuthApplicationService { { Ok(s) => s, // If the session doesn't exist, we consider the logout successful - Err(_) => return Ok(()), + Err(_) => return Ok(None), }; // Verify that the session belongs to the user @@ -1254,6 +1274,12 @@ impl AuthApplicationService { )); } + // Capture the id_token BEFORE revocation so we can build the + // RP-initiated logout URL. Revocation only flips a boolean, so the + // row (and its oidc_id_token column) survives — this order is + // defensive against a future change that hard-deletes on revoke. + let id_token_hint = session.oidc_id_token().map(str::to_string); + // Revoke session self.session_storage.revoke_session(session.id()).await?; @@ -1266,7 +1292,18 @@ impl AuthApplicationService { lc.dispatch_logout(user, LogoutReason::UserInitiated); } - Ok(()) + // If this was an OIDC session AND the IdP advertises an + // end_session_endpoint, build the RP-initiated logout URL. + // Otherwise return None — the caller clears local state either way. + let Some(id_token) = id_token_hint else { + return Ok(None); + }; + let oidc = { self.oidc.read().unwrap().service.clone() }; + let Some(oidc) = oidc else { + return Ok(None); + }; + oidc.build_end_session_url(&id_token, post_logout_redirect_uri) + .await } pub async fn logout_all(&self, user_id: Uuid) -> Result { @@ -3036,7 +3073,8 @@ impl AuthApplicationService { None, self.token_service.refresh_token_expiry_days(), Uuid::new_v4(), - ); + ) + .with_oidc_id_token(token_set.id_token.clone()); self.session_storage.create_session(session).await?; let auth_response = AuthResponseDto { diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index fefa05d3..0d113d24 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -14,6 +14,10 @@ pub struct Session { /// Groups all tokens issued from the same original login. /// Replaying a revoked token from this family triggers full-family revocation. family_id: Uuid, + /// ID token from the OIDC login exchange. Used as `id_token_hint` on the + /// RP-initiated logout URL so the IdP can terminate its own SSO session. + /// `None` for password / magic-link sessions. + oidc_id_token: Option, } impl Session { @@ -40,9 +44,18 @@ impl Session { created_at: now, revoked: false, family_id, + oidc_id_token: None, } } + /// Attach an OIDC ID token — call on sessions minted via the OIDC exchange. + /// The token is persisted with the session and re-emitted at logout as + /// `id_token_hint` so the IdP can end its own SSO session. + pub fn with_oidc_id_token(mut self, id_token: String) -> Self { + self.oidc_id_token = Some(id_token); + self + } + #[allow(clippy::too_many_arguments)] pub fn from_raw( id: Uuid, @@ -54,6 +67,7 @@ impl Session { created_at: DateTime, revoked: bool, family_id: Uuid, + oidc_id_token: Option, ) -> Self { Self { id, @@ -65,6 +79,7 @@ impl Session { created_at, revoked, family_id, + oidc_id_token, } } @@ -112,4 +127,8 @@ impl Session { pub fn family_id(&self) -> Uuid { self.family_id } + + pub fn oidc_id_token(&self) -> Option<&str> { + self.oidc_id_token.as_deref() + } } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index ed0572e1..b85da13f 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -52,9 +52,10 @@ impl SessionRepository for SessionPgRepository { r#" INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked, family_id + ip_address, user_agent, created_at, revoked, family_id, + oidc_id_token ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) "#, ) @@ -67,6 +68,7 @@ impl SessionRepository for SessionPgRepository { .bind(session_clone.created_at()) .bind(session_clone.is_revoked()) .bind(session_clone.family_id()) + .bind(session_clone.oidc_id_token()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -111,7 +113,8 @@ impl SessionRepository for SessionPgRepository { r#" SELECT id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked, family_id + ip_address, user_agent, created_at, revoked, family_id, + oidc_id_token FROM auth.sessions WHERE id = $1 "#, @@ -131,6 +134,7 @@ impl SessionRepository for SessionPgRepository { row.get("created_at"), row.get("revoked"), row.get("family_id"), + row.get("oidc_id_token"), )) } @@ -144,7 +148,8 @@ impl SessionRepository for SessionPgRepository { r#" SELECT id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked, family_id + ip_address, user_agent, created_at, revoked, family_id, + oidc_id_token FROM auth.sessions WHERE refresh_token = $1 "#, @@ -164,6 +169,7 @@ impl SessionRepository for SessionPgRepository { row.get("created_at"), row.get("revoked"), row.get("family_id"), + row.get("oidc_id_token"), )) } @@ -176,7 +182,8 @@ impl SessionRepository for SessionPgRepository { r#" SELECT id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked, family_id + ip_address, user_agent, created_at, revoked, family_id, + oidc_id_token FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC @@ -200,6 +207,7 @@ impl SessionRepository for SessionPgRepository { row.get("created_at"), row.get("revoked"), row.get("family_id"), + row.get("oidc_id_token"), ) }) .collect(); @@ -348,9 +356,10 @@ impl SessionStoragePort for SessionPgRepository { r#" INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked, family_id + ip_address, user_agent, created_at, revoked, family_id, + oidc_id_token ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) "#, ) @@ -363,6 +372,7 @@ impl SessionStoragePort for SessionPgRepository { .bind(session_clone.created_at()) .bind(session_clone.is_revoked()) .bind(session_clone.family_id()) + .bind(session_clone.oidc_id_token()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; diff --git a/src/infrastructure/services/oidc_service.rs b/src/infrastructure/services/oidc_service.rs index 6a845e74..9ddf361b 100644 --- a/src/infrastructure/services/oidc_service.rs +++ b/src/infrastructure/services/oidc_service.rs @@ -28,6 +28,10 @@ struct OidcDiscovery { token_endpoint: String, userinfo_endpoint: Option, jwks_uri: String, + /// RP-initiated logout endpoint (OIDC Session Management 1.0). + /// Optional — not every IdP advertises it. When missing, callers + /// must fall back to local-only logout. + end_session_endpoint: Option, } // ============================================================================ @@ -533,6 +537,28 @@ impl OidcServicePort for OidcService { fn provider_name(&self) -> &str { &self.config.provider_name } + + async fn build_end_session_url( + &self, + id_token_hint: &str, + post_logout_redirect_uri: &str, + ) -> Result, DomainError> { + let discovery = self.get_discovery().await?; + let Some(endpoint) = discovery.end_session_endpoint else { + return Ok(None); + }; + // client_id is also included: some IdPs (Keycloak in "legacy" mode) + // use it to look up the registered post_logout_redirect_uri when + // the id_token_hint is expired or missing. + let url = format!( + "{}?id_token_hint={}&post_logout_redirect_uri={}&client_id={}", + endpoint, + urlencoding::encode(id_token_hint), + urlencoding::encode(post_logout_redirect_uri), + urlencoding::encode(&self.config.client_id), + ); + Ok(Some(url)) + } } // We need urlencoding — let's use a minimal inline implementation diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index b29a4e9d..f66c8bb2 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -858,13 +858,22 @@ pub async fn logout( AppError::unauthorized("Refresh token required for logout (JSON body or cookie)") })?; - auth_service + // Post-logout redirect URI = OxiCloud's `/login`. Must be registered on + // the OIDC client (Keycloak: "Valid post logout redirect URIs"), else + // the IdP will refuse the redirect and strand the user on its error page. + let post_logout_redirect_uri = format!("{}/login", state.core.config.base_url()); + + let post_logout_url = auth_service .auth_application_service - .logout(user_id, &refresh_token) + .logout(user_id, &refresh_token, &post_logout_redirect_uri) .await?; // Clear HttpOnly + CSRF cookies so the browser forgets the session - let mut response = StatusCode::OK.into_response(); + // regardless of whether we also redirect to the IdP. + let body = post_logout_url + .map(|url| serde_json::json!({ "post_logout_url": url })) + .unwrap_or_else(|| serde_json::json!({})); + let mut response = (StatusCode::OK, axum::Json(body)).into_response(); cookie_auth::append_clear_cookies(response.headers_mut()); cookie_auth::append_clear_csrf_cookie(response.headers_mut()); Ok(response) From acd4420fe36e7e49ce7ea85e177c41438390da1a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 08:00:13 +0200 Subject: [PATCH 4/9] feat(oidc): impl back channel logout --- docs/config/authentication.md | 35 ++++ .../20261001000001_sessions_oidc_sid.sql | 26 +++ src/application/ports/auth_ports.rs | 47 +++++ src/application/ports/user_lifecycle.rs | 5 + .../services/auth_application_service.rs | 120 ++++++++++- src/domain/entities/session.rs | 22 +++ src/domain/repositories/session_repository.rs | 22 +++ .../repositories/pg/session_pg_repository.rs | 121 +++++++++++- src/infrastructure/services/oidc_service.rs | 186 +++++++++++++++++- src/interfaces/api/handlers/auth_handler.rs | 97 +++++++++ src/interfaces/api/mod.rs | 1 + 11 files changed, 673 insertions(+), 9 deletions(-) create mode 100644 migrations/20261001000001_sessions_oidc_sid.sql diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 2622d8d2..05365cb3 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -138,6 +138,41 @@ Requirements: The `id_token` used as `id_token_hint` is captured at login time from the OIDC token-exchange response and persisted on `auth.sessions.oidc_id_token`. Non-OIDC sessions leave the column NULL and `POST /api/auth/logout` returns `{}` (local-only logout). +## OIDC Back-Channel Logout + +Complements RP-initiated logout by letting the **IdP** kick OxiCloud sessions server-to-server, without any browser involvement. Fires when: + +- The user logged out of another RP (single sign-out across your fleet). +- An admin revoked the user's SSO session from the Keycloak admin console. +- The user's account was disabled at the IdP. + +Endpoint: `POST /api/auth/oidc/backchannel-logout`. Public (no auth middleware, no CSRF, no cookies) — the signed `logout_token` JWT IS the authentication. + +**IdP-side setup (Keycloak):** + +1. On the client's Settings tab, set **Backchannel Logout URL** to `/api/auth/oidc/backchannel-logout`. +2. Turn on **Backchannel Logout Session Required**. This makes Keycloak include the `sid` claim on both id_tokens (which OxiCloud persists on `auth.sessions.oidc_sid`) AND on the logout_tokens it sends. With `sid` present, OxiCloud revokes only the specific device that logged out; without it, we fall back to revoking every session belonging to the same OIDC subject (all of the user's OxiCloud devices). +3. Leave **Backchannel Logout Revoke Offline Sessions** off unless you have a reason — OxiCloud uses only online sessions today. + +**What OxiCloud validates on the logout_token** (per OIDC Back-Channel Logout 1.0): + +- Signature via the IdP's JWKS (same key material as id_token validation). +- `iss` matches the discovery document's issuer. +- `aud` contains our `client_id`. +- `events` claim contains the `http://schemas.openid.net/event/backchannel-logout` key. +- `sub` and/or `sid` present (else there's nothing to revoke — 400). +- `nonce` absent (spec §2.4 forbids it — a token with a nonce is either an IdP bug or a replay of an id_token; 400). +- `iat` within a 5-minute freshness window. +- `jti` (if present) deduped for 5 minutes so retransmissions don't cause double-audit. + +Response codes are constrained by the spec: + +- **200** — token validated; 0 or more sessions revoked (both are "handled" from the IdP's view). +- **400** — validation failed. Real reason is logged locally (`event=oidc.backchannel_logout_rejected`) and NOT returned in the body; the IdP just sees `invalid_request`. +- **503** — OIDC is not enabled on this deployment. The IdP shouldn't be calling us in that case. + +**Compared to RP-initiated logout** (the flow triggered by `POST /api/auth/logout`): RP-initiated is browser-driven and evicts the local session + kills the IdP session. Back-channel is IdP-driven and evicts the local session; the IdP's own state is not affected. The two are complementary — enable both. + ## Example Flows ### Register — classic diff --git a/migrations/20261001000001_sessions_oidc_sid.sql b/migrations/20261001000001_sessions_oidc_sid.sql new file mode 100644 index 00000000..05594ed9 --- /dev/null +++ b/migrations/20261001000001_sessions_oidc_sid.sql @@ -0,0 +1,26 @@ +-- Persist the OIDC session identifier (`sid` claim from the id_token) so +-- the Back-Channel Logout endpoint can revoke a specific device without +-- wiping every other OxiCloud session the user has open. +-- +-- OIDC Back-Channel Logout 1.0 requires the logout_token to carry `sub` +-- and/or `sid`. Preferring `sid` (per-session) over `sub` (all sessions) +-- matters when a user is logged in from a laptop AND a phone through the +-- same IdP: logging out on the laptop should not evict the phone. +-- +-- Nullable because: +-- * non-OIDC sessions (password / magic-link) don't have a sid; +-- * OIDC IdPs are free to omit the `sid` claim from id_tokens — Keycloak +-- only emits it when "Backchannel Logout Session Required" is enabled +-- on the client. When it's missing we fall back to sub-based revocation +-- (all sessions for that OIDC subject). +-- +-- Indexed for the O(1) revoke-by-sid lookup path called from the BCL handler. +ALTER TABLE auth.sessions + ADD COLUMN IF NOT EXISTS oidc_sid TEXT; + +CREATE INDEX IF NOT EXISTS idx_sessions_oidc_sid + ON auth.sessions(oidc_sid) + WHERE oidc_sid IS NOT NULL AND NOT revoked; + +COMMENT ON COLUMN auth.sessions.oidc_sid IS + 'OIDC session identifier (sid claim) from the id_token. Used by the backchannel-logout endpoint to revoke a single device.'; diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 393d75fc..2488b392 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -253,6 +253,24 @@ pub struct OidcIdClaims { /// `LocaleRegistry`; ignored on subsequent logins so a later /// UI-driven choice isn't overwritten by the IdP. pub locale: Option, + /// OIDC session identifier. Populated only when the IdP emits `sid` + /// on the id_token (Keycloak: "Backchannel Logout Session Required" + /// on the client). When present, we persist it on the OxiCloud + /// session so Back-Channel Logout can revoke that specific device. + pub sid: Option, +} + +/// OIDC Back-Channel Logout 1.0 identifiers extracted from a validated +/// logout_token. The BCL handler uses these to resolve which OxiCloud +/// session(s) to revoke: `sid` for per-device (preferred), else `sub` for +/// all of the user's sessions. +#[derive(Debug, Clone)] +pub struct OidcLogoutClaims { + pub sub: Option, + pub sid: Option, + /// JWT identifier — used by the app service to prevent replay of the + /// same logout_token within the token's freshness window. + pub jti: Option, } /// Port for OIDC operations — implemented in infrastructure layer @@ -288,6 +306,20 @@ pub trait OidcServicePort: Send + Sync + 'static { /// Get the OIDC provider display name fn provider_name(&self) -> &str; + /// Validate an OIDC Back-Channel Logout 1.0 logout_token. + /// + /// Enforces all mandatory spec checks: JWKS signature, iss+aud match, + /// `events` claim contains the backchannel-logout URI, presence of + /// `sub` and/or `sid`, absence of `nonce`. On any failure returns + /// `AccessDenied` — the handler translates to a 400 per spec. + /// + /// The caller is responsible for jti replay prevention (this validator + /// is stateless). + async fn validate_logout_token( + &self, + logout_token: &str, + ) -> Result; + /// Build an RP-initiated logout URL (OIDC Session Management 1.0). /// /// Returns `Ok(None)` when the IdP's discovery document does not advertise @@ -333,6 +365,21 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// Revokes all sessions in a token family (used when replay of a revoked token is detected) async fn revoke_session_family(&self, family_id: Uuid) -> Result; + + /// OIDC Back-Channel Logout: revoke sessions matching an IdP-supplied + /// `sid` (per-device). Returns the user id(s) of revoked sessions so + /// the caller can dispatch lifecycle hooks. + async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> Result, DomainError>; + + /// OIDC Back-Channel Logout fallback when the IdP didn't supply a `sid`: + /// revoke every session belonging to the user identified by + /// `(oidc_provider, oidc_subject)`. Returns the affected user id, or + /// `None` if we don't know that user. + async fn revoke_user_sessions_by_oidc_subject( + &self, + oidc_provider: &str, + oidc_subject: &str, + ) -> Result, DomainError>; } // ============================================================================ diff --git a/src/application/ports/user_lifecycle.rs b/src/application/ports/user_lifecycle.rs index de92a46d..e6d2e34c 100644 --- a/src/application/ports/user_lifecycle.rs +++ b/src/application/ports/user_lifecycle.rs @@ -123,6 +123,11 @@ pub enum LogoutReason { /// Refresh-token reuse detected by the session-family guard. Entire /// family revoked because the rotation was probably stolen. TokenReused, + /// OIDC Back-Channel Logout — the IdP notified us that a session + /// ended on its side (user logged out on another RP, or admin + /// revoked the SSO session). Session(s) revoked without any user + /// action on OxiCloud itself. + IdpNotification, } /// How aggressively `on_user_deleted` cleanup should run. Today both diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 3b667cf8..1061623a 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -141,6 +141,16 @@ pub struct AuthApplicationService { /// Auto-expires after 60 seconds via moka TTL; max 10 000 entries for DoS protection. pending_oidc_tokens: Cache, completed_oidc_logins: Cache, + /// Back-Channel Logout replay guard — dedupes logout_tokens by their + /// `jti` claim within the token's freshness window (5 min per BCL §2.6). + /// A cooperative IdP will not re-send a logout_token, but the endpoint + /// is public and unauthenticated so a rogue caller could try to; we + /// short-circuit repeats to avoid burning DB writes on duplicates. + /// Note: tokens without a jti bypass this guard — the validator has + /// already enforced signature + freshness + subject-presence, so at + /// worst a legitimate re-notification runs the (idempotent) revoke path + /// a second time and returns "no rows changed". + backchannel_logout_jti_seen: Cache, /// Magic-link token repository — populated when the magic-link feature /// is enabled (PR 8+). `None` means redemption endpoints return 503. magic_link_repo: Option>, @@ -208,6 +218,13 @@ impl AuthApplicationService { .max_capacity(10_000) .time_to_live(Duration::from_secs(120)) .build(), + backchannel_logout_jti_seen: Cache::builder() + .max_capacity(10_000) + // Matches OidcService::validate_logout_token freshness clamp + // (5 min). Any token older than that fails validation before + // reaching the jti check, so no need to remember jtis longer. + .time_to_live(Duration::from_secs(300)) + .build(), magic_link_repo: None, user_flags_cache: moka::future::Cache::builder() .max_capacity(10_000) @@ -1306,6 +1323,99 @@ impl AuthApplicationService { .await } + /// OIDC Back-Channel Logout 1.0 entry point. + /// + /// Called by the public BCL handler with an unvalidated logout_token + /// (as delivered by the IdP over server-to-server HTTP). This method + /// owns the full flow: + /// + /// 1. Validate the token (signature + spec-mandated claims). + /// 2. Reject replays via the `jti` seen-cache (best-effort — tokens + /// without a jti are impossible to dedupe cheaply, so the revoke + /// path stays idempotent as a safety net). + /// 3. Prefer `sid` (per-device revocation) over `sub` (all-device) + /// when both are present — matches the intent of the IdP that + /// chose to include `sid`. + /// 4. Dispatch per-user lifecycle hooks so downstream systems + /// (websocket subscriptions, etc.) can react. + /// + /// Returns the count of session rows actually flipped from + /// `revoked=false` to `revoked=true` — 0 is a fine outcome (already + /// logged out or unknown user; both are indistinguishable from the + /// IdP's viewpoint and both mean "OxiCloud has no live session for + /// that identity"). + pub async fn backchannel_logout(&self, logout_token: &str) -> Result { + let oidc = { + let state = self.oidc.read().unwrap(); + state.service.clone().ok_or_else(|| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + "OIDC service not configured — cannot process backchannel logout", + ) + })? + }; + + let claims = oidc.validate_logout_token(logout_token).await?; + + // Replay guard. Insertion-first-then-check: `get()` + `insert()` + // is racy across concurrent BCL calls with the same jti (both + // could observe absent, both would run the revocation), but the + // revocation is idempotent so at worst we double-audit. If it + // matters more we can move to `entry().or_insert()` semantics. + if let Some(jti) = claims.jti.as_ref() { + if self.backchannel_logout_jti_seen.get(jti).is_some() { + tracing::info!( + target: "audit", + event = "oidc.backchannel_logout_replayed", + jti = %jti, + "👮🏻‍♂️ OIDC backchannel-logout token replayed — ignored" + ); + return Ok(0); + } + self.backchannel_logout_jti_seen.insert(jti.clone(), ()); + } + + let provider_name = oidc.provider_name().to_string(); + + // Resolve which sessions to revoke. + let affected_user_ids: Vec = if let Some(sid) = claims.sid.as_ref() { + self.session_storage + .revoke_sessions_by_oidc_sid(sid) + .await? + } else if let Some(sub) = claims.sub.as_ref() { + self.session_storage + .revoke_user_sessions_by_oidc_subject(&provider_name, sub) + .await? + .into_iter() + .collect() + } else { + // Validator already enforced sub-or-sid presence; being here + // means the validator has drifted. Fail loud. + return Err(DomainError::new( + ErrorKind::InternalError, + "OIDC", + "backchannel_logout: validator returned claims without sub or sid", + )); + }; + + // Dispatch lifecycle hooks per unique affected user. Best-effort; + // hook failures don't undo the revocation (which already committed). + // Deduped because sid-based revocation could theoretically match + // multiple sessions for the same user if the IdP re-issued sids. + if let Some(lc) = &self.user_lifecycle { + let unique: std::collections::HashSet = + affected_user_ids.iter().copied().collect(); + for uid in unique { + if let Ok(user) = self.user_storage.get_user_by_id(uid).await { + lc.dispatch_logout(user, LogoutReason::IdpNotification); + } + } + } + + Ok(affected_user_ids.len() as u64) + } + pub async fn logout_all(&self, user_id: Uuid) -> Result { // Revoke all user sessions let revoked_count = self @@ -3066,7 +3176,7 @@ impl AuthApplicationService { let access_token = self.token_service.generate_access_token(&user)?; let refresh_token = self.token_service.generate_refresh_token(); - let session = Session::new( + let mut session = Session::new( user.id(), refresh_token.clone(), None, @@ -3075,6 +3185,14 @@ impl AuthApplicationService { Uuid::new_v4(), ) .with_oidc_id_token(token_set.id_token.clone()); + // Bind the IdP's session identifier so Back-Channel Logout can + // revoke this specific device (see auth_ports::OidcLogoutClaims + // and session_pg_repository::revoke_sessions_by_oidc_sid). IdPs + // that don't emit sid leave this None; BCL then falls back to + // sub-based revocation. + if let Some(sid) = claims.sid.as_ref() { + session = session.with_oidc_sid(sid.clone()); + } self.session_storage.create_session(session).await?; let auth_response = AuthResponseDto { diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index 0d113d24..f0ce4bfb 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -18,6 +18,11 @@ pub struct Session { /// RP-initiated logout URL so the IdP can terminate its own SSO session. /// `None` for password / magic-link sessions. oidc_id_token: Option, + /// OIDC session identifier (sid claim). Populated only when the IdP + /// emits it. Enables per-device Back-Channel Logout — without it, a + /// BCL notification would revoke all of the user's sessions rather + /// than just the one that logged out on the far end. + oidc_sid: Option, } impl Session { @@ -45,6 +50,7 @@ impl Session { revoked: false, family_id, oidc_id_token: None, + oidc_sid: None, } } @@ -56,6 +62,16 @@ impl Session { self } + /// Attach the OIDC session identifier from the id_token's `sid` claim. + /// Optional even for OIDC sessions — only present when the IdP emits + /// sid (Keycloak requires "Backchannel Logout Session Required" on the + /// client). Without it, Back-Channel Logout falls back to sub-based + /// revocation which is coarser (all of the user's OxiCloud sessions). + pub fn with_oidc_sid(mut self, sid: String) -> Self { + self.oidc_sid = Some(sid); + self + } + #[allow(clippy::too_many_arguments)] pub fn from_raw( id: Uuid, @@ -68,6 +84,7 @@ impl Session { revoked: bool, family_id: Uuid, oidc_id_token: Option, + oidc_sid: Option, ) -> Self { Self { id, @@ -80,6 +97,7 @@ impl Session { revoked, family_id, oidc_id_token, + oidc_sid, } } @@ -131,4 +149,8 @@ impl Session { pub fn oidc_id_token(&self) -> Option<&str> { self.oidc_id_token.as_deref() } + + pub fn oidc_sid(&self) -> Option<&str> { + self.oidc_sid.as_deref() + } } diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index 4ca17c84..8041e79b 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -55,6 +55,28 @@ pub trait SessionRepository: Send + Sync + 'static { /// Revokes all sessions in a token family (theft response) async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult; + /// Revokes every OxiCloud session whose OIDC sid claim matches. + /// + /// Used by the Back-Channel Logout handler when the IdP sends a + /// logout_token with a `sid` — this is the per-device path and + /// matches (in the typical case) exactly one session row. Returns + /// user IDs of every affected session so the caller can dispatch + /// per-user lifecycle hooks. + async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> SessionRepositoryResult>; + + /// Revokes every session belonging to the user identified by + /// `(oidc_provider, oidc_subject)`. + /// + /// Fallback path for the Back-Channel Logout handler when the IdP + /// omits `sid` from the logout_token — coarser than sid-based + /// revocation (kills the user's other devices too). Returns the + /// user id of the affected account, or `None` if no matching user. + async fn revoke_user_sessions_by_oidc_subject( + &self, + oidc_provider: &str, + oidc_subject: &str, + ) -> SessionRepositoryResult>; + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult; } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index b85da13f..2535dd94 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -53,9 +53,9 @@ impl SessionRepository for SessionPgRepository { INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) "#, ) @@ -69,6 +69,7 @@ impl SessionRepository for SessionPgRepository { .bind(session_clone.is_revoked()) .bind(session_clone.family_id()) .bind(session_clone.oidc_id_token()) + .bind(session_clone.oidc_sid()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -114,7 +115,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid FROM auth.sessions WHERE id = $1 "#, @@ -135,6 +136,7 @@ impl SessionRepository for SessionPgRepository { row.get("revoked"), row.get("family_id"), row.get("oidc_id_token"), + row.get("oidc_sid"), )) } @@ -149,7 +151,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid FROM auth.sessions WHERE refresh_token = $1 "#, @@ -170,6 +172,7 @@ impl SessionRepository for SessionPgRepository { row.get("revoked"), row.get("family_id"), row.get("oidc_id_token"), + row.get("oidc_sid"), )) } @@ -183,7 +186,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC @@ -208,6 +211,7 @@ impl SessionRepository for SessionPgRepository { row.get("revoked"), row.get("family_id"), row.get("oidc_id_token"), + row.get("oidc_sid"), ) }) .collect(); @@ -308,6 +312,92 @@ impl SessionRepository for SessionPgRepository { Ok(affected) } + /// Back-Channel Logout — revoke sessions matched by the IdP-supplied + /// `sid`. Filters `NOT revoked` so double-notifications are idempotent + /// (returning empty second time). Only session rows with a non-null + /// oidc_sid ever match, so this is safe against sid values happening + /// to collide with anything else. + async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> SessionRepositoryResult> { + let rows = sqlx::query( + r#" + UPDATE auth.sessions + SET revoked = true + WHERE oidc_sid = $1 AND NOT revoked + RETURNING user_id + "#, + ) + .bind(sid) + .fetch_all(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let user_ids: Vec = rows.iter().map(|r| r.get("user_id")).collect(); + if !user_ids.is_empty() { + tracing::info!( + target: "audit", + event = "oidc.backchannel_logout_by_sid", + sid = %sid, + revoked_count = user_ids.len(), + "👮🏻‍♂️ OIDC backchannel-logout revoked sessions by sid" + ); + } + Ok(user_ids) + } + + /// Back-Channel Logout fallback — the IdP omitted `sid` in the + /// logout_token, so we revoke every session belonging to the user + /// identified by (oidc_provider, oidc_subject). Users are looked up + /// through the existing auth.users columns. + async fn revoke_user_sessions_by_oidc_subject( + &self, + oidc_provider: &str, + oidc_subject: &str, + ) -> SessionRepositoryResult> { + // Two-step: look up the user first (deterministic error class if + // the user is unknown), then revoke. Combining into a single + // UPDATE-FROM would work but the audit log wants the user_id + // separately from the revocation count. + let user_row = sqlx::query( + r#" + SELECT id FROM auth.users + WHERE oidc_provider = $1 AND oidc_subject = $2 + "#, + ) + .bind(oidc_provider) + .bind(oidc_subject) + .fetch_optional(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let Some(row) = user_row else { + return Ok(None); + }; + let user_id: Uuid = row.get("id"); + + let result = sqlx::query( + r#" + UPDATE auth.sessions + SET revoked = true + WHERE user_id = $1 AND NOT revoked + "#, + ) + .bind(user_id) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + tracing::info!( + target: "audit", + event = "oidc.backchannel_logout_by_sub", + oidc_provider = %oidc_provider, + oidc_subject = %oidc_subject, + user_id = %user_id, + revoked_count = result.rows_affected(), + "👮🏻‍♂️ OIDC backchannel-logout revoked all user sessions by sub" + ); + Ok(Some(user_id)) + } + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult { let now = Utc::now(); @@ -357,9 +447,9 @@ impl SessionStoragePort for SessionPgRepository { INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) "#, ) @@ -373,6 +463,7 @@ impl SessionStoragePort for SessionPgRepository { .bind(session_clone.is_revoked()) .bind(session_clone.family_id()) .bind(session_clone.oidc_id_token()) + .bind(session_clone.oidc_sid()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -434,4 +525,20 @@ impl SessionStoragePort for SessionPgRepository { .await .map_err(DomainError::from) } + + async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> Result, DomainError> { + SessionRepository::revoke_sessions_by_oidc_sid(self, sid) + .await + .map_err(DomainError::from) + } + + async fn revoke_user_sessions_by_oidc_subject( + &self, + oidc_provider: &str, + oidc_subject: &str, + ) -> Result, DomainError> { + SessionRepository::revoke_user_sessions_by_oidc_subject(self, oidc_provider, oidc_subject) + .await + .map_err(DomainError::from) + } } diff --git a/src/infrastructure/services/oidc_service.rs b/src/infrastructure/services/oidc_service.rs index 9ddf361b..f6e2da23 100644 --- a/src/infrastructure/services/oidc_service.rs +++ b/src/infrastructure/services/oidc_service.rs @@ -9,7 +9,9 @@ use serde::Deserialize; use std::time::{Duration, Instant}; use tokio::sync::RwLock; -use crate::application::ports::auth_ports::{OidcIdClaims, OidcServicePort, OidcTokenSet}; +use crate::application::ports::auth_ports::{ + OidcIdClaims, OidcLogoutClaims, OidcServicePort, OidcTokenSet, +}; use crate::common::config::OidcConfig; use crate::common::errors::{DomainError, ErrorKind}; @@ -75,6 +77,10 @@ struct IdTokenClaims { nonce: Option, picture: Option, locale: Option, + /// OIDC session identifier — only set by IdPs configured to emit it + /// (Keycloak: "Backchannel Logout Session Required"). When present, + /// bind it to the OxiCloud session so BCL can revoke just that device. + sid: Option, // Standard JWT fields #[allow(dead_code)] iss: Option, @@ -86,6 +92,28 @@ struct IdTokenClaims { iat: Option, } +/// OIDC Back-Channel Logout 1.0, §2.4 — the logout_token JWT. +/// +/// Structural differences from an id_token: +/// - MUST have `sub` OR `sid` (or both). +/// - MUST have `events` claim containing the backchannel-logout URI. +/// - MUST NOT have `nonce`. +/// - `exp` is optional (unlike id_token where it's required); a missing +/// exp is fine, we clamp with our own iat-based freshness check. +#[derive(Debug, Deserialize)] +struct LogoutTokenClaims { + iss: String, + aud: serde_json::Value, + iat: i64, + jti: Option, + sub: Option, + sid: Option, + events: serde_json::Value, + nonce: Option, +} + +const BACKCHANNEL_LOGOUT_EVENT: &str = "http://schemas.openid.net/event/backchannel-logout"; + // ============================================================================ // UserInfo response // ============================================================================ @@ -476,6 +504,7 @@ impl OidcServicePort for OidcService { groups: claims.groups.unwrap_or_default(), picture: claims.picture, locale: claims.locale, + sid: claims.sid, }) } @@ -531,6 +560,10 @@ impl OidcServicePort for OidcService { groups: info.groups.unwrap_or_default(), picture: info.picture, locale: info.locale, + // UserInfo endpoint doesn't emit sid — it's an id_token-only + // claim. Callers merging UserInfo into id_token claims must + // preserve the id_token's sid. + sid: None, }) } @@ -559,6 +592,157 @@ impl OidcServicePort for OidcService { ); Ok(Some(url)) } + + async fn validate_logout_token( + &self, + logout_token: &str, + ) -> Result { + let jwks = self.get_jwks().await?; + let discovery = self.get_discovery().await?; + + let kid = Self::extract_jwt_kid(logout_token); + let jwk = Self::find_key(&jwks, kid.as_deref()).ok_or_else(|| { + DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "No suitable key found in JWKS for logout_token validation", + ) + })?; + + let decoding_key = jsonwebtoken::DecodingKey::from_jwk(jwk).map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to create decoding key from JWK: {}", e), + ) + })?; + + let alg = match jwk.common.key_algorithm { + Some(jsonwebtoken::jwk::KeyAlgorithm::RS256) => jsonwebtoken::Algorithm::RS256, + Some(jsonwebtoken::jwk::KeyAlgorithm::RS384) => jsonwebtoken::Algorithm::RS384, + Some(jsonwebtoken::jwk::KeyAlgorithm::RS512) => jsonwebtoken::Algorithm::RS512, + Some(jsonwebtoken::jwk::KeyAlgorithm::ES256) => jsonwebtoken::Algorithm::ES256, + Some(jsonwebtoken::jwk::KeyAlgorithm::ES384) => jsonwebtoken::Algorithm::ES384, + _ => jsonwebtoken::Algorithm::RS256, + }; + + // Spec: iss + aud validated same as id_token. exp is OPTIONAL for + // logout_tokens (unlike id_tokens where it's mandatory), so tell + // jsonwebtoken not to require it; the iat-based freshness clamp + // below enforces our own upper bound. + let mut validation = jsonwebtoken::Validation::new(alg); + validation.set_issuer(&[&discovery.issuer]); + validation.set_audience(&[&self.config.client_id]); + validation.required_spec_claims.remove("exp"); + + let token_data = + jsonwebtoken::decode::(logout_token, &decoding_key, &validation) + .map_err(|e| { + tracing::warn!("OIDC logout_token validation failed: {}", e); + DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + format!("logout_token validation failed: {}", e), + ) + })?; + + let claims = token_data.claims; + + // Spec §2.4: MUST NOT contain a nonce claim (that's an id_token thing). + // If we see one, the IdP is confused or an attacker is replaying an + // id_token as a logout_token; refuse. + if claims.nonce.is_some() { + tracing::warn!( + "OIDC logout_token rejected: nonce claim present (spec §2.4 forbids it)" + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token must not contain nonce", + )); + } + + // Spec §2.4: MUST have `events` claim as a JSON object with a + // property whose name is the backchannel-logout URI. Value is + // typically `{}` — we don't inspect it. + let has_event = claims + .events + .as_object() + .map(|o| o.contains_key(BACKCHANNEL_LOGOUT_EVENT)) + .unwrap_or(false); + if !has_event { + tracing::warn!( + "OIDC logout_token rejected: missing events.'{}'", + BACKCHANNEL_LOGOUT_EVENT + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token missing required backchannel-logout event", + )); + } + + // Spec §2.4: MUST contain `sub` and/or `sid`. Without one, we have + // nothing to key the revocation on. + if claims.sub.is_none() && claims.sid.is_none() { + tracing::warn!("OIDC logout_token rejected: neither sub nor sid present"); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token must contain sub or sid", + )); + } + + // Freshness clamp — iat within the last 5 minutes. Prevents + // rogue replay of an old logout_token. Not spec-mandated but + // recommended (BCL §2.6). + let now = chrono::Utc::now().timestamp(); + const MAX_AGE_SECS: i64 = 300; + if (now - claims.iat).abs() > MAX_AGE_SECS { + tracing::warn!( + "OIDC logout_token rejected: iat too old (age={}s, max={}s)", + now - claims.iat, + MAX_AGE_SECS + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token iat outside freshness window", + )); + } + + // Belt-and-suspenders — the jsonwebtoken decode already enforced + // iss+aud, but log if we get here somehow. Actively used only if + // future changes to Validation config regress the check. + if claims.iss != discovery.issuer { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token iss mismatch", + )); + } + // aud may be string or array — accept either shape carrying our client_id. + let aud_ok = match &claims.aud { + serde_json::Value::String(s) => s == &self.config.client_id, + serde_json::Value::Array(a) => a + .iter() + .any(|v| v.as_str() == Some(self.config.client_id.as_str())), + _ => false, + }; + if !aud_ok { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token aud mismatch", + )); + } + + Ok(OidcLogoutClaims { + sub: claims.sub, + sid: claims.sid, + jti: claims.jti, + }) + } } // We need urlencoding — let's use a minimal inline implementation diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index f66c8bb2..817f2073 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -32,6 +32,9 @@ pub fn auth_public_routes() -> Router> { .route("/oidc/authorize", get(oidc_authorize)) .route("/oidc/callback", get(oidc_callback)) .route("/oidc/exchange", post(oidc_exchange)) + // OIDC Back-Channel Logout 1.0 — public (server-to-server call + // from the IdP with a signed logout_token; no cookies, no CSRF). + .route("/oidc/backchannel-logout", post(oidc_backchannel_logout)) // Login-via-email — sends a magic-link to the user's email so // accounts with no other login credential can sign in. .route("/magic-link/send", post(send_magic_link)) @@ -879,6 +882,100 @@ pub async fn logout( Ok(response) } +/// OIDC Back-Channel Logout 1.0 receiver. +/// +/// The IdP POSTs a signed `logout_token` JWT here when a user's SSO +/// session ends (they logged out elsewhere, admin revoked the session, +/// account was disabled). Body is `application/x-www-form-urlencoded` +/// per spec §2.5 with a single `logout_token` field. +/// +/// Response codes are constrained by the spec (§2.8): +/// - 200 on successful processing (including a validated token that +/// matched no OxiCloud sessions — the notification is still "handled"). +/// - 400 on any validation failure (bad signature, expired, missing +/// required claims, replay). We do NOT return 200 with an error body. +/// +/// This endpoint is public: no auth middleware, no CSRF, no cookie. +/// The `logout_token` signature IS the authentication — an unsigned or +/// wrongly-signed token gets rejected by the OIDC service validator. +#[utoipa::path( + post, + path = "/api/auth/oidc/backchannel-logout", + request_body( + content_type = "application/x-www-form-urlencoded", + description = "Form-encoded body with a single `logout_token` field (the signed JWT from the IdP)", + ), + responses( + (status = 200, description = "Logout notification accepted (0 or more sessions revoked)"), + (status = 400, description = "logout_token missing, malformed, or failed validation"), + (status = 503, description = "OIDC not configured on this deployment"), + ), + tag = "auth" +)] +pub async fn oidc_backchannel_logout( + State(state): State>, + axum::Form(form): axum::Form, +) -> Response { + let auth_service = match state.auth_service.as_ref() { + Some(s) => s, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + axum::Json(serde_json::json!({ + "error": "authentication_not_configured", + "error_description": "OIDC is not enabled on this deployment" + })), + ) + .into_response(); + } + }; + + match auth_service + .auth_application_service + .backchannel_logout(&form.logout_token) + .await + { + Ok(revoked) => { + tracing::info!( + target: "audit", + event = "oidc.backchannel_logout_accepted", + revoked_count = revoked, + "👮🏻‍♂️ OIDC backchannel-logout accepted" + ); + // Spec §2.8: response body has no defined content. Empty JSON + // object keeps content-type coherent and is cheap for the IdP + // to skip. + (StatusCode::OK, axum::Json(serde_json::json!({}))).into_response() + } + Err(e) => { + // Log the real reason for operators; return a spec-compliant + // 400 with a minimal error payload (spec §2.8 recommends + // `application/json` with `error` + `error_description` per + // OAuth 2.0 error style). + tracing::warn!( + target: "audit", + event = "oidc.backchannel_logout_rejected", + reason = %e, + "👮🏻‍♂️ OIDC backchannel-logout rejected" + ); + ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "logout_token validation failed" + })), + ) + .into_response() + } + } +} + +/// Form body carried by OIDC Back-Channel Logout notifications. +#[derive(Debug, serde::Deserialize)] +pub struct BackchannelLogoutForm { + pub logout_token: String, +} + /// One-time endpoint to create the first admin user. /// /// Available only when the system is not yet initialized (no admin exists). diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 25f8918b..7f3b7108 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -79,6 +79,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::auth_handler::oidc_authorize, handlers::auth_handler::oidc_callback, handlers::auth_handler::oidc_exchange, + handlers::auth_handler::oidc_backchannel_logout, // File handlers (free functions — see file_handler.rs for why) handlers::file_handler::list_files_query, handlers::file_handler::upload_file_with_thumbnails, From 921cbef152a6a247eab28fbad76e5452b6d3f6bc Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 21:14:54 +0200 Subject: [PATCH 5/9] test(oidc): test back-channel logout --- justfile | 2 - tests/oidc/fake_idp/server.js | 128 ++++++++++++++++++++++++++++- tests/oidc/oidc.hurl | 150 ++++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 4 deletions(-) diff --git a/justfile b/justfile index 91e89dd6..77d70641 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,3 @@ -set dotenv-load - default: @just --list diff --git a/tests/oidc/fake_idp/server.js b/tests/oidc/fake_idp/server.js index a7c0ea27..5efb2567 100644 --- a/tests/oidc/fake_idp/server.js +++ b/tests/oidc/fake_idp/server.js @@ -27,6 +27,12 @@ import http from 'node:http'; import { URL } from 'node:url'; import { default as Provider } from 'oidc-provider'; +// `jose` ships as a transitive dep of oidc-provider (it's what the +// library uses internally for JWTs). We reuse it to (a) generate the +// signing keypair at boot so oidc-provider signs id_tokens with keys +// we also own, and (b) mint spec-compliant logout_token JWTs in the +// /control/backchannel-logout endpoint below. +import { SignJWT, exportJWK, generateKeyPair } from 'jose'; // ── Configuration knobs ───────────────────────────────────────────────── const ISSUER = process.env.FAKE_IDP_ISSUER || 'http://localhost:1080'; @@ -53,6 +59,14 @@ const TEST_USER_PICTURE = 'https://example.com/oidc-test-user.png'; // app roles. const TEST_USER_GROUPS = ['admin-users']; +// OxiCloud's base URL — derived from the callback URI so run.sh only +// has one place (test.env) to change the port. Used by the BCL control +// endpoint to POST logout_tokens back to OxiCloud. +const OXICLOUD_BASE_URL = + process.env.OXICLOUD_BASE_URL_FOR_BCL || 'http://localhost:8087'; +const BCL_KID = 'fake-idp-key-1'; +const BCL_EVENT = 'http://schemas.openid.net/event/backchannel-logout'; + // ── Runtime-toggleable state for negative tests ──────────────────────── // `email_verified` is normally true; the test flips it to false via // `POST /control/email-verified/false` to drive OxiCloud's anti-takeover @@ -62,6 +76,21 @@ const TEST_USER_GROUPS = ['admin-users']; // claims() callback. let emailVerifiedState = true; +// Pre-generate the signing keypair. oidc-provider v9 accepts private +// JWKs via configuration.jwks and exports the public halves at +// /jwks.json; keeping our own reference to the private key means we +// can also mint valid logout_token JWTs from the /control endpoint, +// so OxiCloud's back-channel-logout validator (which fetches the same +// JWKS) accepts them. +const { publicKey: bclPublicKey, privateKey: bclPrivateKey } = + await generateKeyPair('RS256', { extractable: true }); +const bclPrivateJwk = await exportJWK(bclPrivateKey); +bclPrivateJwk.use = 'sig'; +bclPrivateJwk.alg = 'RS256'; +bclPrivateJwk.kid = BCL_KID; +// eslint-disable-next-line no-unused-vars +const _bclPublicKeyRef = bclPublicKey; // kept for symmetry / debugging + const configuration = { clients: [ { @@ -76,9 +105,25 @@ const configuration = { grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_post', + // Back-Channel Logout 1.0 wire-up. The URI is where OxiCloud's + // handler lives (POST /api/auth/oidc/backchannel-logout). With + // session_required = true, the OP MUST include `sid` in both the + // id_token AND the logout_token — mirrors Keycloak's "Backchannel + // Logout Session Required" client toggle. OxiCloud persists the + // id_token sid on auth.sessions.oidc_sid so per-device revocation + // works; without session_required we'd fall back to sub-based + // (all-device) revocation. + backchannel_logout_uri: `${OXICLOUD_BASE_URL}/api/auth/oidc/backchannel-logout`, + backchannel_logout_session_required: true, }, ], + // Register the private JWK we generated above. The library uses it + // to sign id_tokens; the public half is served at /jwks.json and is + // what OxiCloud's OidcService caches for id_token AND logout_token + // signature verification (they share the same JWKS per BCL 1.0). + jwks: { keys: [bclPrivateJwk] }, + pkce: { required: () => true, methods: ['S256'] }, claims: { @@ -125,6 +170,17 @@ const configuration = { features: { // Turn off the dev login/consent UI; we own the interaction route. devInteractions: { enabled: false }, + // OIDC Back-Channel Logout 1.0. Turning it on makes the OP + // advertise `backchannel_logout_supported` in discovery and + // emit `sid` in id_tokens when the client has + // `backchannel_logout_session_required: true`. We do NOT rely on + // oidc-provider to send BCL notifications from its internal + // session-destroy path (which would require driving OP session + // lifecycle from the test); the /control/backchannel-logout + // endpoint below mints a spec-compliant logout_token directly + // 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 }, }, // Put scope-implied claims (name, given_name, family_name, @@ -164,7 +220,7 @@ const oidcHandler = provider.callback(); // to exercise OxiCloud's anti-takeover rejection branch). Kept on the // SAME port as the OIDC endpoints so we don't have to thread two ports // through every test config. Never used in production-shaped flows. -function handleControl(req, res) { +async function handleControl(req, res) { const url = new URL(req.url, ISSUER); if (req.method === 'POST' && url.pathname === '/control/email-verified/true') { emailVerifiedState = true; @@ -178,6 +234,74 @@ function handleControl(req, res) { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ email_verified: false })); } + if (req.method === 'POST' && url.pathname === '/control/backchannel-logout') { + // Body shape: `{ sub?: string, sid?: string }`. Optional so the test + // can exercise both revocation modes: + // * sub only → OxiCloud falls back to revoke-by-subject (kills all + // the user's sessions). + // * sid present → OxiCloud revokes just the session bound to that + // sid (per-device path — the "typical" mode when + // backchannel_logout_session_required is on). + // Default to sub-only against the built-in test user when neither is + // supplied — that keeps the simplest scenario a one-liner in Hurl. + let body = ''; + for await (const chunk of req) body += chunk; + let parsed = {}; + try { + parsed = body ? JSON.parse(body) : {}; + } catch { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + return res.end(JSON.stringify({ error: 'invalid_json' })); + } + const sub = parsed.sub ?? TEST_USER_SUB; + const sid = parsed.sid; // may be undefined + const now = Math.floor(Date.now() / 1000); + + // Mint the logout_token per BCL 1.0 §2.4: + // * `events` MUST contain the backchannel-logout URI as a key. + // * `sub` and/or `sid` MUST be present (we always include sub; + // sid conditional). + // * `nonce` MUST NOT be present (SignJWT does not add one by default). + // * `iat` present, `jti` present for replay-guard testing. + const payload = { events: { [BCL_EVENT]: {} } }; + if (sub) payload.sub = sub; + if (sid) payload.sid = sid; + + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: 'RS256', kid: BCL_KID, typ: 'JWT' }) + .setIssuer(ISSUER) + .setAudience('oxicloud-test') + .setIssuedAt(now) + .setJti(`bcl-${now}-${Math.random().toString(36).slice(2, 10)}`) + .sign(bclPrivateKey); + + // POST as application/x-www-form-urlencoded per BCL §2.5. + const target = `${OXICLOUD_BASE_URL}/api/auth/oidc/backchannel-logout`; + try { + const resp = await fetch(target, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ logout_token: jwt }).toString(), + }); + const respBody = await resp.text(); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + return res.end( + JSON.stringify({ + forwarded_to: target, + oxicloud_status: resp.status, + oxicloud_body: respBody, + }), + ); + } catch (e) { + res.statusCode = 502; + res.setHeader('content-type', 'application/json'); + return res.end( + JSON.stringify({ error: 'forward_failed', detail: String(e) }), + ); + } + } res.statusCode = 404; res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ error: 'no such control endpoint' })); @@ -193,7 +317,7 @@ function handleControl(req, res) { const server = http.createServer(async (req, res) => { // eslint-disable-next-line no-console console.log(`[fake-idp] ${req.method} ${req.url}`); - if (req.url.startsWith('/control/')) return handleControl(req, res); + if (req.url.startsWith('/control/')) return await handleControl(req, res); try { const url = new URL(req.url, ISSUER); diff --git a/tests/oidc/oidc.hurl b/tests/oidc/oidc.hurl index 2b6dfbd2..e0ea771c 100644 --- a/tests/oidc/oidc.hurl +++ b/tests/oidc/oidc.hurl @@ -731,3 +731,153 @@ HTTP 404 # and its runner IS multi-file. See # `feedback_hurl_teardown_shared_db` for the general rule. # ───────────────────────────────────────────────────────────── + + +# ============================================================= +# Steps 13* — OIDC Back-Channel Logout 1.0 +# ============================================================= +# Proves the /api/auth/oidc/backchannel-logout endpoint accepts a +# valid IdP-signed logout_token and evicts the corresponding +# OxiCloud session — the shared-computer / single-sign-out fix +# that RP-initiated logout alone doesn't cover (RPI needs the +# browser; BCL is server-to-server and works even when the user's +# device is offline). +# +# The fake IdP mints and posts the logout_token itself via its +# `/control/backchannel-logout` endpoint (server.js handleControl): +# it signs with the same RS256 keypair whose public half sits at +# /jwks.json, so OxiCloud's validator (identical code path to +# id_token verification) accepts the signature. The Node fetch() +# then POSTs the token as application/x-www-form-urlencoded to +# OxiCloud, matching BCL §2.5. +# +# We deliberately test the sub-only path here (no `sid`) so the +# service exercises revoke_user_sessions_by_oidc_subject (the +# fallback branch used when the IdP doesn't emit `sid`). Sid-based +# per-device revocation shares the same validator + audit shape; +# a unit test in session_pg_repository covers that branch. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 13a — Freshly log in as `oidc_user`. Cookies from Step 9's +# re-login could still be usable, but the Nextcloud flow +# in Steps 12* has interleaved admin login/logout since +# then and the safest thing to prove BCL revoked +# "something live" is to start with a session we JUST +# minted. The [Options] block clears cookies so the +# `Set-Cookie` from the exchange below is what we assert +# on. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: false + +HTTP 307 +[Captures] +bcl_idp_url: header "Location" + + +GET {{bcl_idp_url}} +[Options] +location: true +location-trusted: true + +HTTP 200 +[Captures] +bcl_oidc_code: url regex "oidc_code=([a-f0-9]+)" + + +POST {{base_url}}/api/auth/oidc/exchange +Content-Type: application/json +{ "code": "{{bcl_oidc_code}}" } + +HTTP 200 +[Asserts] +jsonpath "$.user.username" == "oidc_user" + + +# ───────────────────────────────────────────────────────────── +# Step 13b — Confirm the cookie session is live before we knock +# it down. If /me fails here the eviction assertion in +# 13d becomes meaningless (couldn't tell "was live, +# got revoked" from "was never live"). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.username" == "oidc_user" + + +# ───────────────────────────────────────────────────────────── +# Step 13c — IdP-driven logout. The fake IdP's control endpoint +# mints a spec-compliant logout_token (RS256-signed, +# correct iss/aud, events claim, sub, jti, fresh iat) +# and POSTs it to OxiCloud as +# application/x-www-form-urlencoded per BCL §2.5. +# OxiCloud MUST accept it and revoke every session +# belonging to the OIDC subject — a 200 response with +# oxicloud_status=200 in the forwarding echo proves it. +# ───────────────────────────────────────────────────────────── +POST {{oidc_issuer}}/control/backchannel-logout +Content-Type: application/json +{} + +HTTP 200 +[Asserts] +jsonpath "$.oxicloud_status" == 200 + + +# ───────────────────────────────────────────────────────────── +# Step 13d — Refresh MUST fail. This is the load-bearing "BCL +# actually kicked the user" proof. +# +# Note on why we assert on /refresh and NOT /api/auth/me: +# OxiCloud access tokens are stateless JWTs — the auth +# middleware validates signature + expiry in-memory and +# does NOT consult `sessions.revoked` on every request. +# BCL flipped `sessions.revoked=true` (see audit log +# `oidc.backchannel_logout_by_sub` — 3 sessions revoked) +# which kills the refresh path immediately, but the +# still-valid in-memory access token would let /me +# return 200 until its natural expiry (~1 h default). +# That is the standard JWT trade-off: BCL fully evicts +# within one access-token TTL. The refresh 401 below is +# what proves the eviction landed; once the access +# token expires the user can't mint a new one. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +Content-Type: application/json +{} + +# 403 (not 401): the JWT signature validates, but the session is +# revoked — that's an "access denied on a valid credential" outcome. +# The refresh handler maps DomainError::AccessDenied to StatusCode:: +# FORBIDDEN. Also fires TokenReused audit + revokes the whole session +# family, which is the reuse-detection path (correctly identified: a +# call with a revoked refresh token is indistinguishable from theft +# from the server's viewpoint). +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# Step 13f — Replay guard. Firing the exact same logout_token +# twice in the freshness window must be a no-op — +# OxiCloud's app service dedupes by `jti` (see +# auth_application_service::backchannel_logout). The +# IdP still returns 200 for the second call because +# the control endpoint mints a NEW jti each time +# (Math.random() salt), so this is really testing +# "sending the same content twice is safe": second +# call would find no live sessions and revoke 0 rows. +# Either way the assertion is the same: HTTP 200 from +# the control endpoint, oxicloud_status 200. +# ───────────────────────────────────────────────────────────── +POST {{oidc_issuer}}/control/backchannel-logout +Content-Type: application/json +{} + +HTTP 200 +[Asserts] +jsonpath "$.oxicloud_status" == 200 From ac325958467d59276f271627deb4fb501fb6234d Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 21:35:54 +0200 Subject: [PATCH 6/9] test(oidc): test auto_redirect_if_standalone_oidc and RP iniiate logout --- .../server-with-oidc-only-no-policy.env | 64 ++++++ tests/common/server-with-oidc-only.env | 53 +++-- tests/oidc/fake_idp/server.js | 15 ++ tests/oidc/run-sso-only.sh | 195 ++++++++++++++++++ tests/oidc/sso-only-no-policy.hurl | 69 +++++++ tests/oidc/sso-only.env | 11 + tests/oidc/sso-only.hurl | 193 +++++++++++++++++ 7 files changed, 583 insertions(+), 17 deletions(-) create mode 100644 tests/common/server-with-oidc-only-no-policy.env create mode 100755 tests/oidc/run-sso-only.sh create mode 100644 tests/oidc/sso-only-no-policy.hurl create mode 100644 tests/oidc/sso-only.env create mode 100644 tests/oidc/sso-only.hurl 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. +# ───────────────────────────────────────────────────────────── From d065f9995271d6b21f3a33d13f5537e07b29d463 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 21:36:49 +0200 Subject: [PATCH 7/9] feat(oidc): deprecate OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN --- docs/config/authentication.md | 2 +- docs/config/env.md | 4 ++-- docs/config/oidc.md | 2 +- example.env | 14 ++++++++++--- src/common/config.rs | 37 ++++++++++++++++++++++++++++------- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 05365cb3..0ff48484 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -65,7 +65,7 @@ Comma-separated allowlist of `password`, `magic_link`, and/or `oidc`. Default (w **OIDC master rule.** When OIDC is enabled, magic-link login is **hard-disabled** regardless of this list. The IdP is the identity boundary; magic-link would bypass any 2FA / step-up policy the IdP enforces. The startup gate above does **not** trigger in this case — OIDC provides the login path. -Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the effective allowlist. +**DEPRECATED** alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the effective allowlist. Setting it emits a boot warning; the flag will be removed in the next major release. Migrate to `OXICLOUD_AUTH_METHODS=oidc` (and add `OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc` if you want the server-side `/login` redirect too). ### `OXICLOUD_REQUIRE_VERIFIED_EMAIL` diff --git a/docs/config/env.md b/docs/config/env.md index 8fd5be1c..d4e99e51 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -46,7 +46,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes | | `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts | | `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted on `POST /api/auth/register` (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed. **Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`**: this one gates SELF-registration (public sign-up), the external list gates INVITATIONS (grants + magic-link to third parties). An operator can lock sign-up to their company domain while leaving invitations open. Subdomains must be listed explicitly. Rejected registrations return 403 `RegistrationDomainNotAllowed` and emit an `audit` line. Example: `mycompany.com,mycompany-eu.com`. | -| `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of auth methods (`password`, `magic_link`, `oidc`). **Fail-fast**: unknown token → boot panic; empty allowlist → boot panic; `oidc` in list without `OXICLOUD_OIDC_ENABLED=true` → boot panic. Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. Setting `OXICLOUD_AUTH_METHODS=oidc` is the cleanest "SSO-only" posture. **Loose semantic (deprecation warning)**: if this list is explicitly set WITHOUT `oidc` but `OXICLOUD_OIDC_ENABLED=true`, OIDC is served regardless — a boot warning is emitted and this will become a fail-fast panic in the next major release. **Startup gate**: if `magic_link` is the only working method (no `password`, no `oidc`) AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start. **OIDC master rule**: when OIDC is enabled, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list. | +| `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of auth methods (`password`, `magic_link`, `oidc`). **Fail-fast**: unknown token → boot panic; empty allowlist → boot panic; `oidc` in list without `OXICLOUD_OIDC_ENABLED=true` → boot panic. Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. Setting `OXICLOUD_AUTH_METHODS=oidc` is the cleanest "SSO-only" posture. **Loose semantic (deprecation warning)**: if this list is explicitly set WITHOUT `oidc` but `OXICLOUD_OIDC_ENABLED=true`, OIDC is served regardless — a boot warning is emitted and this will become a fail-fast panic in the next major release. **Startup gate**: if `magic_link` is the only working method (no `password`, no `oidc`) AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start. **OIDC master rule**: when OIDC is enabled, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias (**DEPRECATED**): `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list but emits a boot warning; removal in next major release. | | `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless); `auto_redirect_if_standalone_oidc` (when OIDC is the only working login method, auto-redirect the login page to the IdP instead of showing a click-to-continue button — off by default to avoid redirect loops on IdP failure and preserve logout UX). | | `OXICLOUD_REQUIRE_VERIFIED_EMAIL` | `false` | When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for any account whose `email_verified_at` is NULL. Users can prove control by requesting a magic-link (whose redemption stamps `email_verified_at`), so this composes with `magic_link` in `OXICLOUD_AUTH_METHODS` to give users a self-service verification path. Admin-created (`POST /api/admin/users`) and setup-admin (`POST /api/setup`) users are auto-verified. OIDC-JIT users are also stamped verified at creation. | @@ -211,7 +211,7 @@ See the [OIDC configuration guide](/config/oidc) for details. | `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Frontend URL to redirect to after login | | `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first SSO login (JIT provisioning) | | `OXICLOUD_OIDC_ADMIN_GROUPS` | — | Comma-separated OIDC groups that grant admin role | -| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password form when OIDC is active | +| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | **DEPRECATED** — emits boot warning; slated for removal in next major release. Use `OXICLOUD_AUTH_METHODS=oidc` (and optionally `OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc` for server-side `/login` redirect) instead. Still removes `password` from the effective allowlist when set to `true` — kept working so upgrading deployments don't break. | | `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Display name for the provider shown in UI | ## WOPI (Office Editing) diff --git a/docs/config/oidc.md b/docs/config/oidc.md index acf53c83..2e58fa4e 100644 --- a/docs/config/oidc.md +++ b/docs/config/oidc.md @@ -48,7 +48,7 @@ OXICLOUD_OIDC_PROVIDER_NAME="Authentik" | `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Where to redirect the browser after auth | | `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first login | | `OXICLOUD_OIDC_ADMIN_GROUPS` | — | OIDC groups that grant admin role | -| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password login when OIDC is active | +| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | **DEPRECATED** — use `OXICLOUD_AUTH_METHODS=oidc` instead. Emits a boot warning; slated for removal in next major release. | | `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Label shown on the login button | ::: warning diff --git a/example.env b/example.env index 9577001b..9e00b084 100644 --- a/example.env +++ b/example.env @@ -550,7 +550,13 @@ OXICLOUD_OIDC_ENABLED=false # Example: admins,cloud-admins #OXICLOUD_OIDC_ADMIN_GROUPS= -# Disable password-based login entirely when OIDC is active (default: false) +# DEPRECATED: prefer OXICLOUD_AUTH_METHODS=oidc (with optional +# OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc for the +# server-side /login redirect). This flag still works but emits a +# boot warning; removal is planned for the next major release. +# +# Legacy path — disables password-based login entirely when OIDC is +# active (default: false). #OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=false # Display name for the OIDC provider shown in UI (default: SSO) @@ -744,8 +750,10 @@ OXICLOUD_WOPI_ENABLED=false # identity provider; magic-link would sidestep any 2FA / step-up the # IdP enforces. # -# Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes -# `password` from this list. New deployments should prefer this env var. +# DEPRECATED alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still +# removes `password` from this list but emits a boot warning; it will be +# removed in the next major release. New deployments MUST use this +# `OXICLOUD_AUTH_METHODS` env var instead. # # Default (when unset): password + magic_link. #OXICLOUD_AUTH_METHODS=password,magic_link diff --git a/src/common/config.rs b/src/common/config.rs index dd4b2e6c..85aca2ca 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -2753,13 +2753,36 @@ impl AppConfig { // response; this line makes the effect apply uniformly through // `is_method_allowed(Password)` so services don't need to check // both flags. - if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") - && v.parse::().unwrap_or(false) - { - config - .auth - .allowed_auth_methods - .retain(|m| *m != AuthMethod::Password); + // + // Deprecated in favour of the composable `OXICLOUD_AUTH_METHODS=oidc` + // allowlist which handles the same SSO-only intent alongside the + // AUTH_POLICIES vector. Warn every time the env var is observed so + // operators migrating a config from a pre-AUTH_METHODS release see + // the recommendation on the first boot after upgrade. Removal is + // slated for the next major release; the setting continues to work + // until then to avoid breaking existing deployments. + if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") { + let parsed = v.parse::().unwrap_or(false); + tracing::warn!( + "OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN is DEPRECATED and will \ + be removed in a future major release. Use \ + `OXICLOUD_AUTH_METHODS=oidc` instead (add \ + `OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc` \ + to also enable server-side /login redirect). \ + Current value: {} — {}", + v, + if parsed { + "password login is disabled" + } else { + "no effect (value must be `true` to take effect)" + }, + ); + if parsed { + config + .auth + .allowed_auth_methods + .retain(|m| *m != AuthMethod::Password); + } } if let Ok(v) = env::var("OXICLOUD_REQUIRE_VERIFIED_EMAIL") { From 95104904c420e4f32363f14e43b7f34e288ba265 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 23:00:15 +0200 Subject: [PATCH 8/9] feat(oidc): reduce amount of page when auto_redirect_to_oidc --- frontend/src/lib/api/endpoints/auth.ts | 14 ++++++++++++++ frontend/src/routes/+layout.svelte | 26 +++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index e78b8db7..7d1ea762 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -99,6 +99,20 @@ export interface OidcProviders { */ require_verified_email?: boolean; authorize_endpoint?: string; + /** + * Server-computed: true when the `auto_redirect_if_standalone_oidc` + * policy is on AND OIDC is the only working method (see + * `AuthApplicationService::auto_redirect_to_oidc`). When true the root + * layout guard `window.location.replace`s to `authorize_endpoint` + * instead of routing through `/login` — the server-side `/login` + * middleware only fires on full HTTP loads, so SPA client-side + * navigation to `/login` (root guard, dev via Vite) would otherwise + * stall on the login page. Because the flag is gated by the admin's + * policy on the SERVER, using it on the client does NOT override the + * policy toggle — we're just enacting the same decision on paths the + * middleware can't reach. + */ + auto_redirect_to_oidc?: boolean; } /** Public OIDC provider info for the login page. */ diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 597aa4c3..2052001f 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -12,6 +12,7 @@ import { ui } from '$lib/stores/ui.svelte'; import { hashUrlToPath } from '$lib/utils/hashRedirect'; import { killLegacyServiceWorker } from '$lib/utils/killLegacyServiceWorker'; + import { getOidcProviders, type OidcProviders } from '$lib/api/endpoints/auth'; let { children } = $props(); @@ -34,6 +35,14 @@ } let ready = $state(false); + // Providers info fetched at boot so the guard below can enact the + // SSO-only server-side policy (`auto_redirect_if_standalone_oidc`) on + // SPA client-nav paths the middleware in interfaces/web/mod.rs can't + // see. The middleware only fires on full HTTP loads to /login; when + // the layout guard is about to `goto('/login')` we short-circuit to + // the IdP directly if the server tells us to. Null until fetched; + // the guard waits for it before deciding. + let providers = $state(null); onMount(async () => { await killLegacyServiceWorker(); @@ -51,7 +60,9 @@ const mapped = hashUrlToPath(location.hash); if (mapped) await goto(resolve(mapped as Pathname), { replaceState: true }); } - await session.load(); + // Parallel — providers is a public endpoint independent of session state. + const [, prov] = await Promise.all([session.load(), getOidcProviders()]); + providers = prov; ready = true; }); @@ -60,9 +71,18 @@ $effect(() => { if (!ready) return; const path = page.url.pathname; - if (!session.isAuthenticated && !isPublic(path)) { - void goto(resolve(`/login?redirect=${encodeURIComponent(path)}`), { replaceState: true }); + if (session.isAuthenticated || isPublic(path)) return; + + // SSO-only auto-redirect: mirror what the server-side /login + // middleware does for direct HTTP loads. Full-page navigation + // (`window.location`) so we hit the OxiCloud backend fresh — that + // endpoint 307s to the IdP with a fresh state + PKCE challenge. + // `goto()` would keep us in the SPA and never leave. + if (providers?.auto_redirect_to_oidc && providers.authorize_endpoint) { + window.location.replace(providers.authorize_endpoint); + return; } + void goto(resolve(`/login?redirect=${encodeURIComponent(path)}`), { replaceState: true }); }); From c4b454479e20e6f339d748d0957dd3f6c7cf8f19 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 23:10:51 +0200 Subject: [PATCH 9/9] fix(test-integration): fix OS possible race with tantivy test --- .../search_index/tantivy_content_index.rs | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/infrastructure/services/search_index/tantivy_content_index.rs b/src/infrastructure/services/search_index/tantivy_content_index.rs index ad96fd63..aec4bf05 100644 --- a/src/infrastructure/services/search_index/tantivy_content_index.rs +++ b/src/infrastructure/services/search_index/tantivy_content_index.rs @@ -137,12 +137,41 @@ impl TantivyContentIndex { .unwrap_or(false); if !version_ok && dir.exists() { - std::fs::remove_dir_all(dir).map_err(|e| { - DomainError::internal_error( - "ContentIndex", - format!("wiping stale index dir {}: {e}", dir.display()), - ) - })?; + // remove_dir_all is racy on Linux against concurrent writes: + // it enumerates entries, unlinks them, then rmdirs the parent. + // Tantivy writer threads from a previously-opened Index may + // still be flushing segment files as we walk — the fresh + // segment lands after our listing, and the final rmdir fails + // with ENOTEMPTY (os error 39). CI hits this on tmpfs where + // the race window is widest; local runs on APFS/ext4 rarely + // reproduce it. Retry a few times with short backoffs so the + // background writer can drain. In practice one retry is enough; + // three gives headroom for a very slow VM without stalling the + // real "dir can't be removed" case (permissions, EBUSY, etc.) + // which will still surface after the last attempt. + let mut attempt = 0; + loop { + match std::fs::remove_dir_all(dir) { + Ok(()) => break, + Err(e) + if attempt < 3 + && matches!( + e.kind(), + std::io::ErrorKind::DirectoryNotEmpty + | std::io::ErrorKind::ResourceBusy + ) => + { + attempt += 1; + std::thread::sleep(std::time::Duration::from_millis(50 * attempt)); + } + Err(e) => { + return Err(DomainError::internal_error( + "ContentIndex", + format!("wiping stale index dir {}: {e}", dir.display()), + )); + } + } + } } std::fs::create_dir_all(dir).map_err(|e| { DomainError::internal_error(