this feature to simplify the creation of only 1 binary for multiple architecture
14 KiB
OIDC Account Linking — Self-Service Link / Unlink
Logged-in local users self-serve the wiring of an OIDC identity to their account (no admin round-trip / manual SQL). Companion: unlink for users who want to detach the OIDC identity while keeping their local login.
Design context: builds on the federation-identity rename
(ocm.md § Schema rename) — a linked identity is
(federation_kind='oidc', federation_issuer=<iss URL>, federation_subject=<sub>)
on auth.users.
UX flow — auto-link on first OIDC login (majority case)
Handles the case where a user already has a local OxiCloud account and tries "Sign in with SSO" (or is auto-redirected under the standalone-OIDC policy) for the first time. Without auto-link, today's flow refuses with "A user with email X already exists — contact admin to link your OIDC identity" — forcing an admin round-trip or the self-service link flow below. Auto-link removes that friction for the common case:
Trigger: OIDC login callback lookup misses on (iss, sub) AND on
the legacy-label fallback (Phase B), but the IdP-returned email matches
an existing OxiCloud user.
Decision tree (all checks under normalized-email comparison):
Look up user by normalize(claims.email):
├─ exactly 1 match + email_verified=true + user not already linked
│ → AUTO-LINK: UPDATE federation_kind='oidc', issuer=iss, subject=sub
│ → emit `federation.auto_linked` audit event
│ → proceed with login as this user
├─ 1 match + email_verified=false
│ → refuse (`auto_link_email_not_verified`)
├─ 1 match + already linked to a DIFFERENT identity
│ → refuse (`already_linked_elsewhere`)
├─ >1 match (ambiguous under +alias normalization)
│ → refuse (`email_ambiguous`)
└─ 0 matches
→ existing JIT-provisioning branch (creates a fresh user)
Refusals fall through to the current "contact admin" error page (same shape as before this feature). Users can then self-serve via the link flow below, or the admin can intervene.
Security model — why auto-link is safe here
The classic account-takeover attack: attacker creates a rogue IdP account with the victim's email → OIDC login → auto-link → hijacks the victim's OxiCloud account.
Mitigation is the industry-standard email_verified=true gate: the
IdP itself has verified the user controls the email, so the attacker
can't just claim any email in their own IdP account.
Safe in OxiCloud's current single-IdP model because:
- Admin explicitly configures ONE trusted IdP (
OXICLOUD_OIDC_ISSUER_URL) - Same trust chain as JIT auto-provisioning today (which we already
gate on
email_verifiedwhenOXICLOUD_REQUIRE_VERIFIED_EMAIL=true) - The IdP is chosen by the admin, not the user
Explicitly NOT safe for the future multi-IdP federated login
(docs/plan/federated-login.md — any WebFinger-discovered IdP is
accepted). That flow needs different rules: allowlisted-IdP-only
auto-link, or no auto-link at all. Deferred until that lands.
Config knob
OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH=true # default TRUE
Ships enabled — it's the good UX. Admins with compliance requirements
that mandate explicit consent for every link opt out. false restores
the "refuse with contact admin" behavior; self-service link flow (below)
remains available.
Documented as a deployment-config knob in THREE places — all must be updated when this env var lands (per the project convention that every env var appears in every config-reference surface):
example.env— commented entry under the OIDC block with the default value + one-sentence explanationdocs/config/env.md— table row in the OIDC sectiondocs/config/oidc.md— narrative paragraph explaining the auto-link decision tree and security model (short form of the section above)
UX flow — link
- User logged in via password/OPAQUE lands on
/profile - Sees a "Connect Single Sign-On" card, visible when
oidc.enabled && !user.federation_kind - Clicks button → SPA
POST /api/auth/oidc/link/start(authenticated) → backend mints a state token, stores apending_oidc_flowentry withintent = Link { user_id }, returns{ authorize_url } - Full-page navigation to the IdP → user authenticates as themselves
- IdP redirects back to
/api/auth/oidc/callback?code=&state= - Callback recognises the
Linkintent (via state cache lookup) → exchanges code → validates id_token → runs safety checks (below) → UPDATE user row - Redirect to
/profile?linked=1— SPA shows a toast and strips the query param
UX flow — unlink
- User (currently OIDC-linked AND with alternative auth wired) sees a
"Disconnect Single Sign-On" card on
/profile - Clicks button → SPA
POST /api/auth/oidc/unlink - Backend refuses if the user has no other auth method (see below)
- On success: profile refreshes,
federation_kind/issuer/subjectbecomenull, "Connect SSO" card takes over the space
Safety checks — link callback
Ran BEFORE the UPDATE. Any refusal returns
/profile?link_error=<stable-key> with the reason logged internally.
| Check | Refuse reason | Rationale |
|---|---|---|
Session valid (state's user_id matches an active session) |
session_expired |
Cookie invalidated during the IdP round-trip; treat as auth failure |
| IdP-returned email matches OxiCloud email after normalization | email_mismatch |
Prevents "link Bob's identity to my account, then Bob logs in via OIDC and lands here" |
| IdP provided an email at all | email_not_provided |
Without email we can't verify identity ownership — refuse |
Identity (kind, iss, sub) not already linked to a DIFFERENT user |
already_linked_elsewhere |
Prevents linking the same OIDC identity to two OxiCloud accounts |
| Current user isn't linked to a DIFFERENT identity | already_linked |
User must unlink first — no silent identity swap |
| Same identity as currently-linked → idempotent success | (no error) | Repeat link is a no-op success |
Optional deferred: step-up auth (require fresh password/OPAQUE verification within the last N minutes before starting link). Guards against session-theft → link-attack. Add if we care.
Email normalization
common::text::normalize_email_for_link:
pub fn normalize_email_for_link(email: &str) -> String {
let lower = email.trim().to_ascii_lowercase();
let Some((local, domain)) = lower.split_once('@') else {
return lower;
};
// Strip +alias sub-addressing (Gmail / Outlook / Fastmail / etc.):
// alice+github@example.com → alice@example.com
let local_base = local.split_once('+').map(|(b, _)| b).unwrap_or(local);
format!("{}@{}", local_base, domain)
}
NOT doing dot-stripping (Gmail-only, causes false positives on other providers). NOT doing Unicode normalization (email addresses compare as ASCII-normalized already).
Behavior matrix:
| OxiCloud email | IdP email | Match? |
|---|---|---|
alice@example.com |
alice@example.com |
✅ |
alice@example.com |
Alice@example.com |
✅ (case) |
alice@example.com |
alice+oidc@example.com |
✅ (alias) |
alice+work@example.com |
alice@example.com |
✅ (alias both) |
alice@example.com |
bob@example.com |
❌ |
alice@example.com |
(missing) | ❌ (email_not_provided) |
Legitimate-but-refused cases (documented, admin unlinks+relinks):
- User changed email on IdP but not on OxiCloud
- User's IdP email uses a different domain than OxiCloud email
Unlink refusal — retain a working direct login
POST /api/auth/oidc/unlink refuses when the user has no other
credential to log in with:
if !user.has_password() && !user.opaque_registered() {
return AccessDenied("cannot_unlink_no_alternative_auth");
}
Rationale: OIDC-only account unlinking creates a passwordless account with no OIDC either → the user can't log in AT ALL. Magic-link isn't a safe fallback since (a) it's gated by SMTP wiring and (b) the OIDC-master rule wouldn't refuse it AFTER unlink but does BEFORE, so users could be surprised by inconsistent behavior. Refusing at the API layer forces the user to add a password first (via profile change- password card) before unlinking.
opaque_registered counts as an alternative because an OPAQUE envelope
IS a login credential.
Wire changes — endpoints
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
POST |
/api/auth/oidc/link/start |
Bearer/cookie | {} |
{ authorize_url: "..." } |
POST |
/api/auth/oidc/unlink |
Bearer/cookie | {} |
200 OK or 403 |
GET |
/api/auth/oidc/callback |
Public | — | Extended: ?intent=link cases redirect to /profile?... |
Pending-flow cache extension
Existing AuthApplicationService::pending_oidc_flows: Cache<String, PendingOidcFlow>
gains an intent field:
enum FlowIntent {
Login,
Link { user_id: Uuid },
}
struct PendingOidcFlow {
pkce_verifier: String,
nonce: String,
nc_flow_token: Option<String>,
intent: FlowIntent,
}
Default Login preserves existing behavior. Link is set by
prepare_oidc_link(user_id). The callback dispatches on the intent
variant.
Repo methods
async fn link_federation_identity(
&self, user_id: Uuid,
kind: &str, issuer: &str, subject: &str,
) -> Result<(), UserRepositoryError>;
// Returns AlreadyExists on UNIQUE(kind, issuer, subject) violation —
// app service translates to `already_linked_elsewhere`.
async fn unlink_federation_identity(
&self, user_id: Uuid,
) -> Result<(), UserRepositoryError>;
// UPDATE ... SET federation_kind = NULL, federation_issuer = NULL,
// federation_subject = NULL WHERE id = $1.
Audit events
federation.link_started— user_id, intent (self-service flow only)federation.link_completed— user_id, kind, issuer, subjectfederation.link_refused— user_id, reason (stable enum-shaped key:session_expired,email_mismatch,email_not_provided,already_linked_elsewhere,already_linked)federation.auto_linked— user_id, kind, issuer, subject, reason=email_match_verified(fired by the auto-link branch on the OIDC callback path — NOT by the self-service link flow)federation.auto_link_refused— user_id (if resolvable), reason (auto_link_disabled,auto_link_email_not_verified,email_ambiguous,already_linked_elsewhere)federation.unlinked— user_idfederation.unlink_refused— user_id, reason (=no_alternative_auth)
Same anti-drift discipline as other structured audit events (per
feedback_enum_over_string_literals_in_logs).
Hurl test coverage
tests/oidc/link_unlink.hurl under the OIDC runner:
Auto-link scenarios (OIDC login path with email match):
- Auto-link happy path — Alice has a local account
alice@example.com; the fake IdP is set to return that email withemail_verified=true; Alice clicks "Sign in with SSO" → login completes → GET/api/auth/meshowsfederation_kind = "oidc"+ correct issuer/subject. Audit lineevent="federation.auto_linked", reason="email_match_verified"emitted. - Auto-link refused — email_verified=false — fake IdP returns
the matching email but with
email_verified=false; login refuses withauto_link_email_not_verified. - Auto-link refused — normalized email ambiguity — two OxiCloud
users exist (
alice@example.comANDalice+work@example.com); fake IdP returnsalice@example.com; both normalize to the same value; login refuses withemail_ambiguous. - Auto-link disabled by config — separate suite with
OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH=false; email match no longer auto-links; existing "contact admin" refusal returns. (Optional Phase-2 test — env-var flip requires a separate server boot.)
Self-service link scenarios (POST /link/start from an
authenticated session):
- Self-service happy path — Alice logs in via password, POSTs
/link/start, follows the authorize URL, IdP returns matching email, callback completes link, redirect to/profile?linked=1. - Email mismatch refuse — Alice starts link,
/control/set-emailon the fake IdP flips tobob@example.com; callback refuses, redirects to/profile?link_error=email_mismatch. - +alias normalization link — Alice's OxiCloud email is
alice@example.com, fake IdP returnsalice+oidc@example.com— link succeeds (both normalize toalice@example.com). - Already linked elsewhere — Alice links; Bob logs in and starts
link; fake IdP returns Alice's identity (same sub); refused with
already_linked_elsewhere.
Unlink scenarios:
- Unlink success — Alice (linked via any prior scenario) POSTs
/unlink; refresh showsfederation_kind = null; Alice can still log in via password. - Unlink refused — a user with only OIDC (no password, no
OPAQUE) tries to unlink; refused with
no_alternative_auth.
FE changes
frontend/src/routes/profile/+page.svelte:
- Import
getOidcProviders(existing) to know if OIDC is enabled AND to resolve the display name. - "Connect Single Sign-On" card: visible when
providers.enabled && !user.federation_kind. Button → POST/api/auth/oidc/link/start→window.location.assign(response.authorize_url). - "Disconnect Single Sign-On" card: visible when
user.federation_kind === 'oidc' && (has_password || opaque_registered). Button → POST/api/auth/oidc/unlink→ refresh session. - On mount: read
?linked=1→ success toast; read?link_error=<key>→ error toast with localized message per key; strip query params viahistory.replaceState.
Scope / non-scope
In scope for the first ship:
- Link + unlink endpoints + safety checks
- Email normalization + tests
- Hurl coverage for 6 scenarios
- Profile-page UI
Deferred:
- Step-up auth before link start
- Admin-mediated link/unlink via
oxicloud federation(proper for "user changed IdP email" recovery scenario) - OCM link (same shape, different kind)
- Multi-federation (multiple linked identities per user — see ocm.md § Future — multi-federation per user)