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:
@@ -0,0 +1,337 @@
|
||||
# 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](./ocm.md)) — 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_verified` when `OXICLOUD_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 explanation
|
||||
- `docs/config/env.md` — table row in the OIDC section
|
||||
- `docs/config/oidc.md` — narrative paragraph explaining the
|
||||
auto-link decision tree and security model (short form of the
|
||||
section above)
|
||||
|
||||
## UX flow — link
|
||||
|
||||
1. User logged in via password/OPAQUE lands on `/profile`
|
||||
2. Sees a **"Connect Single Sign-On"** card, visible when
|
||||
`oidc.enabled && !user.federation_kind`
|
||||
3. Clicks button → SPA `POST /api/auth/oidc/link/start` (authenticated)
|
||||
→ backend mints a state token, stores a `pending_oidc_flow` entry with
|
||||
`intent = Link { user_id }`, returns `{ authorize_url }`
|
||||
4. Full-page navigation to the IdP → user authenticates as themselves
|
||||
5. IdP redirects back to `/api/auth/oidc/callback?code=&state=`
|
||||
6. Callback recognises the `Link` intent (via state cache lookup) →
|
||||
exchanges code → validates id_token → runs safety checks (below) →
|
||||
UPDATE user row
|
||||
7. Redirect to `/profile?linked=1` — SPA shows a toast and strips the
|
||||
query param
|
||||
|
||||
## UX flow — unlink
|
||||
|
||||
1. User (currently OIDC-linked AND with alternative auth wired) sees a
|
||||
**"Disconnect Single Sign-On"** card on `/profile`
|
||||
2. Clicks button → SPA `POST /api/auth/oidc/unlink`
|
||||
3. Backend refuses if the user has no other auth method (see below)
|
||||
4. On success: profile refreshes, `federation_kind`/`issuer`/`subject`
|
||||
become `null`, "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`:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
```rust
|
||||
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, subject
|
||||
- `federation.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_id
|
||||
- `federation.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):
|
||||
|
||||
1. **Auto-link happy path** — Alice has a local account
|
||||
`alice@example.com`; the fake IdP is set to return that email with
|
||||
`email_verified=true`; Alice clicks "Sign in with SSO" → login
|
||||
completes → GET `/api/auth/me` shows `federation_kind = "oidc"` +
|
||||
correct issuer/subject. Audit line
|
||||
`event="federation.auto_linked", reason="email_match_verified"`
|
||||
emitted.
|
||||
2. **Auto-link refused — email_verified=false** — fake IdP returns
|
||||
the matching email but with `email_verified=false`; login refuses
|
||||
with `auto_link_email_not_verified`.
|
||||
3. **Auto-link refused — normalized email ambiguity** — two OxiCloud
|
||||
users exist (`alice@example.com` AND `alice+work@example.com`);
|
||||
fake IdP returns `alice@example.com`; both normalize to the same
|
||||
value; login refuses with `email_ambiguous`.
|
||||
4. **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):
|
||||
|
||||
5. **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`.
|
||||
6. **Email mismatch refuse** — Alice starts link, `/control/set-email`
|
||||
on the fake IdP flips to `bob@example.com`; callback refuses,
|
||||
redirects to `/profile?link_error=email_mismatch`.
|
||||
7. **+alias normalization link** — Alice's OxiCloud email is
|
||||
`alice@example.com`, fake IdP returns `alice+oidc@example.com` —
|
||||
link succeeds (both normalize to `alice@example.com`).
|
||||
8. **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:**
|
||||
|
||||
9. **Unlink success** — Alice (linked via any prior scenario) POSTs
|
||||
`/unlink`; refresh shows `federation_kind = null`; Alice can still
|
||||
log in via password.
|
||||
10. **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 via
|
||||
`history.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-cli 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)
|
||||
Reference in New Issue
Block a user