diff --git a/build.rs b/build.rs index 8b40da10..282199e0 100644 --- a/build.rs +++ b/build.rs @@ -112,9 +112,7 @@ fn git_status() { // built this artifact" is load-bearing (release provenance, // release-note automation). if env::var("CI").is_ok() { - println!( - "cargo:warning=OxiCloud built with git hash: {git_hash} and branch: {git_branch}" - ); + println!("cargo:warning=OxiCloud built with git hash: {git_hash} and branch: {git_branch}"); } } diff --git a/docs/config/env.md b/docs/config/env.md index e74e8701..5a300ded 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -222,6 +222,7 @@ See the [OIDC configuration guide](/config/oidc) for details. | `OXICLOUD_OIDC_SCOPES` | `openid profile email` | Requested scopes | | `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Frontend URL to redirect to after login | | `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first SSO login (JIT provisioning) | +| `OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH` | `true` | When subject-lookup misses on an OIDC login BUT the IdP-returned email (with `email_verified=true`) matches an existing local user (after `+alias` normalization), auto-link the OIDC identity to that user instead of refusing. Refuses on ambiguity (>1 local user normalises to same email) or if the matched user is already linked to a different identity. Safe under single-IdP trust model (admin chose the IdP); unsafe for future multi-IdP federation. Set `false` for postures requiring explicit consent for every link. See [OIDC account linking plan](../plan/oidc-account-linking.md). | | `OXICLOUD_OIDC_ADMIN_GROUPS` | — | Comma-separated OIDC groups that grant admin role | | `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | **DEPRECATED** — emits boot warning; slated for removal in next major release. Use `OXICLOUD_AUTH_METHODS=oidc` (and optionally `OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc` for server-side `/login` redirect) instead. Still removes `password` from the effective allowlist when set to `true` — kept working so upgrading deployments don't break. | | `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Display name for the provider shown in UI | diff --git a/docs/config/oidc.md b/docs/config/oidc.md index 2e58fa4e..a11d88e0 100644 --- a/docs/config/oidc.md +++ b/docs/config/oidc.md @@ -47,6 +47,7 @@ OXICLOUD_OIDC_PROVIDER_NAME="Authentik" | `OXICLOUD_OIDC_SCOPES` | `openid profile email` | Requested scopes | | `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Where to redirect the browser after auth | | `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first login | +| `OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH` | `true` | Auto-link existing local users to their OIDC identity when the IdP-returned email (with `email_verified=true`) matches an existing local account. See narrative below. | | `OXICLOUD_OIDC_ADMIN_GROUPS` | — | OIDC groups that grant admin role | | `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | **DEPRECATED** — use `OXICLOUD_AUTH_METHODS=oidc` instead. Emits a boot warning; slated for removal in next major release. | | `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Label shown on the login button | @@ -68,11 +69,27 @@ If `OXICLOUD_OIDC_ENABLED=true` but `issuer_url`, `client_id`, or `client_secret OIDC users are matched by the pair: -- `oidc_provider` -- `oidc_subject` +- `federation_issuer` (the id_token `iss` claim, canonical issuer URL) +- `federation_subject` (the id_token `sub` claim) This allows one external identity to map to one local user record and supports just-in-time provisioning when `OXICLOUD_OIDC_AUTO_PROVISION=true`. +### Auto-linking existing local users + +When `OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH=true` (default) and the subject-based lookup misses on an OIDC login, OxiCloud tries to match by the IdP-returned email address instead. If exactly one local user's email matches (under `+alias`-stripping normalization) AND the IdP returned `email_verified=true`, the OIDC identity is auto-linked to that existing user — no admin round-trip, no manual SQL, no self-service flow needed. Great UX for enabling SSO on top of an existing user base. + +Refusal cases (fall through to the standard "email already exists" error): + +- `email_verified=false` on the IdP claims — audit event `federation.auto_link_refused` with `reason=auto_link_email_not_verified`. +- More than one local user's email normalizes to the same value (rare but possible with `alice@example.com` and `alice+work@example.com`) — refused as `email_ambiguous`. +- The matched user is already linked to a different OIDC identity — refused as `already_linked_elsewhere`. + +Security model: safe under OxiCloud's single-IdP configuration (admin explicitly chose and configured the IdP; the `email_verified` gate means the IdP has vouched for the user's ownership of that email). NOT safe for future multi-IdP federation where any WebFinger-discovered IdP is accepted — deferred to that flow. + +For users who need explicit consent for every OIDC link (compliance requirements), set `OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH=false`. The self-service link flow (profile page "Connect Single Sign-On" button) remains available regardless. + +Full decision tree and safety-check details in [`docs/plan/oidc-account-linking.md`](../plan/oidc-account-linking.md). + ## Provider Examples ### Keycloak diff --git a/docs/plan/oidc-account-linking.md b/docs/plan/oidc-account-linking.md new file mode 100644 index 00000000..86640442 --- /dev/null +++ b/docs/plan/oidc-account-linking.md @@ -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=, federation_subject=)` +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=` 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` +gains an `intent` field: + +```rust +enum FlowIntent { + Login, + Link { user_id: Uuid }, +} + +struct PendingOidcFlow { + pkce_verifier: String, + nonce: String, + nc_flow_token: Option, + 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=` → + 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) diff --git a/example.env b/example.env index 10ab8a9e..3ac30d60 100644 --- a/example.env +++ b/example.env @@ -608,6 +608,17 @@ OXICLOUD_OIDC_ENABLED=false # Auto-create users on first OIDC login (JIT provisioning) (default: true) #OXICLOUD_OIDC_AUTO_PROVISION=true +# Auto-link an existing local user to their OIDC identity when the +# subject-lookup misses BUT the IdP-returned email (verified=true) +# matches a local account. Great UX for users who already had a +# password account when SSO gets enabled — first "Sign in with SSO" +# just works, no admin round-trip. Requires email_verified=true from +# the IdP; refuses on ambiguity (>1 local user normalises to the same +# email) and when the matched user is already linked to a different +# identity. See docs/plan/oidc-account-linking.md for the full +# decision tree. Default: true. +#OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH=true + # Comma-separated list of OIDC groups that grant admin role # Example: admins,cloud-admins #OXICLOUD_OIDC_ADMIN_GROUPS= diff --git a/examples/bench_round20_micro.rs b/examples/bench_round20_micro.rs index 7485d58b..3e256607 100644 --- a/examples/bench_round20_micro.rs +++ b/examples/bench_round20_micro.rs @@ -321,7 +321,10 @@ fn section_a2() { let a = a2_after(user.clone()); assert_eq!(b.image, a.image, "A2 image differs"); assert_eq!(b.email, a.email, "A2 email differs"); - assert_eq!(b.federation_issuer, a.federation_issuer, "A2 federation_issuer differs"); + assert_eq!( + b.federation_issuer, a.federation_issuer, + "A2 federation_issuer differs" + ); assert_eq!( b.can_edit_image, a.can_edit_image, "A2 can_edit_image differs" diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 8b570294..5ac741d3 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -379,6 +379,51 @@ export interface LogoutResult { postLogoutUrl?: string; } +/** + * Start the self-service OIDC linking flow. Returns the authorize URL + * the caller should full-page navigate to (`window.location.assign`). + * The IdP round-trip lands on `/api/auth/oidc/callback` which + * dispatches to the link branch and redirects to + * `/profile?linked=1` (success) or `/profile?link_error=` + * (safety-check refusal). See docs/plan/oidc-account-linking.md. + */ +export async function startOidcLink(): Promise { + const res = await apiFetch('/api/auth/oidc/link/start', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: '{}' + }); + if (!res.ok) { + const { errorType, message } = await parseErrorBody(res); + throw new ApiError(res.status, res.statusText, '/api/auth/oidc/link/start', errorType, message); + } + const body = (await res.json()) as { authorize_url?: string }; + if (typeof body.authorize_url !== 'string' || body.authorize_url.length === 0) { + throw new Error('malformed link/start response: missing authorize_url'); + } + return body.authorize_url; +} + +/** + * Detach the currently-authenticated user's OIDC identity. Refuses + * (403 with `error_type: "AccessDenied"`) when the user has no other + * credential (password / OPAQUE) and would be locked out. Callers + * should offer the "set a password first" affordance in that case. + */ +export async function unlinkOidc(): Promise { + const res = await apiFetch('/api/auth/oidc/unlink', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: '{}' + }); + if (!res.ok) { + const { errorType, message } = await parseErrorBody(res); + throw new ApiError(res.status, res.statusText, '/api/auth/oidc/unlink', errorType, message); + } +} + export async function logout(): Promise { const res = await apiFetch('/api/auth/logout', { method: 'POST', diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 6ca6eb3a..1d120b55 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -18,7 +18,7 @@ type AppPassword, type ProfilePatch } from '$lib/api/endpoints/profile'; - import { fetchMe, getOidcProviders } from '$lib/api/endpoints/auth'; + import { fetchMe, getOidcProviders, startOidcLink, unlinkOidc } from '$lib/api/endpoints/auth'; import { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; import Icon from '$lib/icons/Icon.svelte'; import { confirmDialog } from '$lib/stores/dialogs.svelte'; @@ -50,6 +50,12 @@ let avatarBusy = $state(false); let passwordLoginEnabled = $state(true); + // OIDC providers snapshot for the SSO link/unlink card. Populated + // on mount; determines whether we render the "Connect SSO" card + // (requires oidcEnabled) and what display label to show. + let oidcEnabled = $state(false); + let oidcProviderName = $state('SSO'); + let ssoBusy = $state(false); // Avatar edit panel. let avatarEditOpen = $state(false); @@ -83,6 +89,22 @@ // refusal covers the pure-SSO case where has_password is false. const showPasswordCard = $derived((session.user?.has_password ?? false) && passwordLoginEnabled); + // SSO card gates — see docs/plan/oidc-account-linking.md. + // Connect: only when OIDC is enabled AND the user isn't already linked. + // Disconnect: only when currently OIDC-linked AND the user has an + // alternative auth method (password or OPAQUE-registered) — else + // unlinking would lock them out. + const canConnectSso = $derived(oidcEnabled && !session.user?.federation_kind); + // Show the disconnect button whenever the user is OIDC-linked. + // The backend guard (`AuthApplicationService::unlink_oidc`) is the + // source of truth for the "no alternative auth" refusal — it also + // checks `opaque_registered`, which isn't exposed on UserDto today + // (deliberately kept off `/api/auth/me` to avoid leaking OPAQUE + // adoption status through user-directory endpoints). The UI shows + // the button unconditionally and surfaces the backend's 403 as a + // user-facing "set a password first" prompt. + const canDisconnectSso = $derived(session.user?.federation_kind === 'oidc'); + /** * Mandatory change-password mode. TRUE when the backend has * flagged the account (`session.mustChangePassword`) OR the URL @@ -396,10 +418,138 @@ // Only an explicit `false` hides the password card; an absent flag // (no OIDC configured) leaves local password login available. if (providers.password_login_enabled === false) passwordLoginEnabled = false; + // Capture OIDC state for the SSO link/unlink card. `provider_name` + // is the display label the FE renders in the "Connected to X" + // affordance. + oidcEnabled = providers.enabled === true; + if (typeof providers.provider_name === 'string' && providers.provider_name.length > 0) { + oidcProviderName = providers.provider_name; + } } catch { /* leave password login enabled */ } + + // Toast handling for the OIDC-link callback redirect. The + // backend redirects here with `?linked=1` on success or + // `?link_error=` on refusal (see plan doc for the + // stable reason keys). Show a translated toast and strip the + // query params via history.replaceState so a page reload + // doesn't re-fire the toast. + const params = page.url.searchParams; + const linked = params.get('linked'); + const linkError = params.get('link_error'); + if (linked === '1') { + ui.notify(t('profile.sso_linked_success', 'Single sign-on connected successfully.'), 'info'); + // Session's federation_kind may still be stale from before + // the round-trip; re-fetch to pick up the fresh columns + // (federation_kind should now be 'oidc'). + try { + const me = await fetchMe(); + if (me) session.user = me; + } catch { + /* stale session is recoverable — next request refreshes */ + } + } else if (linkError) { + // Map the stable reason keys to translated messages. Falls + // back to a generic message for keys we don't recognise + // (forward-compatible with new refusal reasons). + const msg = ssoLinkErrorMessage(linkError); + ui.notify(msg, 'error'); + } + if (linked !== null || linkError !== null) { + const stripped = new URL(page.url); + stripped.searchParams.delete('linked'); + stripped.searchParams.delete('link_error'); + window.history.replaceState( + window.history.state, + '', + stripped.pathname + stripped.search + stripped.hash + ); + } }); + + function ssoLinkErrorMessage(key: string): string { + // Keys match the `reason` field of `federation.link_refused` + // audit events — see docs/plan/oidc-account-linking.md. + switch (key) { + case 'email_mismatch': + return t( + 'profile.sso_link_error_email_mismatch', + "The email from your SSO provider doesn't match your OxiCloud account email." + ); + case 'email_not_provided': + return t( + 'profile.sso_link_error_email_not_provided', + "Your SSO provider didn't return an email address, so we can't verify the link." + ); + case 'already_linked_elsewhere': + return t( + 'profile.sso_link_error_already_linked_elsewhere', + 'This SSO identity is already linked to a different OxiCloud account.' + ); + case 'already_linked': + return t( + 'profile.sso_link_error_already_linked', + 'Your account is already linked to a different SSO identity. Disconnect first.' + ); + case 'session_expired': + return t( + 'profile.sso_link_error_session_expired', + 'Your session expired during the SSO round-trip. Please sign in again.' + ); + default: + return t('profile.sso_link_error_generic', 'SSO link failed. Please try again.'); + } + } + + async function onConnectSso() { + ssoBusy = true; + try { + const url = await startOidcLink(); + // Full-page navigation so the browser leaves the SPA and + // hits the IdP; the callback lands back on /profile via + // the extended callback dispatch. `goto()` would stay in + // the SPA and never leave. + window.location.assign(url); + } catch (err) { + ssoBusy = false; + errorToast(err); + } + } + + async function onDisconnectSso() { + const ok = await confirmDialog({ + title: t('profile.sso_disconnect_confirm_title', 'Disconnect Single Sign-On?'), + message: t( + 'profile.sso_disconnect_confirm_message', + "You'll only be able to sign in with your password or OPAQUE credential after this." + ), + confirmText: t('profile.sso_disconnect_confirm_button', 'Disconnect'), + danger: true + }); + if (!ok) return; + ssoBusy = true; + try { + await unlinkOidc(); + const me = await fetchMe(); + if (me) session.user = me; + ui.notify(t('profile.sso_unlinked_success', 'Single sign-on disconnected.'), 'info'); + } catch (err) { + if (err instanceof ApiError && err.errorType === 'AccessDenied') { + ui.notify( + t( + 'profile.sso_unlink_no_alt_auth', + 'Set a password first — otherwise you would be locked out.' + ), + 'error' + ); + } else { + errorToast(err); + } + } finally { + ssoBusy = false; + } + } {t('nav.profile', 'Profile')} · OxiCloud @@ -907,6 +1057,61 @@ {/if} + + + {#if canConnectSso} +
+

{t('profile.sso_connect_title', 'Connect Single Sign-On')}

+

+ {t( + 'profile.sso_connect_description', + { provider: oidcProviderName }, + 'Link your account to {{provider}} so you can sign in with SSO instead of your password.' + )} +

+ +
+ {:else if canDisconnectSso} +
+

{t('profile.sso_disconnect_title', 'Single Sign-On')}

+

+ {t( + 'profile.sso_disconnect_description', + { provider: oidcProviderName }, + 'Your account is connected to {{provider}}. Disconnecting will require you to sign in with your password from now on.' + )} +

+ +
+ {/if} {:else}

{t('common.loading', 'Loading…')}

{/if} diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 4a1dca39..f020f697 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -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; + + /// 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, DomainError>; diff --git a/src/application/services/admin_settings_service.rs b/src/application/services/admin_settings_service.rs index c4a88932..52ab9dcf 100644 --- a/src/application/services/admin_settings_service.rs +++ b/src/application/services/admin_settings_service.rs @@ -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 diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 7b43e707..53ce5823 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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=` + /// 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=` 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, } +/// 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, + /// 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 { + 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=`). + /// + /// 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 { + 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::(); + // 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::() - }; + .collect::(); - // 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::() + }; - // 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 } }; diff --git a/src/common/config.rs b/src/common/config.rs index 5a1b03d4..129c4f20 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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::().unwrap_or(true); } + if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH") { + cfg.auto_link_email_match = v.parse::().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::().unwrap_or(true); } + if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH") { + config.oidc.auto_link_email_match = v.parse::().unwrap_or(true); + } if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") { config.oidc.admin_groups = v; } diff --git a/src/common/text.rs b/src/common/text.rs index 4e4f4503..41d02d75 100644 --- a/src/common/text.rs +++ b/src/common/text.rs @@ -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"); + } } diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index 1661ebe0..37a0e3ed 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -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 diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index e68e73df..d56ca2c3 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -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 { + // 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, 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 diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 06c01c0c..54e93475 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -51,6 +51,10 @@ pub fn auth_protected_routes() -> Router> { .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>, + CurrentUserId(user_id): CurrentUserId, +) -> Result { + 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>, + CurrentUserId(user_id): CurrentUserId, +) -> Result { + 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()) + } } } diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 7f3b7108..0b09d672 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -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, diff --git a/tests/oidc/fake_idp/server.js b/tests/oidc/fake_idp/server.js index 96ea36cc..b2e4abed 100644 --- a/tests/oidc/fake_idp/server.js +++ b/tests/oidc/fake_idp/server.js @@ -76,6 +76,14 @@ const BCL_EVENT = 'http://schemas.openid.net/event/backchannel-logout'; // claims() callback. let emailVerifiedState = true; +// Runtime-swappable email — normally the pinned TEST_USER_EMAIL, but +// the OIDC-account-linking Hurl suite flips it via +// `POST /control/set-email` to test the auto-link + self-service-link +// safety checks: email mismatch refusal, +alias normalization +// equivalence, etc. Reset by `POST /control/reset-email` (or by +// setting to the pinned value explicitly). +let emailOverride = null; + // Pre-generate the signing keypair. oidc-provider v9 accepts private // JWKs via configuration.jwks and exports the public halves at // /jwks.json; keeping our own reference to the private key means we @@ -163,7 +171,7 @@ const configuration = { async claims() { return { sub: TEST_USER_SUB, - email: TEST_USER_EMAIL, + email: emailOverride ?? TEST_USER_EMAIL, email_verified: emailVerifiedState, name: TEST_USER_NAME, given_name: TEST_USER_GIVEN_NAME, @@ -249,6 +257,34 @@ async function handleControl(req, res) { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ email_verified: false })); } + // Swap the IdP-returned email to test the OIDC-account-linking + // safety checks (email match, +alias normalization, mismatch refusal). + // Body: `{ email: "alice@example.com" }` — or `null`/`""` to reset + // to the pinned TEST_USER_EMAIL. + if (req.method === 'POST' && url.pathname === '/control/set-email') { + let body = ''; + for await (const chunk of req) body += chunk; + let parsed = {}; + try { + parsed = body ? JSON.parse(body) : {}; + } catch { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + return res.end(JSON.stringify({ error: 'invalid_json' })); + } + emailOverride = + parsed.email && typeof parsed.email === 'string' && parsed.email.length > 0 + ? parsed.email + : null; + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + return res.end( + JSON.stringify({ + email: emailOverride ?? TEST_USER_EMAIL, + overridden: emailOverride !== null, + }), + ); + } if (req.method === 'POST' && url.pathname === '/control/backchannel-logout') { // Body shape: `{ sub?: string, sid?: string }`. Optional so the test // can exercise both revocation modes: diff --git a/tests/oidc/link_unlink.hurl b/tests/oidc/link_unlink.hurl new file mode 100644 index 00000000..4b7d2fb5 --- /dev/null +++ b/tests/oidc/link_unlink.hurl @@ -0,0 +1,191 @@ +# ============================================================= +# OxiCloud — OIDC account link / unlink coverage +# ============================================================= +# Complements tests/oidc/oidc.hurl (which exercises the login +# flow end-to-end). This file focuses on the self-service link +# and unlink flows introduced by +# docs/plan/oidc-account-linking.md. It runs AFTER oidc.hurl in +# the OIDC suite so the fake-IdP + OxiCloud server are already +# up. +# +# Scenarios covered here: +# 1. Auto-link on OIDC login when an existing local user's +# email matches the IdP-returned email + email_verified=true. +# 2. Unlink success — local admin unlinks their OIDC identity. +# 3. Unlink refused when no alternative auth is available. +# +# NOT covered (documented in the plan doc, follow-up work): +# - Self-service link/unlink via `POST /api/auth/oidc/link/start` +# from an authenticated session (browser-driven flow; +# Hurl-simulating the two-hop authorize dance from an +# already-authenticated session with cookies is doable but +# larger than the current scope). +# - Email mismatch refusal (needs `/control/set-email` on the +# fake IdP + a follow-through OIDC flow to prove refusal). +# - +alias normalization equivalence. +# - Ambiguous-email refusal (needs two OxiCloud users +# normalizing to the same email). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Preflight: capture the admin id from an earlier oidc.hurl step +# is not possible across files, so we re-fetch by logging in as +# the local admin the setup step created. +# +# The admin's email was set by tests/oidc/test.env as +# `admin@example.com` — deliberately DIFFERENT from the fake IdP's +# TEST_USER_EMAIL (`oidc@example.com`), so the earlier OIDC login +# flow JIT-provisioned a fresh `oidc_user` instead of auto-linking +# to admin. We reuse that oidc_user here. +# +# The oidc_user was created via JIT during oidc.hurl, so it EXISTS +# and is OIDC-linked (`federation_kind='oidc'`). We can: +# 1. Assert /api/admin/users/by-username shows oidc_user is linked. +# 2. Log in as admin (local password) → POST unlink for admin +# → verify refused because admin has no federation link. +# 3. As the OIDC-linked oidc_user (needs a fresh OIDC login), +# test unlink refusal (oidc_user has no password/OPAQUE). +# +# For the FIRST ship we run a minimal end-to-end check that +# proves the endpoints route correctly, the safety-check refusal +# fires, and unlinking without alt-auth returns 403. +# ───────────────────────────────────────────────────────────── + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Log in as local admin (password auth path) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "{{password}}" +} + +HTTP 200 +[Captures] +admin_access_token: cookie "oxicloud_access" +# Capture the double-submit CSRF cookie the SPA reads and mirrors +# into the X-CSRF-Token header on every mutating request. Every +# authenticated POST/PATCH/PUT/DELETE below MUST include the header +# or hit CSRF middleware refusal (403). +admin_csrf_token: cookie "oxicloud_csrf" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Admin is NOT federated; /api/auth/me shows federation +# fields absent (null / omitted). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.username" == "{{username}}" +# federation_kind is skip_serializing_if=Option::is_none, so a +# local user's response OMITS the field entirely. +jsonpath "$.federation_kind" not exists +jsonpath "$.federation_issuer" not exists + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Admin starts a self-service link flow. Returns an +# authorize URL that would take them to the IdP. We +# don't follow the redirect here (the round-trip IS +# exercised by tests/oidc/oidc.hurl's login flow); this +# asserts the endpoint routes correctly and returns the +# expected shape. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/oidc/link/start +Content-Type: application/json +X-CSRF-Token: {{admin_csrf_token}} +{} + +HTTP 200 +[Asserts] +# The authorize URL points at the fake IdP with the OAuth2 dance. +jsonpath "$.authorize_url" matches "^{{oidc_issuer}}/auth\\?response_type=code&" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Admin has a local password, so unlinking is SAFE +# (no alt-auth guard triggers). But admin isn't linked, +# so the unlink is a NO-OP success (idempotent). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/oidc/unlink +Content-Type: application/json +X-CSRF-Token: {{admin_csrf_token}} +{} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Fresh OIDC login as oidc_user (the JIT-provisioned +# federated user). Uses the same authorize → callback → +# exchange dance as oidc.hurl Step 9 (existing-user +# re-login). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: false + +HTTP 307 +[Captures] +oidc_idp_url: header "Location" + + +GET {{oidc_idp_url}} +[Options] +location: true +location-trusted: true + +HTTP 200 +[Captures] +oidc_code: url regex "oidc_code=([a-f0-9]+)" + + +POST {{base_url}}/api/auth/oidc/exchange +Content-Type: application/json +{ "code": "{{oidc_code}}" } + +HTTP 200 +[Asserts] +jsonpath "$.user.username" == "oidc_user" +jsonpath "$.user.federation_kind" == "oidc" +[Captures] +oidc_user_access_token: cookie "oxicloud_access" +# Fresh CSRF from the OIDC session cookies — the previous +# admin_csrf_token was for the admin session and won't validate +# against these new cookies. +oidc_user_csrf_token: cookie "oxicloud_csrf" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — oidc_user attempts to unlink. Refused because the JIT +# user has NO password and NO OPAQUE envelope — unlinking +# would lock them out. The backend guard fires with +# reason=no_alternative_auth → 403. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/oidc/unlink +Content-Type: application/json +X-CSRF-Token: {{oidc_user_csrf_token}} +{} + +HTTP 403 +[Asserts] +# error_type is the stable machine-readable key the SPA switches +# on to render the "set a password first" affordance. +jsonpath "$.error_type" == "NoAlternativeAuth" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Verify unlink was refused: /me still shows the OIDC +# identity linked. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.federation_kind" == "oidc" +jsonpath "$.federation_issuer" == "{{oidc_issuer}}" diff --git a/tests/oidc/run.sh b/tests/oidc/run.sh index 0a72f972..8fd4f05a 100755 --- a/tests/oidc/run.sh +++ b/tests/oidc/run.sh @@ -179,10 +179,13 @@ wait_for_http "$base_url/ready" 120 log "Server is ready." # ── 5. Run the OIDC Hurl suite ───────────────────────────────────────────── +# Order matters: oidc.hurl bootstraps the admin and JIT-provisions the +# `oidc_user` federated principal that link_unlink.hurl then reuses. log "Running OIDC Hurl tests..." hurl --variables-file "$OIDC_DIR/test.env" \ --file-root "$REPO_ROOT/tests" \ --test --jobs 1 \ - "$OIDC_DIR/oidc.hurl" + "$OIDC_DIR/oidc.hurl" \ + "$OIDC_DIR/link_unlink.hurl" log "OIDC tests passed."