feat(oidc): harden email verified cases
This commit is contained in:
@@ -3123,17 +3123,56 @@ impl AuthApplicationService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let provider_name = oidc.provider_name().to_string();
|
let provider_name = oidc.provider_name().to_string();
|
||||||
// Check email_verified - only if email is present in claims, and email verification is required.
|
// Email-verification gate. The operator flag
|
||||||
if self.require_verified_email()
|
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is the master switch — an
|
||||||
&& let Some(email) = &claims.email
|
// operator who opts out is telling us they trust the configured
|
||||||
{
|
// IdP end-to-end (e.g. corporate SSO where the directory already
|
||||||
let verified = claims.email_verified.unwrap_or(false);
|
// vets identities out-of-band). Both rejection reasons collapse
|
||||||
if !verified {
|
// to the same "flag off → accept" behaviour so the operator
|
||||||
tracing::warn!(
|
// lever means what it says.
|
||||||
"OIDC login rejected: email not verified (provider: {}, email: {})",
|
//
|
||||||
provider_name,
|
// Two distinct signals are audit-logged even in the accept path
|
||||||
email
|
// so operators can spot risky IdP behaviour after the fact:
|
||||||
|
//
|
||||||
|
// Some(false) → IdP is ACTIVELY asserting the email is
|
||||||
|
// unverified. Riskier than absence: it's the
|
||||||
|
// first-login takeover primitive (attacker
|
||||||
|
// types victim's address into an IdP-with-no-
|
||||||
|
// verify). Emit at info-level either way; the
|
||||||
|
// reject branch adds `oidc.callback_rejected`,
|
||||||
|
// the accept branch adds
|
||||||
|
// `oidc.email_unverified_accepted` so operators
|
||||||
|
// running with the flag off can still see the
|
||||||
|
// underlying risky signal in the audit log.
|
||||||
|
// None → IdP simply doesn't publish the claim.
|
||||||
|
// Weaker signal; rejected only when the flag
|
||||||
|
// is on. No audit line on the accept branch
|
||||||
|
// (the absence of a signal is not itself a
|
||||||
|
// signal — logging it would just be noise).
|
||||||
|
//
|
||||||
|
// We only evaluate when an email is present in the claims —
|
||||||
|
// no email → nothing to verify (the JIT path synthesises a
|
||||||
|
// placeholder later).
|
||||||
|
if let Some(email) = &claims.email {
|
||||||
|
let must_verify = self.require_verified_email();
|
||||||
|
let (reject, reason) = match (claims.email_verified, must_verify) {
|
||||||
|
(Some(true), _) => (false, None),
|
||||||
|
(Some(false), true) => (true, Some("idp_asserts_unverified")),
|
||||||
|
(Some(false), false) => (false, Some("idp_asserts_unverified_flag_off")),
|
||||||
|
(None, true) => (true, Some("claim_absent_and_required")),
|
||||||
|
(None, false) => (false, None),
|
||||||
|
};
|
||||||
|
if let Some(reason) = reason {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = if reject { "oidc.callback_rejected" } else { "oidc.email_unverified_accepted" },
|
||||||
|
reason = reason,
|
||||||
|
provider = %provider_name,
|
||||||
|
email = %email,
|
||||||
|
"👮🏻♂️ OIDC callback: email-verification signal"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if reject {
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
"OIDC",
|
"OIDC",
|
||||||
|
|||||||
@@ -80,4 +80,4 @@ OXICLOUD_OIDC_PROVIDER_NAME=MockSSO
|
|||||||
OXICLOUD_OIDC_ADMIN_GROUPS=admin-users
|
OXICLOUD_OIDC_ADMIN_GROUPS=admin-users
|
||||||
|
|
||||||
OXICLOUD_AUTH_METHODS=password,magic_link
|
OXICLOUD_AUTH_METHODS=password,magic_link
|
||||||
OXICLOUD_REQUIRE_VERIFIED_EMAIL=true
|
OXICLOUD_REQUIRE_VERIFIED_EMAIL=false
|
||||||
|
|||||||
+30
-30
@@ -343,25 +343,28 @@ jsonpath "$.user.role" == "admin"
|
|||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 10 — Anti-takeover: an OIDC callback whose `email_verified`
|
# Step 10 — Operator-override: with `OXICLOUD_REQUIRE_VERIFIED_EMAIL=false`
|
||||||
# claim is `false` MUST be rejected. Without this guard
|
# in `tests/common/server-with-oidc.env` (matching the
|
||||||
# an attacker who can set `email` to a victim's address
|
# default test posture), an OIDC callback whose
|
||||||
# in their own IdP account (some IdPs allow unverified
|
# `email_verified` claim is `false` MUST be accepted.
|
||||||
# emails through the consent screen) gets the victim's
|
# This is the "I trust my IdP end-to-end" posture — the
|
||||||
# OxiCloud account on first login.
|
# operator has told the server not to gate on the
|
||||||
|
# verification signal.
|
||||||
#
|
#
|
||||||
# We flip the fake IdP into the unverified-email mode
|
# This test used to be the anti-takeover check (assert
|
||||||
# via the `/control/email-verified/false` test hook,
|
# rejection) BEFORE commit 1801150a moved the OIDC email
|
||||||
# drive a fresh authorize, expect the OxiCloud callback
|
# check under the operator flag. Post-1801150a it flipped
|
||||||
# to fail, then reset the IdP for any future steps.
|
# to a positive test of the operator-override branch.
|
||||||
#
|
#
|
||||||
# This SHOULD use a different `sub` than the existing
|
# The rejection branch (`flag=true` + IdP `Some(false)` or
|
||||||
# verified user to exercise the JIT path (the
|
# `None`) is proved OUT-OF-SUITE — this Hurl file runs one
|
||||||
# anti-takeover check fires there), but the auto-approve
|
# server with one env config; asserting both branches
|
||||||
# handler resolves one fixed `sub`. The check still
|
# needs either a second `hurl` invocation with the flag
|
||||||
# fires on the existing user path too because the
|
# flipped, or a Rust unit test that exercises
|
||||||
# verified-email requirement is evaluated on every
|
# `handle_oidc_callback_with_id_claims` directly. The
|
||||||
# callback — that's what we exercise here.
|
# audit-log discriminator (`oidc.email_unverified_accepted`
|
||||||
|
# with reason `idp_asserts_unverified_flag_off`) is the
|
||||||
|
# operator-visible signal on the accept path here.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
POST http://localhost:1080/control/email-verified/false
|
POST http://localhost:1080/control/email-verified/false
|
||||||
|
|
||||||
@@ -382,25 +385,22 @@ GET {{unverified_idp_url}}
|
|||||||
location: true
|
location: true
|
||||||
location-trusted: true
|
location-trusted: true
|
||||||
|
|
||||||
# OxiCloud's callback returns 403 (or 401, depending on which
|
# Positive assertion of the operator-override: the redirect chain
|
||||||
# branch fires). What matters is the final URL is NOT
|
# lands on `/login?oidc_code=` (successful OIDC callback), and
|
||||||
# /login?oidc_code= — a successful login would have landed there
|
# the status is 2xx or 3xx (never 4xx/5xx). A regression that
|
||||||
# regardless of status, so a status-code-only assertion would
|
# re-added an unconditional rejection would land on the login
|
||||||
# miss a "we accidentally provisioned the unverified user"
|
# error page instead — either the URL negation or the status
|
||||||
# regression. We assert on BOTH the status AND the negation of
|
# ceiling catches it.
|
||||||
# the success URL via Hurl's built-in `url` query (NOT the
|
|
||||||
# `landed_at` capture from Step 4 — that variable is stale here).
|
|
||||||
HTTP *
|
HTTP *
|
||||||
[Asserts]
|
[Asserts]
|
||||||
status >= 400
|
status < 400
|
||||||
status < 500
|
url matches "^http://localhost:8087/login\\?oidc_code="
|
||||||
url not matches "^http://localhost:8087/login\\?oidc_code="
|
|
||||||
|
|
||||||
|
|
||||||
# Reset the IdP so this test doesn't poison anything that runs
|
# Reset the IdP so this test doesn't poison anything that runs
|
||||||
# after it (defensive — there's nothing after right now, but a
|
# after it (defensive — there's nothing after right now, but a
|
||||||
# future test would silently fail with "all my users get
|
# future test would silently fail with unexpected accept-paths
|
||||||
# rejected" if we forgot this).
|
# if we forgot this).
|
||||||
POST http://localhost:1080/control/email-verified/true
|
POST http://localhost:1080/control/email-verified/true
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
|
|||||||
Reference in New Issue
Block a user