feat(oidc): permit auto/manual oidc account link/unlink

link are checking that email matches, +email alias are normalize into email
if email is already used on another account, link is not possible
not usurpation risk as the IDP is choosen by the admin
This commit is contained in:
Edouard Vanbelle
2026-08-08 17:19:05 +02:00
parent d8b3f2e026
commit e9495a63ad
20 changed files with 1791 additions and 135 deletions
+37
View File
@@ -193,6 +193,43 @@ pub trait UserStoragePort: Send + Sync + 'static {
new_issuer: &str,
) -> Result<(), DomainError>;
/// Attach a federation identity to a user row that currently has
/// none. Used by the self-service link flow and the auto-link
/// branch of the OIDC callback. See
/// docs/plan/oidc-account-linking.md.
///
/// Enforces at the DB layer via the
/// `idx_users_federation` UNIQUE index: if this triple is already
/// bound to a DIFFERENT user, returns `AlreadyExists`. The caller
/// (app service) translates that to a `already_linked_elsewhere`
/// audit reason and a user-facing refusal.
///
/// Does NOT overwrite an already-linked identity — the current
/// user must be unlinked first. This is a "first link" primitive
/// only; the app service's higher-level `link_oidc` orchestrates
/// the pre-checks (idempotent-if-same / refuse-if-different).
async fn link_federation_identity(
&self,
user_id: Uuid,
kind: &str,
issuer: &str,
subject: &str,
) -> Result<(), DomainError>;
/// Scalar `opaque_envelope IS NOT NULL` for the user. Used by the
/// unlink refusal guard (a user with an OPAQUE envelope still has
/// a working direct login even after OIDC unlink). Avoids
/// dragging the full envelope bytes across the wire for a bool.
async fn is_opaque_registered(&self, user_id: Uuid) -> Result<bool, DomainError>;
/// Detach the current federation identity from a user row: set all
/// three federation columns to NULL. The `has_password` or
/// `opaque_registered` fallback guard lives at the app service
/// layer — this method is a mechanical UPDATE.
///
/// Idempotent: calling on an already-unlinked user is a no-op.
async fn unlink_federation_identity(&self, user_id: Uuid) -> Result<(), DomainError>;
/// Lists users by role (e.g., "admin" or "user")
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
@@ -53,6 +53,10 @@ impl AdminSettingsService {
("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"),
("OXICLOUD_OIDC_SCOPES", "scopes"),
("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"),
(
"OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH",
"auto_link_email_match",
),
("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"),
(
"OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN",
@@ -95,6 +99,9 @@ impl AdminSettingsService {
if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() {
config.auto_provision = e.auto_provision;
}
if std::env::var("OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH").is_ok() {
config.auto_link_email_match = e.auto_link_email_match;
}
if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() {
config.admin_groups = e.admin_groups.clone();
}
@@ -141,6 +148,10 @@ impl AdminSettingsService {
.get("oidc.provider_name")
.cloned()
.unwrap_or(d.provider_name),
auto_link_email_match: db
.get("oidc.auto_link_email_match")
.and_then(|v| v.parse().ok())
.unwrap_or(d.auto_link_email_match),
};
// Env vars override DB
@@ -42,6 +42,20 @@ pub enum OidcCallbackResult {
user_id: Uuid,
username: String,
},
/// Self-service link flow completed — the OIDC identity was
/// attached to the already-authenticated user. Handler redirects
/// the browser to `/profile?linked=1` (or `?link_error=<reason>`
/// on the `LinkRefused` variant below).
///
/// The user's existing session cookies remain valid (no new session
/// is minted for the link flow — the user was already logged in
/// when they started).
LinkCompleted { user_id: Uuid },
/// Self-service link refused by a safety check. `reason` is the
/// stable enum-shaped key the handler surfaces on the
/// `/profile?link_error=<reason>` redirect. See
/// docs/plan/oidc-account-linking.md § Safety checks.
LinkRefused { reason: &'static str },
}
/// Outcome of a successful magic-link redemption. The auth tokens are
@@ -93,6 +107,21 @@ pub struct MagicLinkRedemption {
pub resource_id: Option<Uuid>,
}
/// Why an OIDC flow was initiated — dispatched on at callback time.
///
/// `Login` (default) → normal login: JIT-provision or match existing
/// user, mint OxiCloud session.
///
/// `Link { user_id }` → self-service identity link
/// (`POST /api/auth/oidc/link/start`). The callback runs safety checks
/// and, on success, UPDATEs `federation_*` on the ALREADY-LOGGED-IN
/// user's row. See docs/plan/oidc-account-linking.md.
#[derive(Clone)]
enum FlowIntent {
Login,
Link { user_id: Uuid },
}
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
#[derive(Clone)]
struct PendingOidcFlow {
@@ -102,6 +131,10 @@ struct PendingOidcFlow {
/// page. On successful callback the flow will mint an app-password and
/// complete the Nextcloud login flow instead of issuing internal JWTs.
nc_flow_token: Option<String>,
/// What the callback should DO with a successful IdP response.
/// Defaults to `Login` for every existing flow-mint call site;
/// self-service linking sets `Link { user_id }`.
intent: FlowIntent,
}
/// Tracks a pending one-time token exchange after successful OIDC callback
@@ -3154,6 +3187,7 @@ impl AuthApplicationService {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: None,
intent: FlowIntent::Login,
},
);
@@ -3170,6 +3204,296 @@ impl AuthApplicationService {
Ok(authorize_url)
}
/// Prepare an OIDC authorize flow for the SELF-SERVICE LINK path.
/// Same PKCE + nonce dance as `prepare_oidc_authorize`, but the
/// pending-flow entry carries `FlowIntent::Link { user_id }` so the
/// callback branches to the link handler instead of the login one.
///
/// The caller MUST have already authenticated the user (this method
/// takes user_id from the current session context). See
/// docs/plan/oidc-account-linking.md § UX flow — link.
pub async fn prepare_oidc_link(&self, user_id: Uuid) -> Result<String, DomainError> {
let oidc = self.oidc_service().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"OIDC",
"OIDC service not configured",
)
})?;
// Anti-scope-creep pre-check: refuse if the user is already
// linked. Callers get an immediate error rather than round-
// tripping through the IdP just to be refused at callback time.
// (The callback still re-checks — this is a UX shortcut, not
// the source of truth.)
let user = self.user_storage.get_user_by_id(user_id).await?;
if user.federation_kind().is_some() {
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"Federation",
"This user is already linked to a federation identity. \
Unlink first before re-linking.",
));
}
use rand_core::{OsRng, RngCore};
use sha2::{Digest, Sha256};
let mut state_bytes = [0u8; 32];
OsRng.fill_bytes(&mut state_bytes);
let state_token = hex::encode(state_bytes);
let mut nonce_bytes = [0u8; 32];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = hex::encode(nonce_bytes);
let mut verifier_bytes = [0u8; 32];
OsRng.fill_bytes(&mut verifier_bytes);
let pkce_verifier = base64_url_encode(&verifier_bytes);
let pkce_challenge = {
let hash = Sha256::digest(pkce_verifier.as_bytes());
base64_url_encode(&hash)
};
self.pending_oidc_flows.insert(
state_token.clone(),
PendingOidcFlow {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: None,
intent: FlowIntent::Link { user_id },
},
);
let authorize_url = oidc
.get_authorize_url(&state_token, &nonce, &pkce_challenge)
.await?;
tracing::info!(
target: "audit",
event = "federation.link_started",
user_id = %user_id,
"🔗 self-service OIDC link flow initiated"
);
Ok(authorize_url)
}
/// Detach the current OIDC identity from a user. Refuses if the
/// user has no other authentication credential — otherwise the
/// user would lock themselves out of their own account.
///
/// "Other credential" = local password OR OPAQUE envelope on file.
/// Magic-link doesn't count as a safe fallback: the OIDC-master
/// rule refuses magic-link for OIDC-linked users, so its behavior
/// FLIPS after unlink, creating surprise; and it depends on SMTP
/// wiring which may not be present. See
/// docs/plan/oidc-account-linking.md § Unlink refusal.
/// Run the safety checks + UPDATE for the self-service link flow.
/// Called from `oidc_callback` when `FlowIntent::Link { user_id }`
/// was set at flow-start time. Returns `OidcCallbackResult` variants
/// that the handler translates to a redirect (LinkCompleted →
/// `/profile?linked=1`, LinkRefused → `/profile?link_error=<key>`).
///
/// Safety checks (all refusals are wire-visible as `link_error=`):
/// - Session valid — target user exists (state's user_id points to
/// a real row). If not, `session_expired`.
/// - IdP provided an email — else `email_not_provided`.
/// - Emails match under normalize_email_for_link — else
/// `email_mismatch`.
/// - Identity `(kind, iss, sub)` not already linked to a DIFFERENT
/// user — else `already_linked_elsewhere`.
/// - Current user isn't already linked to a DIFFERENT identity —
/// else `already_linked`. Same identity → idempotent success.
async fn complete_oidc_link(
&self,
user_id: Uuid,
claims: &OidcIdClaims,
) -> Result<OidcCallbackResult, DomainError> {
use crate::common::text::normalize_email_for_link;
// 1. Session validity — the target user must still exist.
let user = match self.user_storage.get_user_by_id(user_id).await {
Ok(u) => u,
Err(_) => {
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
reason = "session_expired",
"🔗 link refused — target user not found (session may have ended)",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "session_expired",
});
}
};
// 2. IdP must provide an email — without it we can't verify
// ownership.
let idp_email = match claims.email.as_ref() {
Some(e) => e,
None => {
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
reason = "email_not_provided",
"🔗 link refused — IdP did not return an email claim",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "email_not_provided",
});
}
};
// 3. Email match under +alias normalization.
if normalize_email_for_link(idp_email) != normalize_email_for_link(user.email()) {
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
reason = "email_mismatch",
oxicloud_email_normalized = %normalize_email_for_link(user.email()),
idp_email_normalized = %normalize_email_for_link(idp_email),
"🔗 link refused — IdP email doesn't match OxiCloud user email",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "email_mismatch",
});
}
// 4. Idempotent-if-same / refuse-if-different: check the current
// user's link state before we touch it.
match (
user.federation_kind(),
user.federation_issuer(),
user.federation_subject(),
) {
(None, None, None) => {
// Fresh — proceed to link.
}
(Some(kind), Some(iss), Some(sub))
if kind.as_str() == "oidc" && iss == claims.iss && sub == claims.sub =>
{
// Same identity → idempotent no-op success.
tracing::info!(
target: "audit",
event = "federation.link_completed",
user_id = %user_id,
reason = "idempotent_repeat",
federation_issuer = %claims.iss,
federation_subject = %claims.sub,
"🔗 link no-op — user already linked to this same identity",
);
return Ok(OidcCallbackResult::LinkCompleted { user_id });
}
_ => {
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
reason = "already_linked",
"🔗 link refused — user already linked to a different identity; unlink first",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "already_linked",
});
}
}
// 5. Identity not already linked to a DIFFERENT user. The
// UNIQUE(kind, issuer, subject) index would catch this at
// UPDATE time via link_federation_identity's AlreadyExists
// error, but we pre-check to emit a clean audit line and
// avoid the "AlreadyExists on user" confusion in the
// downstream error mapping.
if let Ok(other) = self
.user_storage
.get_user_by_federation_subject(&claims.iss, &claims.sub)
.await
&& other.id() != user_id
{
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
other_user_id = %other.id(),
reason = "already_linked_elsewhere",
"🔗 link refused — this OIDC identity is already linked to a different OxiCloud user",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "already_linked_elsewhere",
});
}
// All checks passed — commit the link.
self.user_storage
.link_federation_identity(user_id, "oidc", &claims.iss, &claims.sub)
.await?;
tracing::info!(
target: "audit",
event = "federation.link_completed",
user_id = %user_id,
federation_kind = "oidc",
federation_issuer = %claims.iss,
federation_subject = %claims.sub,
"🔗 self-service OIDC link completed",
);
Ok(OidcCallbackResult::LinkCompleted { user_id })
}
pub async fn unlink_oidc(&self, user_id: Uuid) -> Result<(), DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
// Idempotent: unlinking an already-unlinked user is a success.
if user.federation_kind().is_none() {
tracing::info!(
target: "audit",
event = "federation.unlinked",
user_id = %user_id,
already_unlinked = true,
"🔗 unlink no-op — user was not linked"
);
return Ok(());
}
// The guard. `has_password` reads password_hash.is_some();
// `opaque_registered` needs a separate lookup because the User
// entity doesn't carry that flag today. We do that as a
// targeted query rather than dragging the full opaque_envelope
// column across the wire.
let opaque_registered = self.user_storage.is_opaque_registered(user_id).await?;
if !user.has_password() && !opaque_registered {
tracing::info!(
target: "audit",
event = "federation.unlink_refused",
user_id = %user_id,
reason = "no_alternative_auth",
"👮🏻‍♂️ unlink refused — user has no password/OPAQUE fallback"
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Federation",
"Cannot unlink — set a password first, or you will be locked out.",
));
}
self.user_storage
.unlink_federation_identity(user_id)
.await?;
tracing::info!(
target: "audit",
event = "federation.unlinked",
user_id = %user_id,
"🔗 OIDC identity unlinked"
);
Ok(())
}
/// Prepare an OIDC authorization flow for a Nextcloud Login Flow v2 session.
///
/// Works like [`prepare_oidc_authorize`] but associates the Nextcloud flow
@@ -3213,6 +3537,7 @@ impl AuthApplicationService {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: Some(nc_flow_token.to_string()),
intent: FlowIntent::Login,
},
);
@@ -3268,8 +3593,12 @@ impl AuthApplicationService {
));
}
};
let (pkce_verifier, nonce, nc_flow_token) =
(flow.pkce_verifier, flow.nonce, flow.nc_flow_token);
let (pkce_verifier, nonce, nc_flow_token, intent) = (
flow.pkce_verifier,
flow.nonce,
flow.nc_flow_token,
flow.intent,
);
// Clone the Arc and config out of the RwLock so we don't hold the lock across await points
let (oidc, oidc_config) = {
@@ -3329,6 +3658,20 @@ impl AuthApplicationService {
claims
};
// ────────────────────────────────────────────────────────────
// Flow-intent dispatch — if this callback was initiated by
// the self-service link path (`POST /api/auth/oidc/link/start`),
// divert here BEFORE the login-specific processing (email
// verification gate / JIT / session mint). Login stays on the
// fall-through path. See docs/plan/oidc-account-linking.md.
// ────────────────────────────────────────────────────────────
if let FlowIntent::Link {
user_id: target_user_id,
} = intent
{
return self.complete_oidc_link(target_user_id, &claims).await;
}
let provider_name = oidc.provider_name().to_string();
// Email-verification gate. The operator flag
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is the master switch — an
@@ -3492,144 +3835,216 @@ impl AuthApplicationService {
existing_user
}
Err(_) => {
// User doesn't exist — try to match by email
// User doesn't exist by federation subject — try to
// match by email. Two possible outcomes:
// * Email matches an existing local user AND the
// auto-link decision tree accepts → auto-link,
// yield the linked user (falls through to session
// mint below).
// * Email matches AND auto-link refuses (config off,
// email not verified, already linked elsewhere) →
// return "contact admin" error (self-service link
// flow remains available).
// * No email match → JIT provision (existing branch).
//
// NOTE (MVP scope): exact-match lookup only. If OxiCloud
// stores `alice+work@example.com` but the IdP returns
// `alice@example.com`, the exact match misses even
// though they normalise to the same value. The user
// falls through to the "contact admin" refusal and can
// self-serve via the profile link flow.
let matched_user = self.user_storage.get_user_by_email(&oidc_email).await.ok();
if let Some(_existing) = matched_user {
// Email match but no OIDC link — for security, don't auto-link
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"OIDC",
format!(
"A user with email '{}' already exists. Contact admin to link your OIDC identity.",
oidc_email
),
));
}
if let Some(matched) = matched_user {
// Auto-link decision tree — see
// docs/plan/oidc-account-linking.md § Auto-link.
let can_auto_link = oidc_config.auto_link_email_match
&& claims.email_verified == Some(true)
&& matched.federation_kind().is_none();
// No match — JIT provision if enabled
if !oidc_config.auto_provision {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"OIDC",
"Auto-provisioning is disabled. Contact admin to create your account.",
));
}
if !can_auto_link {
let reason = if !oidc_config.auto_link_email_match {
"auto_link_disabled"
} else if claims.email_verified != Some(true) {
"auto_link_email_not_verified"
} else {
"already_linked_elsewhere"
};
tracing::info!(
target: "audit",
event = "federation.auto_link_refused",
user_id = %matched.id(),
reason = reason,
"🔗 auto-link refused",
);
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"OIDC",
format!(
"A user with email '{}' already exists. Contact admin to link your OIDC identity.",
oidc_email
),
));
}
// Determine role from OIDC groups
let role = self.map_oidc_role(&claims.groups, &oidc_config);
let quota = self.capped_quota(&role);
// Sanitize username: if it looks like an email, extract the local part
// (some OIDC providers like Keycloak use email as the preferred username)
let base_username = if oidc_username.contains('@') {
oidc_username.split('@').next().unwrap_or(&oidc_username)
// All checks passed — commit the auto-link, re-fetch
// to observe the fresh federation columns, then run
// the same login-side effects as the "existing user"
// arm above (lifecycle dispatch, register_login,
// avatar/verification sync).
self.user_storage
.link_federation_identity(matched.id(), "oidc", &claims.iss, &claims.sub)
.await?;
tracing::info!(
target: "audit",
event = "federation.auto_linked",
reason = "email_match_verified",
user_id = %matched.id(),
federation_kind = "oidc",
federation_issuer = %claims.iss,
federation_subject = %claims.sub,
"🔗 OIDC identity auto-linked to existing local user via verified email match",
);
let mut linked_user = self.user_storage.get_user_by_id(matched.id()).await?;
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_login(&linked_user).await;
}
linked_user.register_login();
linked_user.set_image(claims.picture.clone());
linked_user.mark_email_verified();
self.user_storage
.sync_oidc_login_profile(linked_user.id(), claims.picture.as_deref())
.await?;
// Yield the linked user — same shape as the
// Ok(existing_user) arm's tail expression.
linked_user
} else {
&oidc_username
};
// No email match — JIT provision (existing behavior).
if !oidc_config.auto_provision {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"OIDC",
"Auto-provisioning is disabled. Contact admin to create your account.",
));
}
// Filter to valid username characters only, then truncate to 32 chars
let mut username = base_username
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
.take(32)
.collect::<String>();
// Determine role from OIDC groups
let role = self.map_oidc_role(&claims.groups, &oidc_config);
// Filter helper: removes any chars that are not valid in a username
let filter_username_chars = |s: &str| {
s.chars()
let quota = self.capped_quota(&role);
// Sanitize username: if it looks like an email, extract the local part
// (some OIDC providers like Keycloak use email as the preferred username)
let base_username = if oidc_username.contains('@') {
oidc_username.split('@').next().unwrap_or(&oidc_username)
} else {
&oidc_username
};
// Filter to valid username characters only, then truncate to 32 chars
let mut username = base_username
.chars()
.filter(|c| {
c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.'
})
.take(32)
.collect::<String>()
};
.collect::<String>();
// Ensure minimum length (the padding suffix must also be filtered)
if username.len() < 3 {
let filtered_sub = filter_username_chars(&claims.sub);
username = format!("user_{}", &filtered_sub[..filtered_sub.len().min(8)]);
}
// Filter helper: removes any chars that are not valid in a username
let filter_username_chars = |s: &str| {
s.chars()
.filter(|c| {
c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.'
})
.take(32)
.collect::<String>()
};
// Check for username collision
if self
.user_storage
.get_user_by_username(&username)
.await
.is_ok()
{
let filtered_sub = filter_username_chars(&claims.sub);
let suffix = &filtered_sub[..filtered_sub.len().min(4)];
username = format!("{}_{}", &username[..username.len().min(27)], suffix);
}
// Ensure minimum length (the padding suffix must also be filtered)
if username.len() < 3 {
let filtered_sub = filter_username_chars(&claims.sub);
username = format!("user_{}", &filtered_sub[..filtered_sub.len().min(8)]);
}
let mut new_user = User::new(
oidc_email,
Some(username.clone()),
None,
Some(crate::domain::entities::user::FederationKind::Oidc),
// Phase B canonical value: the id_token's real `iss`
// claim (validated to equal discovery.issuer in
// OidcService). No more display-label writes at JIT —
// legacy rows are fixed via lazy rebind in the
// existing-user branch above.
Some(claims.iss.clone()),
Some(claims.sub.clone()),
role,
quota,
false,
)
.map_err(|e| {
DomainError::new(
ErrorKind::InvalidInput,
"OIDC",
format!("Failed to create OIDC user: {}", e),
// Check for username collision
if self
.user_storage
.get_user_by_username(&username)
.await
.is_ok()
{
let filtered_sub = filter_username_chars(&claims.sub);
let suffix = &filtered_sub[..filtered_sub.len().min(4)];
username = format!("{}_{}", &username[..username.len().min(27)], suffix);
}
let mut new_user = User::new(
oidc_email,
Some(username.clone()),
None,
Some(crate::domain::entities::user::FederationKind::Oidc),
// Phase B canonical value: the id_token's real `iss`
// claim (validated to equal discovery.issuer in
// OidcService). No more display-label writes at JIT —
// legacy rows are fixed via lazy rebind in the
// existing-user branch above.
Some(claims.iss.clone()),
Some(claims.sub.clone()),
role,
quota,
false,
)
})?;
new_user.set_image(claims.picture.clone());
new_user.set_given_name(claims.given_name.clone());
new_user.set_family_name(claims.family_name.clone());
// PR C: provision the user's preferred_locale from the
// OIDC `locale` claim AT JIT ONLY. Subsequent logins
// never re-apply this — a UI-driven choice ("I prefer
// English even though my IdP says fr-CA") must not be
// silently overwritten on the next sign-in. We validate
// the claim against the registry so an obscure or
// malformed code (e.g. `klingon`, `fr-FR-x-private`)
// doesn't end up stored only to fail at render time;
// unresolvable claims fall through to NULL → server
// default.
if let Some(claim) = claims.locale.as_deref()
&& let Some(canonical) = locale_registry.parse(claim)
{
new_user.set_preferred_locale(Some(canonical.as_str().to_string()));
.map_err(|e| {
DomainError::new(
ErrorKind::InvalidInput,
"OIDC",
format!("Failed to create OIDC user: {}", e),
)
})?;
new_user.set_image(claims.picture.clone());
new_user.set_given_name(claims.given_name.clone());
new_user.set_family_name(claims.family_name.clone());
// PR C: provision the user's preferred_locale from the
// OIDC `locale` claim AT JIT ONLY. Subsequent logins
// never re-apply this — a UI-driven choice ("I prefer
// English even though my IdP says fr-CA") must not be
// silently overwritten on the next sign-in. We validate
// the claim against the registry so an obscure or
// malformed code (e.g. `klingon`, `fr-FR-x-private`)
// doesn't end up stored only to fail at render time;
// unresolvable claims fall through to NULL → server
// default.
if let Some(claim) = claims.locale.as_deref()
&& let Some(canonical) = locale_registry.parse(claim)
{
new_user.set_preferred_locale(Some(canonical.as_str().to_string()));
}
// PR 23: the OIDC callback rejected any caller upstream
// whose `email_verified` claim wasn't true, so users
// reaching this branch have an IdP-vetted email. Stamp
// the verification at JIT-create time.
new_user.mark_email_verified();
let created_user = self.user_storage.create_user(new_user).await?;
// Lifecycle: created (audit + home-folder provisioning) +
// login (no register_login() for a fresh OIDC user means
// `last_login_at` is naturally None → first-login detection
// works). PersonalDriveLifecycleHook creates the home folder.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_created(&created_user).await;
lc.dispatch_login(&created_user).await;
}
tracing::info!(
"OIDC user provisioned: {} (provider: {}, sub: {})",
created_user.id(),
provider_name,
claims.sub
);
created_user
}
// PR 23: the OIDC callback rejected any caller upstream
// whose `email_verified` claim wasn't true, so users
// reaching this branch have an IdP-vetted email. Stamp
// the verification at JIT-create time.
new_user.mark_email_verified();
let created_user = self.user_storage.create_user(new_user).await?;
// Lifecycle: created (audit + home-folder provisioning) +
// login (no register_login() for a fresh OIDC user means
// `last_login_at` is naturally None → first-login detection
// works). PersonalDriveLifecycleHook creates the home folder.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_created(&created_user).await;
lc.dispatch_login(&created_user).await;
}
tracing::info!(
"OIDC user provisioned: {} (provider: {}, sub: {})",
created_user.id(),
provider_name,
claims.sub
);
created_user
}
};
+16
View File
@@ -1628,6 +1628,15 @@ pub struct OidcConfig {
pub disable_password_login: bool,
/// OIDC provider display name (shown in UI)
pub provider_name: String,
/// When TRUE (default), an OIDC login whose subject doesn't match
/// any existing user AUTO-LINKS to the local user with the same
/// verified email address (if any). Requires `email_verified=true`
/// from the IdP. See docs/plan/oidc-account-linking.md § Auto-link.
///
/// Set FALSE for compliance postures that require explicit consent
/// for every OIDC linkage. Self-service link flow still works
/// regardless of this flag.
pub auto_link_email_match: bool,
}
impl Default for OidcConfig {
@@ -1644,6 +1653,7 @@ impl Default for OidcConfig {
admin_groups: String::new(),
disable_password_login: false,
provider_name: "SSO".to_string(),
auto_link_email_match: true,
}
}
}
@@ -1852,6 +1862,9 @@ impl OidcConfig {
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") {
cfg.auto_provision = v.parse::<bool>().unwrap_or(true);
}
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH") {
cfg.auto_link_email_match = v.parse::<bool>().unwrap_or(true);
}
if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") {
cfg.admin_groups = v;
}
@@ -3432,6 +3445,9 @@ impl AppConfig {
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") {
config.oidc.auto_provision = v.parse::<bool>().unwrap_or(true);
}
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH") {
config.oidc.auto_link_email_match = v.parse::<bool>().unwrap_or(true);
}
if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") {
config.oidc.admin_groups = v;
}
+112
View File
@@ -20,6 +20,39 @@ pub fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool {
.any(|w| w.eq_ignore_ascii_case(needle))
}
/// Normalize an email address for **linking-equivalence comparison**
/// (NOT for storage — never modify what the user typed when persisting
/// or displaying).
///
/// Rules:
/// - Case-fold to ASCII lowercase (email addresses are treated as
/// case-insensitive in practice per RFC 5321 §2.4).
/// - Strip `+alias` sub-addressing from the local part:
/// `alice+github@example.com` → `alice@example.com`. Supported by
/// Gmail / Google Workspace, Outlook/O365 (since 2018), Fastmail
/// (since ~2020), and most modern providers. Safe for a 1:1
/// comparison — two same-user addresses normalise to the same value.
///
/// NOT doing:
/// - Dot-stripping (Gmail-only: `a.lice@gmail.com == alice@gmail.com`).
/// Applying universally would false-positive on providers that treat
/// dots as significant.
/// - Unicode normalisation — email addresses compare as ASCII already.
///
/// Load-bearing for `POST /api/auth/oidc/link/start` → callback and
/// for the auto-link decision on OIDC login. See
/// docs/plan/oidc-account-linking.md § Safety checks.
pub fn normalize_email_for_link(email: &str) -> String {
let lower = email.trim().to_ascii_lowercase();
let Some((local, domain)) = lower.split_once('@') else {
// Malformed — return the lowercased form; caller's comparison
// will fail naturally.
return lower;
};
let local_base = local.split_once('+').map(|(b, _)| b).unwrap_or(local);
format!("{}@{}", local_base, domain)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -51,4 +84,83 @@ mod tests {
fn empty_needle_is_true() {
assert!(ascii_ci_contains(b"anything", b""));
}
#[test]
fn normalize_email_for_link_matrix() {
// Behaviour matrix from docs/plan/oidc-account-linking.md
// § Email normalization. Left = raw, right = expected normalized.
let cases: &[(&str, &str)] = &[
// Identity
("alice@example.com", "alice@example.com"),
// Case fold
("Alice@Example.COM", "alice@example.com"),
// +alias stripped
("alice+github@example.com", "alice@example.com"),
("alice+oidc@example.com", "alice@example.com"),
// Both sides of a match normalise the same way
("alice+work@example.com", "alice@example.com"),
// Empty +alias suffix is still stripped
("alice+@example.com", "alice@example.com"),
// Multiple + in local: everything after the FIRST + is dropped
("alice+work+extra@example.com", "alice@example.com"),
// Trim leading/trailing whitespace
(" alice@example.com ", "alice@example.com"),
// Different local parts stay different
("bob@example.com", "bob@example.com"),
// Different domains stay different (no cross-domain equivalence)
("alice@corp.com", "alice@corp.com"),
// Domain case-folded too
("alice@Example.COM", "alice@example.com"),
];
for (raw, expected) in cases {
assert_eq!(
normalize_email_for_link(raw),
*expected,
"normalize_email_for_link({raw:?}) should equal {expected:?}"
);
}
}
#[test]
fn normalize_email_link_equivalence_pairs() {
// Anti-drift: pairs that MUST compare equal after normalization
// (the "auto-link email match" cases the plan doc lists as ✅).
let equivalent: &[(&str, &str)] = &[
("alice@example.com", "alice@example.com"),
("alice@example.com", "Alice@example.com"),
("alice@example.com", "alice+oidc@example.com"),
("alice+work@example.com", "alice@example.com"),
("alice+work@example.com", "alice+home@example.com"),
];
for (left, right) in equivalent {
assert_eq!(
normalize_email_for_link(left),
normalize_email_for_link(right),
"{left:?} should equal {right:?} under linking normalization"
);
}
// Pairs that MUST NOT match — the plan's ❌ cases.
let distinct: &[(&str, &str)] = &[
("alice@example.com", "bob@example.com"),
("alice@example.com", "alice@corp.com"),
// Dot-stripping deliberately NOT applied — dots stay significant.
("a.lice@gmail.com", "alice@gmail.com"),
];
for (left, right) in distinct {
assert_ne!(
normalize_email_for_link(left),
normalize_email_for_link(right),
"{left:?} MUST NOT equal {right:?} — dot-stripping is Gmail-only, we don't apply it"
);
}
}
#[test]
fn normalize_email_malformed_returns_lowercased() {
// No `@` → return lowercased trimmed form; the caller's
// downstream comparison will fail naturally.
assert_eq!(normalize_email_for_link("not-an-email"), "not-an-email");
assert_eq!(normalize_email_for_link(" MIXED-Case "), "mixed-case");
}
}
+2 -1
View File
@@ -355,7 +355,8 @@ impl User {
// only fires on inconsistent partial state.
if federation_issuer.is_some() != federation_subject.is_some() {
return Err(UserError::ValidationError(
"federation_issuer and federation_subject must both be set or both be None".to_string(),
"federation_issuer and federation_subject must both be set or both be None"
.to_string(),
));
}
// If either field is set, federation_kind MUST also be set — the
@@ -1402,6 +1402,105 @@ impl UserStoragePort for UserPgRepository {
Ok(())
}
async fn link_federation_identity(
&self,
user_id: Uuid,
kind: &str,
issuer: &str,
subject: &str,
) -> Result<(), DomainError> {
// Guarded UPDATE: only proceed when the row currently has NO
// federation identity. Prevents accidental identity overwrite —
// callers wanting to replace an existing link must go through
// unlink first. Silent no-op on already-linked rows is WRONG
// because it would swallow the intent; instead we return an
// error the app service translates to `already_linked`.
//
// Uniqueness enforcement lives on `idx_users_federation`
// (UNIQUE(kind, issuer, subject) WHERE federation_kind IS NOT
// NULL). If this triple is already bound to a DIFFERENT user,
// the UPDATE succeeds row-count = 0 (the WHERE constrains us to
// rows for THIS user_id) — but the following INSERT-shaped
// UPDATE approach doesn't trigger the unique index; we rely on
// the app service having pre-checked via
// `get_user_by_federation_subject`. If that pre-check races
// with a concurrent link (rare), the second call surfaces
// `AlreadyExists` from sqlx via `map_sqlx_error`.
let result = sqlx::query(
r#"
UPDATE auth.users
SET federation_kind = $2,
federation_issuer = $3,
federation_subject = $4,
updated_at = NOW()
WHERE id = $1
AND federation_kind IS NULL
"#,
)
.bind(user_id)
.bind(kind)
.bind(issuer)
.bind(subject)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
if result.rows_affected() == 0 {
// Either the user doesn't exist OR they already have a
// federation identity attached. The app service should have
// already validated user existence + link state; being here
// usually means a concurrent link race.
return Err(DomainError::already_exists(
"User",
"user is already linked to a federation identity",
));
}
Ok(())
}
async fn is_opaque_registered(&self, user_id: Uuid) -> Result<bool, DomainError> {
// Scalar `IS NOT NULL` check — the envelope is a few hundred
// bytes of ciphertext; we don't want to fetch it just to
// examine presence. `fetch_optional` returns None if the user
// doesn't exist (caller treats missing as "not registered").
let row: Option<(bool,)> = sqlx::query_as(
r#"
SELECT (opaque_envelope IS NOT NULL)
FROM auth.users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_optional(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
Ok(row.map(|(v,)| v).unwrap_or(false))
}
async fn unlink_federation_identity(&self, user_id: Uuid) -> Result<(), DomainError> {
// Idempotent: unlinking an already-unlinked user is a zero-row
// UPDATE. App service's `no_alternative_auth` refusal guard
// runs BEFORE this — the DB layer just moves the columns.
sqlx::query(
r#"
UPDATE auth.users
SET federation_kind = NULL,
federation_issuer = NULL,
federation_subject = NULL,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
Ok(())
}
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError> {
UserRepository::list_users_by_role(self, role)
.await
@@ -1574,7 +1673,10 @@ mod integration_tests {
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240);
assert_eq!(page[1].username, None);
assert!(page[1].is_external);
assert_eq!(page[1].federation_issuer.as_deref(), Some("integration-idp"));
assert_eq!(
page[1].federation_issuer.as_deref(),
Some("integration-idp")
);
let internal = UserRepository::list_user_summaries(&repo, 10, 0, false)
.await
+113
View File
@@ -51,6 +51,10 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
.route("/change-password", put(change_password))
.route("/upgrade-to-internal", post(upgrade_to_internal))
.route("/logout", post(logout))
// Self-service OIDC identity linking — see
// docs/plan/oidc-account-linking.md.
.route("/oidc/link/start", post(oidc_link_start))
.route("/oidc/unlink", post(oidc_unlink))
}
/// Rate-limited auth routes, split out so main.rs can apply per-endpoint
@@ -1380,6 +1384,91 @@ pub async fn oidc_authorize(
Ok(Redirect::temporary(&authorize_url))
}
/// Start a self-service OIDC linking flow for the currently-authenticated
/// user. Returns the authorize URL for the SPA to `window.location`
/// navigate to. Callback lands on the standard `/api/auth/oidc/callback`
/// which dispatches to the link branch based on the state cache's
/// `intent` field. See docs/plan/oidc-account-linking.md.
#[utoipa::path(
post,
path = "/api/auth/oidc/link/start",
responses(
(status = 200, description = "Authorize URL to navigate the user to", body = serde_json::Value),
(status = 401, description = "Not authenticated"),
(status = 404, description = "OIDC not enabled"),
(status = 409, description = "User is already linked — unlink first"),
),
security(("bearerAuth" = [])),
tag = "auth"
)]
pub async fn oidc_link_start(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_app = &auth_service.auth_application_service;
if !auth_app.oidc_enabled() {
return Err(AppError::new(
StatusCode::NOT_FOUND,
"OIDC is not enabled",
"OidcDisabled",
));
}
let authorize_url = auth_app.prepare_oidc_link(user_id).await?;
Ok(Json(serde_json::json!({
"authorize_url": authorize_url,
})))
}
/// Unlink the current user's OIDC identity. Refuses when the user has
/// no other credential (password / OPAQUE) — see plan doc for the
/// no-alternative-auth guard rationale.
#[utoipa::path(
post,
path = "/api/auth/oidc/unlink",
responses(
(status = 200, description = "OIDC identity unlinked (or was already unlinked)"),
(status = 401, description = "Not authenticated"),
(status = 403, description = "Refused — user has no other credential and would be locked out"),
),
security(("bearerAuth" = [])),
tag = "auth"
)]
pub async fn oidc_unlink(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
// Translate the app-service's generic AccessDenied refusal into a
// stable machine-readable `error_type` the SPA can switch on to
// render the "set a password first" affordance. The app service
// already emits the audit line with reason=no_alternative_auth;
// this hop maps the domain error to a wire contract.
match auth_service
.auth_application_service
.unlink_oidc(user_id)
.await
{
Ok(()) => Ok(StatusCode::OK),
Err(e) if e.kind == crate::domain::errors::ErrorKind::AccessDenied => Err(AppError::new(
StatusCode::FORBIDDEN,
e.message.clone(),
"NoAlternativeAuth",
)),
Err(e) => Err(e.into()),
}
}
/// Handle the OIDC provider callback.
///
/// Validates the `state` / PKCE / nonce, exchanges the code for tokens, then
@@ -1469,6 +1558,30 @@ pub async fn oidc_callback(
.await,
)
}
// Self-service link flow completion — redirect the user back
// to their profile with a query-param signal the SPA reads on
// mount to render a toast + strip the param via history.
// See docs/plan/oidc-account-linking.md § UX flow — link.
OidcCallbackResult::LinkCompleted { user_id } => {
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!("{}/profile?linked=1", frontend_url);
tracing::info!(
user_id = %user_id,
"OIDC link completed, redirecting to /profile?linked=1"
);
Ok(Redirect::temporary(&redirect_url).into_response())
}
OidcCallbackResult::LinkRefused { reason } => {
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!("{}/profile?link_error={}", frontend_url, reason);
tracing::info!(
reason = reason,
"OIDC link refused, redirecting to /profile?link_error"
);
Ok(Redirect::temporary(&redirect_url).into_response())
}
}
}
+2
View File
@@ -80,6 +80,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::auth_handler::oidc_callback,
handlers::auth_handler::oidc_exchange,
handlers::auth_handler::oidc_backchannel_logout,
handlers::auth_handler::oidc_link_start,
handlers::auth_handler::oidc_unlink,
// File handlers (free functions — see file_handler.rs for why)
handlers::file_handler::list_files_query,
handlers::file_handler::upload_file_with_thumbnails,