From b91f2fab2ba3299d98e33ae261fd85edf1a8fae1 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 00:11:49 +0200 Subject: [PATCH] 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." ); }