390aa31443
this feature to simplify the creation of only 1 binary for multiple architecture
338 lines
14 KiB
Markdown
338 lines
14 KiB
Markdown
# 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 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)
|