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

link are checking that email matches, +email alias are normalize into email
if email is already used on another account, link is not possible
not usurpation risk as the IDP is choosen by the admin
This commit is contained in:
Edouard Vanbelle
2026-08-08 17:19:05 +02:00
parent d8b3f2e026
commit e9495a63ad
20 changed files with 1791 additions and 135 deletions
+1 -3
View File
@@ -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}");
}
}
+1
View File
@@ -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 |
+19 -2
View File
@@ -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
+337
View File
@@ -0,0 +1,337 @@
# OIDC Account Linking — Self-Service Link / Unlink
Logged-in local users self-serve the wiring of an OIDC identity to their
account (no admin round-trip / manual SQL). Companion: unlink for users
who want to detach the OIDC identity while keeping their local login.
Design context: builds on the federation-identity rename
([ocm.md § Schema rename](./ocm.md)) — a linked identity is
`(federation_kind='oidc', federation_issuer=<iss URL>, federation_subject=<sub>)`
on `auth.users`.
## UX flow — auto-link on first OIDC login (majority case)
Handles the case where a user already has a local OxiCloud account and
tries "Sign in with SSO" (or is auto-redirected under the standalone-OIDC
policy) for the first time. Without auto-link, today's flow refuses with
"A user with email X already exists — contact admin to link your OIDC
identity" — forcing an admin round-trip or the self-service link flow
below. Auto-link removes that friction for the common case:
**Trigger:** OIDC login callback lookup misses on `(iss, sub)` AND on
the legacy-label fallback (Phase B), but the IdP-returned email matches
an existing OxiCloud user.
**Decision tree** (all checks under normalized-email comparison):
```
Look up user by normalize(claims.email):
├─ exactly 1 match + email_verified=true + user not already linked
│ → AUTO-LINK: UPDATE federation_kind='oidc', issuer=iss, subject=sub
│ → emit `federation.auto_linked` audit event
│ → proceed with login as this user
├─ 1 match + email_verified=false
│ → refuse (`auto_link_email_not_verified`)
├─ 1 match + already linked to a DIFFERENT identity
│ → refuse (`already_linked_elsewhere`)
├─ >1 match (ambiguous under +alias normalization)
│ → refuse (`email_ambiguous`)
└─ 0 matches
→ existing JIT-provisioning branch (creates a fresh user)
```
**Refusals fall through to the current "contact admin" error page**
(same shape as before this feature). Users can then self-serve via the
link flow below, or the admin can intervene.
### Security model — why auto-link is safe here
The classic account-takeover attack: attacker creates a rogue IdP
account with the victim's email → OIDC login → auto-link → hijacks the
victim's OxiCloud account.
Mitigation is the industry-standard **`email_verified=true` gate**: the
IdP itself has verified the user controls the email, so the attacker
can't just claim any email in their own IdP account.
Safe in OxiCloud's current single-IdP model because:
- Admin explicitly configures ONE trusted IdP (`OXICLOUD_OIDC_ISSUER_URL`)
- Same trust chain as JIT auto-provisioning today (which we already
gate on `email_verified` when `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true`)
- The IdP is chosen by the admin, not the user
**Explicitly NOT safe** for the future multi-IdP federated login
(`docs/plan/federated-login.md` — any WebFinger-discovered IdP is
accepted). That flow needs different rules: allowlisted-IdP-only
auto-link, or no auto-link at all. Deferred until that lands.
### Config knob
```
OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH=true # default TRUE
```
Ships enabled — it's the good UX. Admins with compliance requirements
that mandate explicit consent for every link opt out. `false` restores
the "refuse with contact admin" behavior; self-service link flow (below)
remains available.
Documented as a deployment-config knob in **THREE places** — all must
be updated when this env var lands (per the project convention that
every env var appears in every config-reference surface):
- `example.env` — commented entry under the OIDC block with the
default value + one-sentence explanation
- `docs/config/env.md` — table row in the OIDC section
- `docs/config/oidc.md` — narrative paragraph explaining the
auto-link decision tree and security model (short form of the
section above)
## UX flow — link
1. User logged in via password/OPAQUE lands on `/profile`
2. Sees a **"Connect Single Sign-On"** card, visible when
`oidc.enabled && !user.federation_kind`
3. Clicks button → SPA `POST /api/auth/oidc/link/start` (authenticated)
→ backend mints a state token, stores a `pending_oidc_flow` entry with
`intent = Link { user_id }`, returns `{ authorize_url }`
4. Full-page navigation to the IdP → user authenticates as themselves
5. IdP redirects back to `/api/auth/oidc/callback?code=&state=`
6. Callback recognises the `Link` intent (via state cache lookup) →
exchanges code → validates id_token → runs safety checks (below) →
UPDATE user row
7. Redirect to `/profile?linked=1` — SPA shows a toast and strips the
query param
## UX flow — unlink
1. User (currently OIDC-linked AND with alternative auth wired) sees a
**"Disconnect Single Sign-On"** card on `/profile`
2. Clicks button → SPA `POST /api/auth/oidc/unlink`
3. Backend refuses if the user has no other auth method (see below)
4. On success: profile refreshes, `federation_kind`/`issuer`/`subject`
become `null`, "Connect SSO" card takes over the space
## Safety checks — link callback
Ran BEFORE the UPDATE. Any refusal returns
`/profile?link_error=<stable-key>` with the reason logged internally.
| Check | Refuse reason | Rationale |
|---|---|---|
| Session valid (state's `user_id` matches an active session) | `session_expired` | Cookie invalidated during the IdP round-trip; treat as auth failure |
| IdP-returned email matches OxiCloud email after normalization | `email_mismatch` | Prevents "link Bob's identity to my account, then Bob logs in via OIDC and lands here" |
| IdP provided an email at all | `email_not_provided` | Without email we can't verify identity ownership — refuse |
| Identity `(kind, iss, sub)` not already linked to a DIFFERENT user | `already_linked_elsewhere` | Prevents linking the same OIDC identity to two OxiCloud accounts |
| Current user isn't linked to a DIFFERENT identity | `already_linked` | User must unlink first — no silent identity swap |
| Same identity as currently-linked → idempotent success | (no error) | Repeat link is a no-op success |
Optional deferred: step-up auth (require fresh password/OPAQUE
verification within the last N minutes before starting link). Guards
against session-theft → link-attack. Add if we care.
## Email normalization
`common::text::normalize_email_for_link`:
```rust
pub fn normalize_email_for_link(email: &str) -> String {
let lower = email.trim().to_ascii_lowercase();
let Some((local, domain)) = lower.split_once('@') else {
return lower;
};
// Strip +alias sub-addressing (Gmail / Outlook / Fastmail / etc.):
// alice+github@example.com → alice@example.com
let local_base = local.split_once('+').map(|(b, _)| b).unwrap_or(local);
format!("{}@{}", local_base, domain)
}
```
**NOT** doing dot-stripping (Gmail-only, causes false positives on other
providers). NOT doing Unicode normalization (email addresses compare as
ASCII-normalized already).
Behavior matrix:
| OxiCloud email | IdP email | Match? |
|---|---|---|
| `alice@example.com` | `alice@example.com` | ✅ |
| `alice@example.com` | `Alice@example.com` | ✅ (case) |
| `alice@example.com` | `alice+oidc@example.com` | ✅ (alias) |
| `alice+work@example.com` | `alice@example.com` | ✅ (alias both) |
| `alice@example.com` | `bob@example.com` | ❌ |
| `alice@example.com` | (missing) | ❌ (`email_not_provided`) |
Legitimate-but-refused cases (documented, admin unlinks+relinks):
- User changed email on IdP but not on OxiCloud
- User's IdP email uses a different domain than OxiCloud email
## Unlink refusal — retain a working direct login
`POST /api/auth/oidc/unlink` refuses when the user has **no other
credential** to log in with:
```
if !user.has_password() && !user.opaque_registered() {
return AccessDenied("cannot_unlink_no_alternative_auth");
}
```
Rationale: OIDC-only account unlinking creates a passwordless account
with no OIDC either → the user can't log in AT ALL. Magic-link isn't a
safe fallback since (a) it's gated by SMTP wiring and (b) the
OIDC-master rule wouldn't refuse it AFTER unlink but does BEFORE, so
users could be surprised by inconsistent behavior. Refusing at the API
layer forces the user to add a password first (via profile change-
password card) before unlinking.
`opaque_registered` counts as an alternative because an OPAQUE envelope
IS a login credential.
## Wire changes — endpoints
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
| `POST` | `/api/auth/oidc/link/start` | Bearer/cookie | `{}` | `{ authorize_url: "..." }` |
| `POST` | `/api/auth/oidc/unlink` | Bearer/cookie | `{}` | `200 OK` or `403` |
| `GET` | `/api/auth/oidc/callback` | Public | — | Extended: `?intent=link` cases redirect to `/profile?...` |
## Pending-flow cache extension
Existing `AuthApplicationService::pending_oidc_flows: Cache<String, PendingOidcFlow>`
gains an `intent` field:
```rust
enum FlowIntent {
Login,
Link { user_id: Uuid },
}
struct PendingOidcFlow {
pkce_verifier: String,
nonce: String,
nc_flow_token: Option<String>,
intent: FlowIntent,
}
```
Default `Login` preserves existing behavior. `Link` is set by
`prepare_oidc_link(user_id)`. The callback dispatches on the intent
variant.
## Repo methods
```rust
async fn link_federation_identity(
&self, user_id: Uuid,
kind: &str, issuer: &str, subject: &str,
) -> Result<(), UserRepositoryError>;
// Returns AlreadyExists on UNIQUE(kind, issuer, subject) violation —
// app service translates to `already_linked_elsewhere`.
async fn unlink_federation_identity(
&self, user_id: Uuid,
) -> Result<(), UserRepositoryError>;
// UPDATE ... SET federation_kind = NULL, federation_issuer = NULL,
// federation_subject = NULL WHERE id = $1.
```
## Audit events
- `federation.link_started` — user_id, intent (self-service flow only)
- `federation.link_completed` — user_id, kind, issuer, subject
- `federation.link_refused` — user_id, reason (stable enum-shaped key:
`session_expired`, `email_mismatch`, `email_not_provided`,
`already_linked_elsewhere`, `already_linked`)
- `federation.auto_linked` — user_id, kind, issuer, subject,
reason=`email_match_verified` (fired by the auto-link branch on the
OIDC callback path — NOT by the self-service link flow)
- `federation.auto_link_refused` — user_id (if resolvable), reason
(`auto_link_disabled`, `auto_link_email_not_verified`,
`email_ambiguous`, `already_linked_elsewhere`)
- `federation.unlinked` — user_id
- `federation.unlink_refused` — user_id, reason (=`no_alternative_auth`)
Same anti-drift discipline as other structured audit events (per
`feedback_enum_over_string_literals_in_logs`).
## Hurl test coverage
`tests/oidc/link_unlink.hurl` under the OIDC runner:
**Auto-link scenarios** (OIDC login path with email match):
1. **Auto-link happy path** — Alice has a local account
`alice@example.com`; the fake IdP is set to return that email with
`email_verified=true`; Alice clicks "Sign in with SSO" → login
completes → GET `/api/auth/me` shows `federation_kind = "oidc"` +
correct issuer/subject. Audit line
`event="federation.auto_linked", reason="email_match_verified"`
emitted.
2. **Auto-link refused — email_verified=false** — fake IdP returns
the matching email but with `email_verified=false`; login refuses
with `auto_link_email_not_verified`.
3. **Auto-link refused — normalized email ambiguity** — two OxiCloud
users exist (`alice@example.com` AND `alice+work@example.com`);
fake IdP returns `alice@example.com`; both normalize to the same
value; login refuses with `email_ambiguous`.
4. **Auto-link disabled by config** — separate suite with
`OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH=false`; email match no longer
auto-links; existing "contact admin" refusal returns. (Optional
Phase-2 test — env-var flip requires a separate server boot.)
**Self-service link scenarios** (`POST /link/start` from an
authenticated session):
5. **Self-service happy path** — Alice logs in via password, POSTs
`/link/start`, follows the authorize URL, IdP returns matching
email, callback completes link, redirect to `/profile?linked=1`.
6. **Email mismatch refuse** — Alice starts link, `/control/set-email`
on the fake IdP flips to `bob@example.com`; callback refuses,
redirects to `/profile?link_error=email_mismatch`.
7. **+alias normalization link** — Alice's OxiCloud email is
`alice@example.com`, fake IdP returns `alice+oidc@example.com` —
link succeeds (both normalize to `alice@example.com`).
8. **Already linked elsewhere** — Alice links; Bob logs in and starts
link; fake IdP returns Alice's identity (same sub); refused with
`already_linked_elsewhere`.
**Unlink scenarios:**
9. **Unlink success** — Alice (linked via any prior scenario) POSTs
`/unlink`; refresh shows `federation_kind = null`; Alice can still
log in via password.
10. **Unlink refused** — a user with only OIDC (no password, no
OPAQUE) tries to unlink; refused with `no_alternative_auth`.
## FE changes
`frontend/src/routes/profile/+page.svelte`:
- Import `getOidcProviders` (existing) to know if OIDC is enabled AND
to resolve the display name.
- "Connect Single Sign-On" card: visible when
`providers.enabled && !user.federation_kind`. Button → POST
`/api/auth/oidc/link/start` → `window.location.assign(response.authorize_url)`.
- "Disconnect Single Sign-On" card: visible when
`user.federation_kind === 'oidc' && (has_password || opaque_registered)`.
Button → POST `/api/auth/oidc/unlink` → refresh session.
- On mount: read `?linked=1` → success toast; read `?link_error=<key>` →
error toast with localized message per key; strip query params via
`history.replaceState`.
## Scope / non-scope
**In scope for the first ship:**
- Link + unlink endpoints + safety checks
- Email normalization + tests
- Hurl coverage for 6 scenarios
- Profile-page UI
**Deferred:**
- Step-up auth before link start
- Admin-mediated link/unlink via `oxicloud-cli federation` (proper for
"user changed IdP email" recovery scenario)
- OCM link (same shape, different kind)
- Multi-federation (multiple linked identities per user — see
ocm.md § Future — multi-federation per user)
+11
View File
@@ -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=
+4 -1
View File
@@ -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"
+45
View File
@@ -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=<reason>`
* (safety-check refusal). See docs/plan/oidc-account-linking.md.
*/
export async function startOidcLink(): Promise<string> {
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<void> {
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<LogoutResult> {
const res = await apiFetch('/api/auth/logout', {
method: 'POST',
+206 -1
View File
@@ -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<string>('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=<reason>` 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;
}
}
</script>
<svelte:head><title>{t('nav.profile', 'Profile')} · OxiCloud</title></svelte:head>
@@ -907,6 +1057,61 @@
</button>
</form>
{/if}
<!--
OIDC identity link / unlink card. See
docs/plan/oidc-account-linking.md § UX flow.
Two mutually-exclusive states: Connect (no federation yet) or
Disconnect (currently OIDC-linked). The Connect button
navigates to the IdP; the Disconnect button unlinks and
refreshes the session. Backend enforces safety checks —
email-match on link, no-alternative-auth refusal on unlink.
-->
{#if canConnectSso}
<section class="card sso-card" data-testid="profile-sso-connect-card">
<h2><Icon name="key" /> {t('profile.sso_connect_title', 'Connect Single Sign-On')}</h2>
<p>
{t(
'profile.sso_connect_description',
{ provider: oidcProviderName },
'Link your account to {{provider}} so you can sign in with SSO instead of your password.'
)}
</p>
<button
type="button"
data-testid="profile-sso-connect-btn"
onclick={onConnectSso}
disabled={ssoBusy}
>
{t(
'profile.sso_connect_button',
{ provider: oidcProviderName },
'Connect with {{provider}}'
)}
</button>
</section>
{:else if canDisconnectSso}
<section class="card sso-card" data-testid="profile-sso-disconnect-card">
<h2><Icon name="key" /> {t('profile.sso_disconnect_title', 'Single Sign-On')}</h2>
<p>
{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.'
)}
</p>
<button
type="button"
class="btn-danger"
data-testid="profile-sso-disconnect-btn"
onclick={onDisconnectSso}
disabled={ssoBusy}
>
{t('profile.sso_disconnect_button', 'Disconnect Single Sign-On')}
</button>
</section>
{/if}
{:else}
<p>{t('common.loading', 'Loading…')}</p>
{/if}
+37
View File
@@ -193,6 +193,43 @@ pub trait UserStoragePort: Send + Sync + 'static {
new_issuer: &str,
) -> Result<(), DomainError>;
/// Attach a federation identity to a user row that currently has
/// none. Used by the self-service link flow and the auto-link
/// branch of the OIDC callback. See
/// docs/plan/oidc-account-linking.md.
///
/// Enforces at the DB layer via the
/// `idx_users_federation` UNIQUE index: if this triple is already
/// bound to a DIFFERENT user, returns `AlreadyExists`. The caller
/// (app service) translates that to a `already_linked_elsewhere`
/// audit reason and a user-facing refusal.
///
/// Does NOT overwrite an already-linked identity — the current
/// user must be unlinked first. This is a "first link" primitive
/// only; the app service's higher-level `link_oidc` orchestrates
/// the pre-checks (idempotent-if-same / refuse-if-different).
async fn link_federation_identity(
&self,
user_id: Uuid,
kind: &str,
issuer: &str,
subject: &str,
) -> Result<(), DomainError>;
/// Scalar `opaque_envelope IS NOT NULL` for the user. Used by the
/// unlink refusal guard (a user with an OPAQUE envelope still has
/// a working direct login even after OIDC unlink). Avoids
/// dragging the full envelope bytes across the wire for a bool.
async fn is_opaque_registered(&self, user_id: Uuid) -> Result<bool, DomainError>;
/// Detach the current federation identity from a user row: set all
/// three federation columns to NULL. The `has_password` or
/// `opaque_registered` fallback guard lives at the app service
/// layer — this method is a mechanical UPDATE.
///
/// Idempotent: calling on an already-unlinked user is a no-op.
async fn unlink_federation_identity(&self, user_id: Uuid) -> Result<(), DomainError>;
/// Lists users by role (e.g., "admin" or "user")
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
@@ -53,6 +53,10 @@ impl AdminSettingsService {
("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"),
("OXICLOUD_OIDC_SCOPES", "scopes"),
("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"),
(
"OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH",
"auto_link_email_match",
),
("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"),
(
"OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN",
@@ -95,6 +99,9 @@ impl AdminSettingsService {
if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() {
config.auto_provision = e.auto_provision;
}
if std::env::var("OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH").is_ok() {
config.auto_link_email_match = e.auto_link_email_match;
}
if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() {
config.admin_groups = e.admin_groups.clone();
}
@@ -141,6 +148,10 @@ impl AdminSettingsService {
.get("oidc.provider_name")
.cloned()
.unwrap_or(d.provider_name),
auto_link_email_match: db
.get("oidc.auto_link_email_match")
.and_then(|v| v.parse().ok())
.unwrap_or(d.auto_link_email_match),
};
// Env vars override DB
@@ -42,6 +42,20 @@ pub enum OidcCallbackResult {
user_id: Uuid,
username: String,
},
/// Self-service link flow completed — the OIDC identity was
/// attached to the already-authenticated user. Handler redirects
/// the browser to `/profile?linked=1` (or `?link_error=<reason>`
/// on the `LinkRefused` variant below).
///
/// The user's existing session cookies remain valid (no new session
/// is minted for the link flow — the user was already logged in
/// when they started).
LinkCompleted { user_id: Uuid },
/// Self-service link refused by a safety check. `reason` is the
/// stable enum-shaped key the handler surfaces on the
/// `/profile?link_error=<reason>` redirect. See
/// docs/plan/oidc-account-linking.md § Safety checks.
LinkRefused { reason: &'static str },
}
/// Outcome of a successful magic-link redemption. The auth tokens are
@@ -93,6 +107,21 @@ pub struct MagicLinkRedemption {
pub resource_id: Option<Uuid>,
}
/// Why an OIDC flow was initiated — dispatched on at callback time.
///
/// `Login` (default) → normal login: JIT-provision or match existing
/// user, mint OxiCloud session.
///
/// `Link { user_id }` → self-service identity link
/// (`POST /api/auth/oidc/link/start`). The callback runs safety checks
/// and, on success, UPDATEs `federation_*` on the ALREADY-LOGGED-IN
/// user's row. See docs/plan/oidc-account-linking.md.
#[derive(Clone)]
enum FlowIntent {
Login,
Link { user_id: Uuid },
}
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
#[derive(Clone)]
struct PendingOidcFlow {
@@ -102,6 +131,10 @@ struct PendingOidcFlow {
/// page. On successful callback the flow will mint an app-password and
/// complete the Nextcloud login flow instead of issuing internal JWTs.
nc_flow_token: Option<String>,
/// What the callback should DO with a successful IdP response.
/// Defaults to `Login` for every existing flow-mint call site;
/// self-service linking sets `Link { user_id }`.
intent: FlowIntent,
}
/// Tracks a pending one-time token exchange after successful OIDC callback
@@ -3154,6 +3187,7 @@ impl AuthApplicationService {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: None,
intent: FlowIntent::Login,
},
);
@@ -3170,6 +3204,296 @@ impl AuthApplicationService {
Ok(authorize_url)
}
/// Prepare an OIDC authorize flow for the SELF-SERVICE LINK path.
/// Same PKCE + nonce dance as `prepare_oidc_authorize`, but the
/// pending-flow entry carries `FlowIntent::Link { user_id }` so the
/// callback branches to the link handler instead of the login one.
///
/// The caller MUST have already authenticated the user (this method
/// takes user_id from the current session context). See
/// docs/plan/oidc-account-linking.md § UX flow — link.
pub async fn prepare_oidc_link(&self, user_id: Uuid) -> Result<String, DomainError> {
let oidc = self.oidc_service().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"OIDC",
"OIDC service not configured",
)
})?;
// Anti-scope-creep pre-check: refuse if the user is already
// linked. Callers get an immediate error rather than round-
// tripping through the IdP just to be refused at callback time.
// (The callback still re-checks — this is a UX shortcut, not
// the source of truth.)
let user = self.user_storage.get_user_by_id(user_id).await?;
if user.federation_kind().is_some() {
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"Federation",
"This user is already linked to a federation identity. \
Unlink first before re-linking.",
));
}
use rand_core::{OsRng, RngCore};
use sha2::{Digest, Sha256};
let mut state_bytes = [0u8; 32];
OsRng.fill_bytes(&mut state_bytes);
let state_token = hex::encode(state_bytes);
let mut nonce_bytes = [0u8; 32];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = hex::encode(nonce_bytes);
let mut verifier_bytes = [0u8; 32];
OsRng.fill_bytes(&mut verifier_bytes);
let pkce_verifier = base64_url_encode(&verifier_bytes);
let pkce_challenge = {
let hash = Sha256::digest(pkce_verifier.as_bytes());
base64_url_encode(&hash)
};
self.pending_oidc_flows.insert(
state_token.clone(),
PendingOidcFlow {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: None,
intent: FlowIntent::Link { user_id },
},
);
let authorize_url = oidc
.get_authorize_url(&state_token, &nonce, &pkce_challenge)
.await?;
tracing::info!(
target: "audit",
event = "federation.link_started",
user_id = %user_id,
"🔗 self-service OIDC link flow initiated"
);
Ok(authorize_url)
}
/// Detach the current OIDC identity from a user. Refuses if the
/// user has no other authentication credential — otherwise the
/// user would lock themselves out of their own account.
///
/// "Other credential" = local password OR OPAQUE envelope on file.
/// Magic-link doesn't count as a safe fallback: the OIDC-master
/// rule refuses magic-link for OIDC-linked users, so its behavior
/// FLIPS after unlink, creating surprise; and it depends on SMTP
/// wiring which may not be present. See
/// docs/plan/oidc-account-linking.md § Unlink refusal.
/// Run the safety checks + UPDATE for the self-service link flow.
/// Called from `oidc_callback` when `FlowIntent::Link { user_id }`
/// was set at flow-start time. Returns `OidcCallbackResult` variants
/// that the handler translates to a redirect (LinkCompleted →
/// `/profile?linked=1`, LinkRefused → `/profile?link_error=<key>`).
///
/// Safety checks (all refusals are wire-visible as `link_error=`):
/// - Session valid — target user exists (state's user_id points to
/// a real row). If not, `session_expired`.
/// - IdP provided an email — else `email_not_provided`.
/// - Emails match under normalize_email_for_link — else
/// `email_mismatch`.
/// - Identity `(kind, iss, sub)` not already linked to a DIFFERENT
/// user — else `already_linked_elsewhere`.
/// - Current user isn't already linked to a DIFFERENT identity —
/// else `already_linked`. Same identity → idempotent success.
async fn complete_oidc_link(
&self,
user_id: Uuid,
claims: &OidcIdClaims,
) -> Result<OidcCallbackResult, DomainError> {
use crate::common::text::normalize_email_for_link;
// 1. Session validity — the target user must still exist.
let user = match self.user_storage.get_user_by_id(user_id).await {
Ok(u) => u,
Err(_) => {
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
reason = "session_expired",
"🔗 link refused — target user not found (session may have ended)",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "session_expired",
});
}
};
// 2. IdP must provide an email — without it we can't verify
// ownership.
let idp_email = match claims.email.as_ref() {
Some(e) => e,
None => {
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
reason = "email_not_provided",
"🔗 link refused — IdP did not return an email claim",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "email_not_provided",
});
}
};
// 3. Email match under +alias normalization.
if normalize_email_for_link(idp_email) != normalize_email_for_link(user.email()) {
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
reason = "email_mismatch",
oxicloud_email_normalized = %normalize_email_for_link(user.email()),
idp_email_normalized = %normalize_email_for_link(idp_email),
"🔗 link refused — IdP email doesn't match OxiCloud user email",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "email_mismatch",
});
}
// 4. Idempotent-if-same / refuse-if-different: check the current
// user's link state before we touch it.
match (
user.federation_kind(),
user.federation_issuer(),
user.federation_subject(),
) {
(None, None, None) => {
// Fresh — proceed to link.
}
(Some(kind), Some(iss), Some(sub))
if kind.as_str() == "oidc" && iss == claims.iss && sub == claims.sub =>
{
// Same identity → idempotent no-op success.
tracing::info!(
target: "audit",
event = "federation.link_completed",
user_id = %user_id,
reason = "idempotent_repeat",
federation_issuer = %claims.iss,
federation_subject = %claims.sub,
"🔗 link no-op — user already linked to this same identity",
);
return Ok(OidcCallbackResult::LinkCompleted { user_id });
}
_ => {
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
reason = "already_linked",
"🔗 link refused — user already linked to a different identity; unlink first",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "already_linked",
});
}
}
// 5. Identity not already linked to a DIFFERENT user. The
// UNIQUE(kind, issuer, subject) index would catch this at
// UPDATE time via link_federation_identity's AlreadyExists
// error, but we pre-check to emit a clean audit line and
// avoid the "AlreadyExists on user" confusion in the
// downstream error mapping.
if let Ok(other) = self
.user_storage
.get_user_by_federation_subject(&claims.iss, &claims.sub)
.await
&& other.id() != user_id
{
tracing::info!(
target: "audit",
event = "federation.link_refused",
user_id = %user_id,
other_user_id = %other.id(),
reason = "already_linked_elsewhere",
"🔗 link refused — this OIDC identity is already linked to a different OxiCloud user",
);
return Ok(OidcCallbackResult::LinkRefused {
reason: "already_linked_elsewhere",
});
}
// All checks passed — commit the link.
self.user_storage
.link_federation_identity(user_id, "oidc", &claims.iss, &claims.sub)
.await?;
tracing::info!(
target: "audit",
event = "federation.link_completed",
user_id = %user_id,
federation_kind = "oidc",
federation_issuer = %claims.iss,
federation_subject = %claims.sub,
"🔗 self-service OIDC link completed",
);
Ok(OidcCallbackResult::LinkCompleted { user_id })
}
pub async fn unlink_oidc(&self, user_id: Uuid) -> Result<(), DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
// Idempotent: unlinking an already-unlinked user is a success.
if user.federation_kind().is_none() {
tracing::info!(
target: "audit",
event = "federation.unlinked",
user_id = %user_id,
already_unlinked = true,
"🔗 unlink no-op — user was not linked"
);
return Ok(());
}
// The guard. `has_password` reads password_hash.is_some();
// `opaque_registered` needs a separate lookup because the User
// entity doesn't carry that flag today. We do that as a
// targeted query rather than dragging the full opaque_envelope
// column across the wire.
let opaque_registered = self.user_storage.is_opaque_registered(user_id).await?;
if !user.has_password() && !opaque_registered {
tracing::info!(
target: "audit",
event = "federation.unlink_refused",
user_id = %user_id,
reason = "no_alternative_auth",
"👮🏻‍♂️ unlink refused — user has no password/OPAQUE fallback"
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Federation",
"Cannot unlink — set a password first, or you will be locked out.",
));
}
self.user_storage
.unlink_federation_identity(user_id)
.await?;
tracing::info!(
target: "audit",
event = "federation.unlinked",
user_id = %user_id,
"🔗 OIDC identity unlinked"
);
Ok(())
}
/// Prepare an OIDC authorization flow for a Nextcloud Login Flow v2 session.
///
/// Works like [`prepare_oidc_authorize`] but associates the Nextcloud flow
@@ -3213,6 +3537,7 @@ impl AuthApplicationService {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: Some(nc_flow_token.to_string()),
intent: FlowIntent::Login,
},
);
@@ -3268,8 +3593,12 @@ impl AuthApplicationService {
));
}
};
let (pkce_verifier, nonce, nc_flow_token) =
(flow.pkce_verifier, flow.nonce, flow.nc_flow_token);
let (pkce_verifier, nonce, nc_flow_token, intent) = (
flow.pkce_verifier,
flow.nonce,
flow.nc_flow_token,
flow.intent,
);
// Clone the Arc and config out of the RwLock so we don't hold the lock across await points
let (oidc, oidc_config) = {
@@ -3329,6 +3658,20 @@ impl AuthApplicationService {
claims
};
// ────────────────────────────────────────────────────────────
// Flow-intent dispatch — if this callback was initiated by
// the self-service link path (`POST /api/auth/oidc/link/start`),
// divert here BEFORE the login-specific processing (email
// verification gate / JIT / session mint). Login stays on the
// fall-through path. See docs/plan/oidc-account-linking.md.
// ────────────────────────────────────────────────────────────
if let FlowIntent::Link {
user_id: target_user_id,
} = intent
{
return self.complete_oidc_link(target_user_id, &claims).await;
}
let provider_name = oidc.provider_name().to_string();
// Email-verification gate. The operator flag
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is the master switch — an
@@ -3492,11 +3835,48 @@ 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
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();
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",
@@ -3507,7 +3887,39 @@ impl AuthApplicationService {
));
}
// No match — JIT provision if enabled
// 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 {
// No email match — JIT provision (existing behavior).
if !oidc_config.auto_provision {
return Err(DomainError::new(
ErrorKind::AccessDenied,
@@ -3532,7 +3944,9 @@ impl AuthApplicationService {
// 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 == '.')
.filter(|c| {
c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.'
})
.take(32)
.collect::<String>();
@@ -3631,6 +4045,7 @@ impl AuthApplicationService {
created_user
}
}
};
// ── Branch: Nextcloud Login Flow v2 vs regular web login ──
+16
View File
@@ -1628,6 +1628,15 @@ pub struct OidcConfig {
pub disable_password_login: bool,
/// OIDC provider display name (shown in UI)
pub provider_name: String,
/// When TRUE (default), an OIDC login whose subject doesn't match
/// any existing user AUTO-LINKS to the local user with the same
/// verified email address (if any). Requires `email_verified=true`
/// from the IdP. See docs/plan/oidc-account-linking.md § Auto-link.
///
/// Set FALSE for compliance postures that require explicit consent
/// for every OIDC linkage. Self-service link flow still works
/// regardless of this flag.
pub auto_link_email_match: bool,
}
impl Default for OidcConfig {
@@ -1644,6 +1653,7 @@ impl Default for OidcConfig {
admin_groups: String::new(),
disable_password_login: false,
provider_name: "SSO".to_string(),
auto_link_email_match: true,
}
}
}
@@ -1852,6 +1862,9 @@ impl OidcConfig {
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") {
cfg.auto_provision = v.parse::<bool>().unwrap_or(true);
}
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH") {
cfg.auto_link_email_match = v.parse::<bool>().unwrap_or(true);
}
if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") {
cfg.admin_groups = v;
}
@@ -3432,6 +3445,9 @@ impl AppConfig {
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") {
config.oidc.auto_provision = v.parse::<bool>().unwrap_or(true);
}
if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH") {
config.oidc.auto_link_email_match = v.parse::<bool>().unwrap_or(true);
}
if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") {
config.oidc.admin_groups = v;
}
+112
View File
@@ -20,6 +20,39 @@ pub fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool {
.any(|w| w.eq_ignore_ascii_case(needle))
}
/// Normalize an email address for **linking-equivalence comparison**
/// (NOT for storage — never modify what the user typed when persisting
/// or displaying).
///
/// Rules:
/// - Case-fold to ASCII lowercase (email addresses are treated as
/// case-insensitive in practice per RFC 5321 §2.4).
/// - Strip `+alias` sub-addressing from the local part:
/// `alice+github@example.com` → `alice@example.com`. Supported by
/// Gmail / Google Workspace, Outlook/O365 (since 2018), Fastmail
/// (since ~2020), and most modern providers. Safe for a 1:1
/// comparison — two same-user addresses normalise to the same value.
///
/// NOT doing:
/// - Dot-stripping (Gmail-only: `a.lice@gmail.com == alice@gmail.com`).
/// Applying universally would false-positive on providers that treat
/// dots as significant.
/// - Unicode normalisation — email addresses compare as ASCII already.
///
/// Load-bearing for `POST /api/auth/oidc/link/start` → callback and
/// for the auto-link decision on OIDC login. See
/// docs/plan/oidc-account-linking.md § Safety checks.
pub fn normalize_email_for_link(email: &str) -> String {
let lower = email.trim().to_ascii_lowercase();
let Some((local, domain)) = lower.split_once('@') else {
// Malformed — return the lowercased form; caller's comparison
// will fail naturally.
return lower;
};
let local_base = local.split_once('+').map(|(b, _)| b).unwrap_or(local);
format!("{}@{}", local_base, domain)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -51,4 +84,83 @@ mod tests {
fn empty_needle_is_true() {
assert!(ascii_ci_contains(b"anything", b""));
}
#[test]
fn normalize_email_for_link_matrix() {
// Behaviour matrix from docs/plan/oidc-account-linking.md
// § Email normalization. Left = raw, right = expected normalized.
let cases: &[(&str, &str)] = &[
// Identity
("alice@example.com", "alice@example.com"),
// Case fold
("Alice@Example.COM", "alice@example.com"),
// +alias stripped
("alice+github@example.com", "alice@example.com"),
("alice+oidc@example.com", "alice@example.com"),
// Both sides of a match normalise the same way
("alice+work@example.com", "alice@example.com"),
// Empty +alias suffix is still stripped
("alice+@example.com", "alice@example.com"),
// Multiple + in local: everything after the FIRST + is dropped
("alice+work+extra@example.com", "alice@example.com"),
// Trim leading/trailing whitespace
(" alice@example.com ", "alice@example.com"),
// Different local parts stay different
("bob@example.com", "bob@example.com"),
// Different domains stay different (no cross-domain equivalence)
("alice@corp.com", "alice@corp.com"),
// Domain case-folded too
("alice@Example.COM", "alice@example.com"),
];
for (raw, expected) in cases {
assert_eq!(
normalize_email_for_link(raw),
*expected,
"normalize_email_for_link({raw:?}) should equal {expected:?}"
);
}
}
#[test]
fn normalize_email_link_equivalence_pairs() {
// Anti-drift: pairs that MUST compare equal after normalization
// (the "auto-link email match" cases the plan doc lists as ✅).
let equivalent: &[(&str, &str)] = &[
("alice@example.com", "alice@example.com"),
("alice@example.com", "Alice@example.com"),
("alice@example.com", "alice+oidc@example.com"),
("alice+work@example.com", "alice@example.com"),
("alice+work@example.com", "alice+home@example.com"),
];
for (left, right) in equivalent {
assert_eq!(
normalize_email_for_link(left),
normalize_email_for_link(right),
"{left:?} should equal {right:?} under linking normalization"
);
}
// Pairs that MUST NOT match — the plan's ❌ cases.
let distinct: &[(&str, &str)] = &[
("alice@example.com", "bob@example.com"),
("alice@example.com", "alice@corp.com"),
// Dot-stripping deliberately NOT applied — dots stay significant.
("a.lice@gmail.com", "alice@gmail.com"),
];
for (left, right) in distinct {
assert_ne!(
normalize_email_for_link(left),
normalize_email_for_link(right),
"{left:?} MUST NOT equal {right:?} — dot-stripping is Gmail-only, we don't apply it"
);
}
}
#[test]
fn normalize_email_malformed_returns_lowercased() {
// No `@` → return lowercased trimmed form; the caller's
// downstream comparison will fail naturally.
assert_eq!(normalize_email_for_link("not-an-email"), "not-an-email");
assert_eq!(normalize_email_for_link(" MIXED-Case "), "mixed-case");
}
}
+2 -1
View File
@@ -355,7 +355,8 @@ impl User {
// only fires on inconsistent partial state.
if federation_issuer.is_some() != federation_subject.is_some() {
return Err(UserError::ValidationError(
"federation_issuer and federation_subject must both be set or both be None".to_string(),
"federation_issuer and federation_subject must both be set or both be None"
.to_string(),
));
}
// If either field is set, federation_kind MUST also be set — the
@@ -1402,6 +1402,105 @@ impl UserStoragePort for UserPgRepository {
Ok(())
}
async fn link_federation_identity(
&self,
user_id: Uuid,
kind: &str,
issuer: &str,
subject: &str,
) -> Result<(), DomainError> {
// Guarded UPDATE: only proceed when the row currently has NO
// federation identity. Prevents accidental identity overwrite —
// callers wanting to replace an existing link must go through
// unlink first. Silent no-op on already-linked rows is WRONG
// because it would swallow the intent; instead we return an
// error the app service translates to `already_linked`.
//
// Uniqueness enforcement lives on `idx_users_federation`
// (UNIQUE(kind, issuer, subject) WHERE federation_kind IS NOT
// NULL). If this triple is already bound to a DIFFERENT user,
// the UPDATE succeeds row-count = 0 (the WHERE constrains us to
// rows for THIS user_id) — but the following INSERT-shaped
// UPDATE approach doesn't trigger the unique index; we rely on
// the app service having pre-checked via
// `get_user_by_federation_subject`. If that pre-check races
// with a concurrent link (rare), the second call surfaces
// `AlreadyExists` from sqlx via `map_sqlx_error`.
let result = sqlx::query(
r#"
UPDATE auth.users
SET federation_kind = $2,
federation_issuer = $3,
federation_subject = $4,
updated_at = NOW()
WHERE id = $1
AND federation_kind IS NULL
"#,
)
.bind(user_id)
.bind(kind)
.bind(issuer)
.bind(subject)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
if result.rows_affected() == 0 {
// Either the user doesn't exist OR they already have a
// federation identity attached. The app service should have
// already validated user existence + link state; being here
// usually means a concurrent link race.
return Err(DomainError::already_exists(
"User",
"user is already linked to a federation identity",
));
}
Ok(())
}
async fn is_opaque_registered(&self, user_id: Uuid) -> Result<bool, DomainError> {
// Scalar `IS NOT NULL` check — the envelope is a few hundred
// bytes of ciphertext; we don't want to fetch it just to
// examine presence. `fetch_optional` returns None if the user
// doesn't exist (caller treats missing as "not registered").
let row: Option<(bool,)> = sqlx::query_as(
r#"
SELECT (opaque_envelope IS NOT NULL)
FROM auth.users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_optional(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
Ok(row.map(|(v,)| v).unwrap_or(false))
}
async fn unlink_federation_identity(&self, user_id: Uuid) -> Result<(), DomainError> {
// Idempotent: unlinking an already-unlinked user is a zero-row
// UPDATE. App service's `no_alternative_auth` refusal guard
// runs BEFORE this — the DB layer just moves the columns.
sqlx::query(
r#"
UPDATE auth.users
SET federation_kind = NULL,
federation_issuer = NULL,
federation_subject = NULL,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
Ok(())
}
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError> {
UserRepository::list_users_by_role(self, role)
.await
@@ -1574,7 +1673,10 @@ mod integration_tests {
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240);
assert_eq!(page[1].username, None);
assert!(page[1].is_external);
assert_eq!(page[1].federation_issuer.as_deref(), Some("integration-idp"));
assert_eq!(
page[1].federation_issuer.as_deref(),
Some("integration-idp")
);
let internal = UserRepository::list_user_summaries(&repo, 10, 0, false)
.await
+113
View File
@@ -51,6 +51,10 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
.route("/change-password", put(change_password))
.route("/upgrade-to-internal", post(upgrade_to_internal))
.route("/logout", post(logout))
// Self-service OIDC identity linking — see
// docs/plan/oidc-account-linking.md.
.route("/oidc/link/start", post(oidc_link_start))
.route("/oidc/unlink", post(oidc_unlink))
}
/// Rate-limited auth routes, split out so main.rs can apply per-endpoint
@@ -1380,6 +1384,91 @@ pub async fn oidc_authorize(
Ok(Redirect::temporary(&authorize_url))
}
/// Start a self-service OIDC linking flow for the currently-authenticated
/// user. Returns the authorize URL for the SPA to `window.location`
/// navigate to. Callback lands on the standard `/api/auth/oidc/callback`
/// which dispatches to the link branch based on the state cache's
/// `intent` field. See docs/plan/oidc-account-linking.md.
#[utoipa::path(
post,
path = "/api/auth/oidc/link/start",
responses(
(status = 200, description = "Authorize URL to navigate the user to", body = serde_json::Value),
(status = 401, description = "Not authenticated"),
(status = 404, description = "OIDC not enabled"),
(status = 409, description = "User is already linked — unlink first"),
),
security(("bearerAuth" = [])),
tag = "auth"
)]
pub async fn oidc_link_start(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_app = &auth_service.auth_application_service;
if !auth_app.oidc_enabled() {
return Err(AppError::new(
StatusCode::NOT_FOUND,
"OIDC is not enabled",
"OidcDisabled",
));
}
let authorize_url = auth_app.prepare_oidc_link(user_id).await?;
Ok(Json(serde_json::json!({
"authorize_url": authorize_url,
})))
}
/// Unlink the current user's OIDC identity. Refuses when the user has
/// no other credential (password / OPAQUE) — see plan doc for the
/// no-alternative-auth guard rationale.
#[utoipa::path(
post,
path = "/api/auth/oidc/unlink",
responses(
(status = 200, description = "OIDC identity unlinked (or was already unlinked)"),
(status = 401, description = "Not authenticated"),
(status = 403, description = "Refused — user has no other credential and would be locked out"),
),
security(("bearerAuth" = [])),
tag = "auth"
)]
pub async fn oidc_unlink(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
// Translate the app-service's generic AccessDenied refusal into a
// stable machine-readable `error_type` the SPA can switch on to
// render the "set a password first" affordance. The app service
// already emits the audit line with reason=no_alternative_auth;
// this hop maps the domain error to a wire contract.
match auth_service
.auth_application_service
.unlink_oidc(user_id)
.await
{
Ok(()) => Ok(StatusCode::OK),
Err(e) if e.kind == crate::domain::errors::ErrorKind::AccessDenied => Err(AppError::new(
StatusCode::FORBIDDEN,
e.message.clone(),
"NoAlternativeAuth",
)),
Err(e) => Err(e.into()),
}
}
/// Handle the OIDC provider callback.
///
/// Validates the `state` / PKCE / nonce, exchanges the code for tokens, then
@@ -1469,6 +1558,30 @@ pub async fn oidc_callback(
.await,
)
}
// Self-service link flow completion — redirect the user back
// to their profile with a query-param signal the SPA reads on
// mount to render a toast + strip the param via history.
// See docs/plan/oidc-account-linking.md § UX flow — link.
OidcCallbackResult::LinkCompleted { user_id } => {
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!("{}/profile?linked=1", frontend_url);
tracing::info!(
user_id = %user_id,
"OIDC link completed, redirecting to /profile?linked=1"
);
Ok(Redirect::temporary(&redirect_url).into_response())
}
OidcCallbackResult::LinkRefused { reason } => {
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!("{}/profile?link_error={}", frontend_url, reason);
tracing::info!(
reason = reason,
"OIDC link refused, redirecting to /profile?link_error"
);
Ok(Redirect::temporary(&redirect_url).into_response())
}
}
}
+2
View File
@@ -80,6 +80,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::auth_handler::oidc_callback,
handlers::auth_handler::oidc_exchange,
handlers::auth_handler::oidc_backchannel_logout,
handlers::auth_handler::oidc_link_start,
handlers::auth_handler::oidc_unlink,
// File handlers (free functions — see file_handler.rs for why)
handlers::file_handler::list_files_query,
handlers::file_handler::upload_file_with_thumbnails,
+37 -1
View File
@@ -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:
+191
View File
@@ -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}}"
+4 -1
View File
@@ -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."