diff --git a/docs/plan/federated-login.md b/docs/plan/federated-login.md new file mode 100644 index 00000000..8e80fa81 --- /dev/null +++ b/docs/plan/federated-login.md @@ -0,0 +1,419 @@ +# Federated Login — Autodiscovery Without RP Registration + +The goal: a user types `alice@example.com` (or a URL), OxiCloud discovers +their identity provider via a well-known probe on `example.com`, initiates +whatever auth flow that provider speaks, and gets back a stable +`(issuer, subject)` pair. **Zero pre-registration of OxiCloud with every +provider.** Fully-meshed federation — no social-login intermediary. + +This is separate from [OCM](./ocm.md) which federates SHARED RESOURCES +between clouds. Federated login federates IDENTITY: authenticating a user +whose home isn't ours. + +## Why "no RP registration" matters + +Every social-login flow today (Google Sign-In, Sign in with Apple, GitHub +OAuth) requires the RP to register upfront in the provider's admin console: +get a client_id, configure redirect URIs, get approval. This works for +centralized RPs but breaks the mesh: every OxiCloud instance would need to +register with every user's IdP. + +The dream is what BrowserID *almost* was — RP discovers identity +provisioning from the user's domain, no bilateral setup, works for anyone +who hosts an identity endpoint. + +## Landscape — sorted by fit and status + +### Alive and directly-useful + +**IndieAuth (W3C, active spec)** — literal answer to the "no registration" +ask. Discovery via HTML `` on the +identity URL. The **RP's URL IS its client identifier** — no registration +step. Auth flow returns a `me` URL as canonical identity. +- Ecosystem: strong in IndieWeb (Aperture, Micropub servers, IndieAuth.com). +- Adoption outside IndieWeb: thin. +- Best for: small-mesh federation of self-hosted OIDC-adjacent IdPs. +- Fails for: users whose IdP is Google / Microsoft. + +**WebFinger (RFC 7033)** — user-supplied discovery. +`GET https://example.com/.well-known/webfinger?resource=acct:alice@example.com` +returns a JRD listing the identity's endpoints, including +`rel="http://openid.net/specs/connect/1.0/issuer"` → OIDC issuer URL. +- NOT a login mechanism, just a discovery layer. +- Deployed at scale (Mastodon uses it for account discovery). +- Pair with OIDC or IndieAuth for the actual login. + +**OIDC Dynamic Client Registration (RFC 7591 + OIDC Registration 1.0)** — +machine-driven RP registration. RP POSTs to `registration_endpoint` +(advertised in OIDC discovery) with its metadata, gets back `client_id` / +`client_secret`. Fully server-to-server, no admin console. +- Support matrix: + - ✅ Keycloak, Authentik, Zitadel, Kanidm, Ory Hydra + - ⚠️ Auth0, Okta (with policy configuration) + - ❌ Google, Microsoft/Entra, Facebook (disabled by policy) +- The pragmatic bridge between "no registration" and "OIDC ecosystem." + Works with essentially every self-hosted OIDC server. + +**OpenID Federation 1.0 (finalized 2024)** — the STANDARDIZED future. +Each entity publishes a signed entity statement at +`/.well-known/openid-federation`; trust chains built through federation +authorities (trust anchors). Designed for RP-IdP federation at scale +without pairwise registration. +- Adoption: EU eIDAS 2.0 wallets, GAIN identity network (banking). +- Complexity: needs a trust-anchor infrastructure to be meaningful. +- Timeline for OxiCloud: watch, don't implement Day 1. + +**Solid-OIDC** — Solid project's OIDC extension where `client_id` is a +dereferenceable URL. Same trick as IndieAuth applied to full OIDC. +- Deployed in Solid ecosystem (Inrupt); minimal outside it. +- Reasonable to support as a variant of the IndieAuth strategy if demand + materialises. + +### Dead or dying — do not build against + +**Mozilla Persona / BrowserID (2011-2016)** — was almost exactly this +plan. RP fetched `example.com/.well-known/browserid`, got the primary +IdP's signing key, redirected user to primary, primary signed an assertion +`{email, iss, sub}`, RP verified signature. Zero RP registration. Elegant +design. **Killed by mainstream mail providers refusing to run primaries**; +Mozilla's fallback (their own primary verifying via email link) undermined +the security story. Lesson for us: any scheme requiring the user's domain +to host an identity endpoint depends on domain operators playing along — +they mostly don't for public email. + +**OpenID 2.0 (2007-2014)** — original OpenID with XRDS discovery, +user-entered URL as identifier, full autodiscovery, no client registration +required. Killed by OIDC (which requires registration). Some legacy +support in Drupal, MediaWiki. Do not build new. + +**XRI / i-names / OpenXRI** — attempted "personal domain" identifiers with +`=name` / `@name` prefixes. Never gained traction. Standards body +dissolved. Dead. + +**WebID+TLS** — Solid predecessor. Client certificate-based, no client_id +needed at all. Academic circles only. Dead outside research. + +**SXIP** — early single-sign-on protocol, pre-OpenID. Dead. + +**Self-Issued OP (SIOP) v1** — wallet-based auth where the user's device +is the IdP. Cool idea, too complicated for a browser-only flow, requires +wallet software installed. **v2 is emerging** in EU eIDAS 2.0 context but +not for our use case. + +**Verifiable Credentials + SIOP v2** — the OIDC-flavored VC world. Right +now: infrastructure-heavy (trust registries, credential formats), user +must have a wallet. Watch for the eIDAS 2.0 rollout in 2026-2027; not yet +right for us. + +### Adjacent but not this problem + +**OCM invitation flow** — federates SHARED RESOURCES, not identity. See +[ocm.md](./ocm.md). Different plan. + +**OAuth 2.0 device flow (RFC 8628)** — solves "log in on a TV using your +phone." Not autodiscovery. + +## OxiCloud strategy — WebFinger + strategy-per-provider + +Compose the alive-and-useful pieces: + +``` +alice@example.com + ↓ +discover(identifier) + ├─ WebFinger probe on example.com (RFC 7033) + │ → JRD lists rel="…/openid/1.0/issuer" → OIDC issuer URL + │ → JRD lists rel="…/indieauth/…" → IndieAuth authorization_endpoint + │ → both = choose OIDC (richer), fall back to IndieAuth on OIDC failure + │ + ├─ If WebFinger returns 404 / no useful rel: + │ HTML fetch of identity URL, parse + │ → IndieAuth flow + │ + └─ Else: hard-fail with actionable message + "No autodiscovery signal at example.com. Options: + (a) ask your admin to enable WebFinger/OIDC on your domain; + (b) ask this OxiCloud admin to pre-register example.com; + (c) use a supported IdP account." +``` + +**Strategy A — OIDC + Dynamic Registration:** + +``` +Discovered issuer URL → fetch /.well-known/openid-configuration + ↓ +Cache lookup: auth.oidc_dynamic_clients WHERE issuer = ? + ├─ hit → reuse cached client_id + client_secret + └─ miss → POST registration_endpoint {redirect_uris, client_name, …} + get client_id + client_secret → cache row + ↓ +Normal OIDC authorize / callback / token flow (PKCE, nonce, etc.) + ↓ +id_token → federation_kind='oidc', federation_issuer=iss, federation_subject=sub +``` + +**Strategy B — IndieAuth (fallback):** + +``` +Discovered authorization_endpoint URL (and token_endpoint from same discovery) + ↓ +Redirect user with client_id = OxiCloud's own URL, redirect_uri, state, PKCE + ↓ +User consents on their IdP + ↓ +Callback with code → POST to token_endpoint + ↓ +Response has { me: "https://alice.example.com" } + ↓ +Store federation_kind='indieauth', + federation_issuer=example.com (domain from `me`), + federation_subject=me URL +``` + +**Strategy C — pre-registered (existing OIDC config):** the current +single-IdP configured via `OXICLOUD_OIDC_ISSUER_URL` etc. Continues to +work; discovery bypassed for that specific issuer. Handles Google / +Microsoft cases where dynamic registration isn't available — admin +pre-registers, users still get autodiscovery for the "which issuer" step. + +## Schema compatibility — the identity triple already covers this + +The federation-identity model from +[ocm.md § Identity & auth model](./ocm.md) uses +`(federation_kind, federation_issuer, federation_subject)`. **All the alive +strategies fit cleanly** — no schema surgery per strategy, just one CHECK +constraint update to admit new `federation_kind` values. + +| Strategy | federation_kind | federation_issuer | federation_subject | +|---|---|---|---| +| Magic-link (existing) | `magic_link` | NULL | NULL (identity is local `email`) | +| OIDC (existing + dynamic) | `oidc` | id_token `iss` (issuer URL) | id_token `sub` | +| IndieAuth | `indieauth` | domain from `me` URL | full `me` URL | +| OCM (planned) | `ocm` | peer domain | federated address | +| OpenID Federation (future) | `openid_fed` | entity-statement `sub` (issuer) | subject id | + +Extending the enum when a strategy lands is a one-line migration: + +```sql +ALTER TABLE auth.users DROP CONSTRAINT users_federation_kind_check; +ALTER TABLE auth.users ADD CONSTRAINT users_federation_kind_check + CHECK (federation_kind IN ('magic_link','oidc','indieauth','ocm','openid_fed')); +``` + +BCL revocation, anti-duplicate lookup, session middleware, and the AuthZ +engine all key on the triple — kind-blind. Adding IndieAuth or OpenID +Federation touches ONLY the discovery + auth-flow code, never the identity +storage or authorization layers. + +## What we'd add on top + +**For Strategy A:** + +```sql +CREATE TABLE auth.oidc_dynamic_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issuer TEXT NOT NULL UNIQUE, -- keyed on issuer URL + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, -- encrypted at rest (blob-encryption path) + registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB -- full registration response, for audit +); +``` + +Cache of "IdPs we've dynamically registered with." First login per issuer +pays ~500ms for the registration round-trip; subsequent logins hit the +cache. + +**For Strategy B:** no new table. Client identifier is our URL, discovery +metadata is small enough to cache in moka with a short TTL. + +**For discovery layer (shared):** small in-memory + DB cache of +`(identifier → strategy + endpoint URLs)` with a TTL matching provider +metadata freshness (typical: 1 hour for OIDC discovery per its `Cache-Control` +guidance). + +## Trust boundary — policy switch, not a hard-coded stance + +Open discovery = we accept logins from ANY IdP on ANY domain. Three modes, +per admin choice: + +**Open federation** (default for personal / community deployments) — allow +any WebFinger-discovered IdP. Trust is per-user (user chose to authenticate +via that IdP; we trust their choice). Simplest UX, largest attack surface. +A malicious IdP can mint arbitrary `sub`s and claim any identity WITHIN +ITS OWN DOMAIN (can't impersonate other domains — the `(iss, sub)` key +scopes damage). This is the same trust posture as accepting any email +address from an SMTP server today. + +**Allowlist federation** (default for enterprise) — admin-managed +`ocm.trusted_peers` (reuse the OCM peer allowlist, or a sibling table +`auth.trusted_federation_domains`). Only auto-discover for listed +domains. Kills the "just enter your email" UX but bounds the attack +surface. + +**Hybrid** — allow-by-default with per-user quotas + rate limits + admin +visibility (`GET /api/admin/federation/discovered_peers` shows every +domain we've ever registered against, with revoke button). Middle ground. + +Config knob: + +``` +OXICLOUD_AUTH_FEDERATION_MODE=open|allowlist|hybrid # default: hybrid +OXICLOUD_AUTH_FEDERATION_ALLOWLIST=domain1.com,domain2.com # allowlist mode +``` + +## Same discovery layer serves invitation + +The `discover(identifier) → Strategy` function is not just for login — it +answers the same question at INVITE time. When a local user shares with +`alice@example.com`, the invite flow probes her domain and picks the +strongest available channel: + +``` +share_with_external(alice@example.com) + ↓ +discover(alice@example.com) + ├─ OCM signal on example.com → OCM outbound share (resource stays here, + │ she consumes via her own cloud). Grant + │ tied to `federation_kind='ocm'`. + ├─ OIDC + dynamic reg supported → mint a magic-link that triggers OIDC + │ auth on redemption. Grant tied to + │ `federation_kind='oidc'` on first login. + ├─ IndieAuth signal → magic-link that triggers IndieAuth on + │ redemption. Grant tied to + │ `federation_kind='indieauth'`. + ├─ OpenID Federation → same idea, `federation_kind='openid_fed'`. + └─ Nothing discoverable → traditional email magic-link, mailbox- + strength trust. Grant tied to + `federation_kind='magic_link'`. +``` + +**Preference ordering** (strongest → weakest): + +| Channel | Trust source | Why higher | +|---|---|---| +| OCM | Peer server + user's own auth on their cloud | Resource federation + strong identity | +| OpenID Federation | Signed entity statement + trust anchor chain | Cryptographic RP-IdP trust | +| OIDC + dynamic reg | User's IdP (potentially MFA, hardware keys) | IdP-mediated identity | +| IndieAuth | User's domain (same domain owns identity + endpoint) | Domain-owner attestation | +| Magic-link | Mailbox possession only | Weakest — no MFA, phishable | + +The share dialog UX shows a small badge on the resolved recipient +indicating the channel that will be used: `Federated cloud` (OCM), +`SSO via IdP.example.com` (OIDC), `IndieAuth on example.com`, or +`Email link` (magic-link fallback). Sender sees the trust posture +before sending; receiver's invite email / OCM notification reflects +the same. + +**Cache the discovery outcome per identifier for a short TTL** (e.g. 24h +with revalidation) so repeated invites to the same person don't repeat +the probe. Cache lives in `auth.federated_identity_discovery` with +columns `(identifier, discovered_kind, discovered_endpoints, cached_at, +expires_at)`. Invalidated eagerly if the invite fails (endpoint +disappeared, IdP rejected registration) — next attempt re-probes. + +**Trust-mode enforcement applies here too.** In `allowlist` mode, if a +recipient's domain isn't on the allowlist, invitation falls back to +magic-link regardless of what discovery finds. In `hybrid`/`open` mode, +discovery is trusted per-user with rate limiting. + +**Existing external-invite code paths converge here.** Today's magic-link +invitation flow (external user gets emailed a redemption link) becomes +the "Nothing discoverable" branch. No change to the redemption side — +still creates an `is_external=true` shadow user. New paths simply set a +richer `federation_kind` on that same shadow user, which downstream +enables the stronger auth channel on next login attempt. + +## Interactions with existing auth flow + +- **AUTH_METHODS** — add `federated` as a new allowlist token, analogous + to `oidc`. `OXICLOUD_AUTH_METHODS=password,federated` means password + login OR autodiscovered federated login. Same fail-fast semantics as + the existing OIDC token. +- **OIDC-master rule** — `federation_kind='oidc'` OR + `federation_kind='indieauth'` OR `federation_kind='openid_fed'` all + short-circuit magic-link login for that user. Federation-authenticated + users authenticate through their federation. See + [ocm.md § Magic-link on federated addresses](./ocm.md). +- **JIT provisioning** — same code path as current OIDC JIT-provisioning: + create a shadow user on first successful federated login, `is_external` + handling depending on whether they should get local storage + (`OXICLOUD_AUTH_FEDERATION_JIT_GRANTS_STORAGE=false` by default). +- **RP-initiated logout** — reuses the existing `end_session_endpoint` + discovery + `id_token_hint` flow for OIDC. IndieAuth has no standard + logout; local session revocation only. +- **Back-channel logout** — reuses the existing BCL endpoint; the + identity key is now unambiguously the `(iss, sub)` pair (fixed by the + federation_issuer rename planned in [ocm.md](./ocm.md)). + +## Phasing (rough) + +**Phase 0 — Prerequisite:** federation-identity schema rename (documented +in [ocm.md § Schema rename](./ocm.md)). Land first, no matter which +federated-login strategy comes next. + +**Phase 1 — WebFinger + OIDC + Dynamic Registration.** Covers the +self-hosted OIDC mesh case. ~1-2 weeks. Highest immediate value; runs +on top of the existing OIDC flow. + +**Phase 2 — Discovery UX in the login form.** Detect email vs URL, +run discovery on submit, show progress spinner while discovering, +graceful fallback if no signal. ~3-5 days FE. + +**Phase 3 — IndieAuth strategy.** Small addition once the discovery +framework exists. ~1 week. + +**Phase 3b — Discovery reused at INVITE time.** The invitation flow +runs the same probe and dispatches to OCM / OIDC / IndieAuth / +magic-link based on preference ordering. Recipient badge in the share +dialog shows the chosen channel. Cache table +`auth.federated_identity_discovery`. ~5-7 days including UX. + +**Phase 4 — Admin panel for discovered peers.** List, revoke, force +rebind. ~3-5 days. + +**Phase 5 — Trust-mode policy switches.** Config knobs + +allowlist-mode enforcement. ~2-3 days. + +**Phase 6+ — OpenID Federation 1.0.** Add when trust anchor +infrastructure matures externally or a deployment specifically needs it. + +## Open questions + +- **Federated JIT and quotas** — federated users creating drives? Or + strictly grant-only externals? Default to grant-only; per-domain + policy could allow drive creation for trusted domains only. +- **Discovery-cache invalidation** — when an IdP rotates keys, our + cached JWKS goes stale within the OIDC service's TTL (already + handled). Discovered issuer + registered client should also expire + eventually; add a periodic refresh job. +- **Multiple discovery signals on the same domain** — WebFinger says + IdP is X, HTML `` says Y. Rule: WebFinger wins (RFC-specified + discovery is authoritative over convention). +- **Rate limiting** — discovery probes are cheap but can be abused. + Per-caller-IP + per-target-domain rate limits. +- **Anti-typo** — user types `alice@gogle.com`, we discover Gogle's IdP + and successfully register with it. That's a working federated login + to a lookalike domain. Mitigation: display the discovered issuer in + the consent screen ("You will authenticate at + `https://accounts.gogle.com`"). Not fixable server-side alone; + user-side confirmation is the last defense. + +## Non-goals + +- Persona-style browser-mediated flow (dead) +- Verifiable credentials / SIOP v2 (too early) +- OpenID 2.0 backwards compatibility (deprecated a decade ago) +- Social-login integration (Facebook / GitHub / Twitter buttons) — + intentionally out of scope; those require explicit RP registration and + a per-provider adapter, not federated identity + +## References + +- WebFinger: RFC 7033 +- OIDC Discovery 1.0: +- OIDC Dynamic Client Registration: RFC 7591 + +- IndieAuth: +- OpenID Federation 1.0: +- Solid-OIDC: +- BrowserID postmortem: diff --git a/docs/plan/ocm.md b/docs/plan/ocm.md new file mode 100644 index 00000000..02b17a85 --- /dev/null +++ b/docs/plan/ocm.md @@ -0,0 +1,516 @@ +# Open Cloud Mesh (OCM) — Federated Sharing + +Federate file-sharing with Nextcloud, ownCloud, Reva/OCIS, Seafile, and any +other OCM 1.1+ speaker. Track the design decisions here before code lands. + +## Identity & auth model — DECIDED + +OCM-federated recipients live as **shadow rows in `auth.users`** with: + +- `is_external = true` +- `password_hash = NULL` (enforced by existing `users_external_no_storage` + CHECK constraint) +- `email = federated-address` (e.g. `bob@nextcloud.example.com`) +- Federation columns (see "Schema rename — PRE-REQ" below for the + migration path that turns today's OIDC-specific columns into these): + - `federation_kind ∈ {NULL, 'magic_link', 'ocm', 'oidc'}` — distinguishes + the trust chain that owns this identity. NULL for internal users. + - `federation_issuer` — **THE AUTHORITY** that mints the subject id. + NOT a display name. The full identity per federation kind is + `(federation_issuer, federation_subject)`; the issuer is what makes + a subject id meaningful — two different IdPs can independently mint + `sub=1234` for two completely different people. + - `oidc` — the `iss` claim from the id_token (also + `discovery.issuer`), e.g. + `https://sso.example.com/realms/main`. Immutable per + spec (RFC 7519 §4.1.1). + - `ocm` — the peer domain, e.g. `nextcloud.example.com`. The + peer's domain IS the authority for its federated identities in + OCM's model. + - `magic_link` — NULL (identity is the local `email`, no external + authority involved). + - **Fixes an existing bug.** Today `oidc_provider` stores the + human-readable `OXICLOUD_OIDC_PROVIDER_NAME` label (e.g. "SSO", + "MockSSO"), not the issuer URL. Consequences of the current + shape: + 1. Admin renaming `OXICLOUD_OIDC_PROVIDER_NAME` silently + orphans every existing OIDC user — new lookups key on the + new label, stored rows have the old one. + 2. BCL revocation (`revoke_user_sessions_by_oidc_subject`) + keys on the label; same rename → BCL notifications from the + IdP match no one. Silent security regression — users + thought-kicked stay logged in. + 3. Multi-IdP futures (multi-realm, multi-tenant) would suffer + cross-issuer subject collision if two IdPs share a display + name. + - Display name becomes a separate concern — either derived from a + `peer_configs.issuer → display_name` lookup, or stored alongside + as a denormalized `federation_display_name` column (admin- + editable, no identity impact). Not part of the identity key. + - `federation_subject` — the remote id string. Stability: + - `oidc` — the `sub` claim, issuer-assigned, MUST NOT change per spec + (RFC 7519 §4.1.2, OIDC Core §5.4). Email can change on the IdP + side with no impact on this row. + - `ocm` — the full federated address (`bob@nextcloud.example.com`). + OCM 1.1 does NOT define a separate stable subject id; the address + IS the identity. If a peer renames a user, we see a new address + and create a new shadow row. Grants against the old row are + orphaned until admin intervention. This is a protocol + limitation — some peers ship extensions (Nextcloud Global Site + Selector, ScienceMesh/Reva `opaqueUserId`) but they aren't in + base OCM. Treat `federation_subject` as opaque; if a future OCM + version adds a stable id, swap the value in without schema change. + - `magic_link` — NULL (magic-link externals don't carry a federation + subject; their identity is the local `email` column). + - Composite UNIQUE index `(federation_kind, federation_issuer, federation_subject)` + WHERE `federation_kind IS NOT NULL`. +- `storage_quota_bytes = 0`, no home folder provisioned + +**Grants stay `subject_type = 'user'` on `storage.role_grants`.** No new +subject-type variant. The team's earlier refactor (2026-07-30) removing +`'external'` from the CHECK constraint is the precedent — external is a flag +on the user row, not a subject type. + +**AuthZ engine unchanged.** Middleware for OCM WebDAV resolves sharedSecret → +`ocm.outbound_shares.recipient_user_id` (the shadow row), sets `CurrentUserId` +to that, and the existing `authz.require(subject, resource, permission)` runs +identically for local + federated subjects. + +## Schema rename — PRE-REQ (ships before ANY OCM code) + +Today's `auth.users.oidc_provider` / `oidc_subject` are semantically identical +to the generic `federation_issuer` / `federation_subject` above. Keeping both +would be drift risk. Rename first, then start OCM work on top of the clean +shape. + +```sql +ALTER TABLE auth.users RENAME COLUMN oidc_provider TO federation_issuer; +ALTER TABLE auth.users RENAME COLUMN oidc_subject TO federation_subject; +ALTER TABLE auth.users ADD COLUMN federation_kind TEXT + CHECK (federation_kind IN ('magic_link', 'ocm', 'oidc')); + +-- Backfill kind: rows that had oidc_provider set were all OIDC-linked. +UPDATE auth.users SET federation_kind = 'oidc' + WHERE federation_issuer IS NOT NULL; + +-- Swap the anti-duplicate index. +DROP INDEX idx_users_oidc; +CREATE UNIQUE INDEX idx_users_federation + ON auth.users(federation_kind, federation_issuer, federation_subject) + WHERE federation_kind IS NOT NULL; +``` + +**Value migration is more than a rename** — today's stored values in the +renamed `federation_issuer` column are DISPLAY LABELS (e.g. `'MockSSO'`, +`'MyIdP'`), not issuer URLs. Rewriting them to real issuer URLs +touches every existing OIDC row and can't happen in one atomic step without +losing rollback ability. Phase it: + +### Rename PR — Phase A (schema) +- Add columns (`federation_kind`, `federation_issuer` renamed from + `oidc_provider`, `federation_subject` renamed from `oidc_subject`) — + the SQL block above. +- Application code DUAL-WRITES on OIDC login: still writes the display + label to `federation_issuer` (unchanged semantics) AND stamps + `federation_kind = 'oidc'`. Reads unchanged. +- Ships safely as a pure rename + kind-backfill. Rollback = drop the + `federation_kind` column; the renamed columns keep working with the + old code because their VALUES haven't changed. + +### Rename PR — Phase B (progressive lazy rebind on OIDC login) + +**Simplification** — no explicit backfill CLI needed. The id_token +carries the true `iss` claim on every OIDC login, so the migration +does itself organically: + +- On every successful OIDC login: if the user's stored + `federation_issuer` does not equal the id_token's `iss`, UPDATE it + (with an audit line `event="federation.issuer_rebound"`, + `reason="lazy_backfill"`). First login post-upgrade heals the row. +- Users who never log in stay on the legacy label — but they also + can't do anything (no login → no action), so the stale value is + harmless. It appears only in admin listings, where a "legacy label + needs rebind" badge could surface if we care. +- Contradiction guard: if the observed `iss` is DIFFERENT from the + stored issuer AND the stored issuer is already a valid URL (not a + display label), that's a genuine identity change — refuse the login + with an audit event and admin alert. Preserves "identity change is + admin-mediated." Heuristic for label vs URL: starts with `http://` + or `https://` and contains `/`. +- Same lazy-rebind logic covers BCL: if a BCL notification's `iss` + matches a user's `federation_subject` but issuer differs, we can + either rebind (safer bet: same peer just told us their true issuer) + or refuse and audit. Recommend refuse-and-audit, since BCL is + server-to-server and less user-driven. + +**CLI subcommand shape (optional, ships later if needed)** — lives +under `oxicloud-cli federation …` (see `src/bin/oxicloud-cli.rs` for +the domain/action pattern; add `federation` module alongside +`opaque`). Two subcommands worth having if operators want proactive +control: + +- `federation status` — show counts of users by `federation_kind` and + how many still hold display-label-shaped `federation_issuer` values. +- `federation remap-issuer --from-label X --to-issuer https://...` — + bulk-update the users still holding a specific legacy label, for + operators who want to close the migration without waiting for + every user to log in. + +**All user-identifier arguments accept EITHER username OR user UUID.** +Not all users have a username (email-only signups per PR-18 leave it +NULL); the CLI must handle both, resolving via +`SELECT id FROM auth.users WHERE username = $1 OR id::text = $1`. +Applies to any subcommand that takes a user reference. + +- New admin CLI or boot-time task: for each row where + `federation_kind = 'oidc'`, try to derive the true issuer URL: + - **Single-IdP deployment** (typical): if the current OIDC config's + `issuer_url` is set AND no other candidate exists, UPDATE all rows + to that issuer. One-line log per row updated. + - **Multi-value case** (deployment has renamed + `OXICLOUD_OIDC_PROVIDER_NAME` mid-life, so the same physical IdP + has rows with different labels): auto-backfill would smear all + labels into the SAME issuer — WRONG for rows that predate the + label swap. **Refuse to auto-backfill**; emit a boot audit warning + listing the distinct current values + affected user_ids; require + operator to pick a mapping via CLI (`oxicloud federation + remap-issuer --from-label X --to-issuer https://…`) or accept + the current-config issuer for all rows via + `--all-legacy-labels`. + - **Genuine multi-IdP** (rare today, forward-looking): fail loud; + no automatic mapping is safe. Manual mapping per label. +- Log each mapping decision to `audit` for post-hoc traceability. +- Ship after Phase A has been in production long enough to prove + stable (one release cycle at minimum). + +### Rename PR — Phase C (read switch + lazy rebind) +- Application code reads anti-duplicate lookups AND BCL revocation + keying on `(federation_kind, federation_issuer, federation_subject)`. +- On EVERY successful OIDC login: if the row's current + `federation_issuer` value doesn't equal the id_token's `iss` claim, + UPDATE it (with an audit line). This catches rows Phase B couldn't + resolve automatically — the first login after rollout self-heals. +- Dual-write continues (kind + issuer both stamped from id_token now). +- Hard failure mode to watch for: if the `iss` on the id_token + contradicts a Phase-B-backfilled value for existing users, we've + either (a) misconfigured the IdP, or (b) the IdP is a completely + different one than we backfilled to. Refuse the login with an audit + event and admin alert rather than silently rebind — preserves the + principle "identity change is admin-mediated". + +### Rename PR — Phase D (drop legacy compatibility) +- Precondition check: no rows remain with a `federation_issuer` value + that looks like a display label (heuristic: doesn't start with + `http://` or `https://`, doesn't contain `/`). +- Remove any dead code paths that assumed display-label semantics. +- No column drop needed — the columns are already renamed. This phase + is code-only. +- Ships when telemetry from Phase C shows zero unresolved + federation_issuer values across the fleet. + +Total: rename in one release cycle, value backfill in the next, read +switch after that, cleanup at leisure. Each phase reversible. Operator +warnings surface the exceptional cases (multi-label, multi-IdP) so +they don't get silent-defaulted into a broken state. + +Rust-side rename (~15-20 sites, mechanical): +- `User` entity fields (`oidc_provider` → `federation_issuer` etc.) +- `UserPgRepository` SQL statements +- `OidcIdClaims → User` mapping in `auth_application_service` (sets + `federation_kind = 'oidc'`) +- `admin_settings_service`'s env-override table +- Any DTOs that leaked `oidc_provider` externally — check `UserDto` before + the migration in case FE reads the field +- Tests + +Why rename rather than coexist: +- `oidc_provider` is a misleading name anyway — a value like + `MyIdP` isn't a "provider" (that's a category), it's the IdP's + identity/name. `federation_issuer` reads more truthfully. +- Adding OCM alongside without renaming forces every future query that asks + "is this user externally-owned?" to check two column pairs. One canonical + shape is cheaper. + +The rename can ship as an independent PR before OCM work starts. Backfilling +`kind = 'oidc'` for existing rows is monotone (no NULL flips), so the +migration is reversible if we need to abort. + +## Magic-link on federated addresses — DECIDED + +Default: **`federation_kind='ocm'` users CANNOT use magic-link login.** +Same rule shape as the existing OIDC-master rule +(`feedback_oidc_master_no_magic_link_bypass`). + +Enforcement in `is_magic_link_login_allowed_for(&user)`: + +``` +if user.federation_kind == Some(FederationKind::Ocm) { + return false; // audit: reason="ocm_federated" +} +``` + +Rationale: +- OCM's identity boundary is the remote peer's authentication (which may + enforce MFA, geo-fence, hardware keys). Magic-link would downgrade that to + mailbox-strength. +- Federation revocation on the remote side (peer disables bob) should NOT + leave bob with a working login on our side via his mailbox. +- Same anti-enumeration shape as `oidc_user` rejection — 404 to the caller, + audit line internally. + +### Deferred opt-in: `allow_magic_link_for_ocm_federated` + +Some deployments will want unified UX (federated invite doubles as a browser +account). Ship as an additive policy on `OXICLOUD_AUTH_POLICIES`: + +``` +OXICLOUD_AUTH_POLICIES=allow_magic_link_for_ocm_federated +``` + +Same shape as `permit_magic_link_for_password_users` — off by default, +operators who enable it accept the auth-boundary trade-off. Document alongside +the shared-computer / weakened-boundary caveat. + +**Not implemented in the initial OCM ship.** Add when a deployment actually +asks for it. + +## Reverse case: address already exists as magic-link external + +If `bob@example.com` is already in our DB as a magic-link external (a local +user invited him last month) AND a peer sends us an OCM share addressed to +`bob@example.com`, do NOT upgrade in place. Create a separate shadow row +with `federation_kind='ocm'`. Different trust chains, different identities. +The composite UNIQUE index on `(federation_kind, federation_issuer, +federation_subject)` allows the two rows to coexist. + +Consequence: magic-link bob CAN log in; OCM bob CANNOT. Same address, two +accounts, two postures. Honest about the different trust semantics. + +## New DB shape + +``` +ocm.trusted_peers -- allowlist of federation partners +ocm.outbound_shares -- shares WE created for remote users (with acceptance state) +ocm.inbound_shares -- shares OTHER servers created for our users (with acceptance state) +ocm.notifications -- wire-level OCM notifications (retry queue + dedup + forensic audit) +``` + +### `ocm.notifications` in detail + +**Not to be confused with acceptance state.** When a user clicks +Accept/Decline on a pending invitation, that mutates +`ocm.inbound_shares.state` — the user's decision lives there. This +table stores the *wire message* we then send to the peer telling them +"our user accepted." The two are logically distinct: acceptance is +share-scoped workflow state, notifications are transport-layer events. +Future readers: don't conflate. + +Given that, `ocm.notifications` serves FOUR overlapping purposes. +Collapsing them into one table (instead of parallel queue / dedup / +audit tables) avoids drift. + +**1. Retry queue for outbound.** OCM 1.1 requires exponential-backoff +retry when the peer's `POST /notifications` fails. Rows with +`direction='outbound' AND processing_result='pending' AND +next_retry_at <= now()` are what a background worker picks up. + +**2. Idempotency / dedup for inbound.** Peers may resend after network +hiccups. `UNIQUE(peer_domain, remote_notification_id)` short-circuits. + +**3. Debugging.** "Why did that share disappear?" — grep by +`related_share_id`. Payload preserved verbatim as JSONB. + +**4. Security audit.** Federated state transitions cross trust +boundaries; every one is auditable, joinable to peer domain + share. + +Sketch: + +```sql +CREATE TYPE ocm.notification_direction AS ENUM ('inbound', 'outbound'); +CREATE TYPE ocm.notification_kind AS ENUM ( + 'share_accepted', 'share_declined', 'share_revoked', + 'share_unshared', 'user_removed', 'other' +); +CREATE TYPE ocm.notification_result AS ENUM ( + 'pending', 'success', 'failed', 'ignored' +); + +CREATE TABLE ocm.notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + direction ocm.notification_direction NOT NULL, + peer_domain TEXT NOT NULL, + related_share_kind TEXT CHECK (related_share_kind IN ('inbound','outbound')), + related_share_id UUID, -- points at inbound_shares.id OR + -- outbound_shares.id; typed union + -- isn't natural in Postgres so no + -- hard FK — verify at insert + kind ocm.notification_kind NOT NULL, + payload JSONB NOT NULL, + remote_notification_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + processing_result ocm.notification_result NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + next_retry_at TIMESTAMPTZ, -- outbound only + error_message TEXT, + UNIQUE (peer_domain, remote_notification_id) +); + +CREATE INDEX idx_ocm_notif_related + ON ocm.notifications(related_share_id, related_share_kind); +CREATE INDEX idx_ocm_notif_retry + ON ocm.notifications(next_retry_at) + WHERE direction = 'outbound' AND processing_result = 'pending'; +CREATE INDEX idx_ocm_notif_peer + ON ocm.notifications(peer_domain, created_at DESC); +``` + +**Retry policy** — attempt 1 immediate; 2-5 with exponential backoff +(30s, 2m, 10m, 1h); after attempt 5, mark `failed` and emit +`event="ocm.notification_delivery_gave_up"` with admin alert. +Configurable via `OXICLOUD_OCM_NOTIFICATION_MAX_ATTEMPTS` (default 5). + +**Retention** — 90 days default, purged by a nightly consistency-job +handler (`notifications_cleanup`), fits the existing recovery-jobs +pattern. `failed` rows kept indefinitely until admin resolution +(paper trail). Configurable via +`OXICLOUD_OCM_NOTIFICATION_RETENTION_DAYS`. + +**Inbound security invariants** — verify BEFORE mutating any state: + +- Peer domain matches the peer that owns the referenced share + (`peer_mismatch` → mark `ignored`, audit). +- `related_share_id` points at an existing share in the correct table + (`unknown_share` → `ignored`). +- Notification kind is compatible with current share state + (`state_conflict` → `ignored`, e.g. accept on an already-declined + share). + +Each rejection emits an `event="ocm.notification_rejected"` audit line +with a stable `reason=` field (`peer_mismatch | unknown_share | +state_conflict | duplicate`) — same anti-drift discipline as other +structured audit events, per `feedback_enum_over_string_literals_in_logs`. + +Plus `auth.users` new columns above. + +Plus `storage.resource_kind` extension for a `RemoteShare` resource type +(pointing at `ocm.inbound_shares.id`), so accepting an inbound share is a +regular grant row from the accepting user to the RemoteShare resource. + +## Invitation workflow — where accept/decline lives + +Local grants have no accept/decline concept — the grant IS the access. +OCM introduces one because the protocol is push-driven (peer POSTs a +share, our user decides whether to surface it). Solved WITHOUT adding +state to `role_grants`: + +**Inbound shares (peer → us):** +- `POST /ocm/shares` creates an `ocm.inbound_shares` row (pending). NO + role_grant created yet. +- User sees pending invitation in `/shared-with-me` → `Federated` tab + (new), plus a badge on AppShell. +- **Accept** → insert `role_grants` row (subject = local user, resource + = a new `RemoteShareResource` entry, permission = as offered), POST + accept notification to peer, mark `ocm.inbound_shares.accepted_at`. +- **Decline** → no grant created; POST decline notification, mark + `ocm.inbound_shares.declined_at`. Row kept for audit. +- Modal prompt on next login if pending count changed since last login. + +**Outbound shares (us → peer):** +- Grant is created immediately in `role_grants` (subject = shadow user + for the federated recipient). AuthZ from OUR side is active the + moment the OCM POST succeeds — remote user CAN consume via WebDAV. +- `ocm.outbound_shares` tracks delivery + acceptance state + (`delivering | delivered | accepted | declined_by_recipient | + undeliverable | revoked`), surfaced in `/shared` (outgoing view) as + a status column. +- Share dialog recognizes `user@remote-domain` and shows a channel + badge on the resolved recipient (see + [federated-login.md § Same discovery layer serves invitation](./federated-login.md)). +- Peer sends decline notification → auto-revoke the local grant + (recipient said no, keeping access is pointless). Configurable + behaviour deferred. + +**ReBAC extensions needed** (all local to the OCM code path, no engine +churn): +- New `resource_type = 'remote_share'` on `role_grants`, resource_id + pointing to `ocm.inbound_shares.id`. AuthZ dispatches to a + WebDAV-proxy handler for this type. +- `ocm.inbound_shares.state` and `ocm.outbound_shares.state` columns + hold acceptance metadata. `role_grants` stays state-free. +- OCM notification handler translates peer messages into local state + transitions (create/delete grant, update state column). + +## Future — merge with a generic grant-request workflow + +`ocm.inbound_shares.state` is a purpose-specific state machine for the +MVP. If OxiCloud later gains a local pending-approval workflow (Alice +asks Bob for Read on his folder; Bob approves) — a real +enterprise-features ask — the natural refactor is to extract a shared +workflow spine: + +- New table `storage.grant_requests` with + `(subject, resource, requested_permission, approval_authority, state, + decided_at, decided_by)`. Approval_authority distinguishes who holds + decide-power: `owner` (local approval), `recipient` (OCM inbound + accept, magic-link redeem), `admin` (policy gate). +- `ocm.inbound_shares` keeps its federation-specific columns + (peer_domain, remote WebDAV URL, sharedSecret) and gains a + `grant_request_id` FK. Accept = flip request to `approved` + create + `role_grant`. Decline = flip to `rejected`. +- `magic_link_tokens` with `resource_kind ≠ NULL` (invitation-purpose + tokens) gets the same FK. Redeem = flip to `approved` + create + `role_grant`. +- **`role_grants` stays approved-only.** Hot AuthZ path doesn't gain a + state filter — no regression on read paths. + +**NOT** a merge of the tables themselves — collapsing OCM / +magic-link / local-request into `role_grants` with a state column +would leak state-machine logic into every AuthZ check. The merge is at +the ABSTRACTION level: three flavors of "prospective grant" today +(magic-link invite, OCM inbound, hypothetical local request) share ONE +workflow table; channel-specific data stays in satellites. + +Migration is additive and non-breaking: add tables + FK columns, +backfill existing rows as synthetic `approved` requests for audit +continuity, refactor callers one at a time. No ReBAC engine change. + +Not urgent — the OCM MVP works fine without it. Recorded so the +option isn't forgotten if local approval workflow ever gets requested. + +## Phasing (rough) + +1. **Phase 0** — Discovery (`/ocm-provider`) + inbound receive + FE listing + of received shares (metadata only, no consumption yet). ~1 week. +2. **Phase 1** — Consume remote shares (server-side WebDAV proxy). ~1-2 weeks. + Needs a WebDAV client — none exists in the tree today. +3. **Phase 2** — Outbound sharing (share dialog recognizes `user@remote`). + ~3-5 days. +4. **Phase 3** — OCM 2.0 invite handshake (ScienceMesh trust flow). ~1 week. +5. **Phase 4** — Address book / federated-contact registry. ~1 week. + +## Open design questions (not yet decided) + +- **Trust model** — open federation vs allowlisted peers? Recommend + allowlist-by-default (safer for self-hosted). +- **Recipient resolution rule** — `username@LOCAL_DOMAIN` vs `email` match? + Recommend username-based (matches Nextcloud, deterministic). +- **Quota accounting** — do consumed remote shares count against the + recipient's `storage_quota_bytes`? Recommend no (remote storage is remote). +- **Notification authenticity** — bind every OCM notification to its + `inbound_share.id`, refuse if peer domain doesn't match the domain we + recorded at share creation. +- **Group grants** — local group containing OCM-federated user is weird. + Semantically fine but the share dialog should probably discourage it. + +## Interop targets + +- Nextcloud in Docker as the primary peer (both directions). +- OCIS/Reva as the CS3-blessed reference behaviour. +- SciMesh integration tests once Phase 3 is in. + +## References + +- OCM 1.1 spec: +- Nextcloud OCM docs (implementation quirks): +- Reva (Go reference impl): diff --git a/migrations/20261010000000_federation_identity_rename.sql b/migrations/20261010000000_federation_identity_rename.sql new file mode 100644 index 00000000..4d936d43 --- /dev/null +++ b/migrations/20261010000000_federation_identity_rename.sql @@ -0,0 +1,44 @@ +-- Phase A of the federation-identity rename (see docs/plan/ocm.md § Schema rename). +-- +-- Prepares the identity model to accommodate OCM + IndieAuth + OpenID Federation +-- alongside today's single-strategy OIDC surface. Nothing about VALUES changes in +-- this migration; the rename is pure and the new `federation_kind` column is +-- backfilled deterministically from the presence of the existing OIDC columns. +-- +-- Value semantics (`federation_issuer` still holds the display-name label today, +-- not the true `iss` URL) are corrected in Phase B — see plan doc. Ship this +-- first so the schema shape is stable before that value migration. +-- +-- Rollback: DROP the federation_kind column; the RENAMEd columns keep working +-- with pre-rename code paths because their values are untouched. + +ALTER TABLE auth.users RENAME COLUMN oidc_provider TO federation_issuer; +ALTER TABLE auth.users RENAME COLUMN oidc_subject TO federation_subject; + +ALTER TABLE auth.users ADD COLUMN federation_kind TEXT + CHECK (federation_kind IN ('magic_link', 'ocm', 'oidc')); + +-- Backfill: existing rows with a federation_issuer set are all OIDC-linked +-- (only the OIDC flow populated the old oidc_provider column). Anything NULL +-- stays NULL — local / password / magic-link users don't get a kind. +UPDATE auth.users + SET federation_kind = 'oidc' + WHERE federation_issuer IS NOT NULL; + +-- Swap the anti-duplicate uniqueness. Old index was keyed on +-- (oidc_provider, oidc_subject); new one includes federation_kind so OIDC +-- and OCM (and future IndieAuth / OpenID Federation) principals stay +-- distinct even if their (issuer, subject) tuples collide across kinds. +DROP INDEX IF EXISTS auth.idx_users_oidc; +CREATE UNIQUE INDEX idx_users_federation + ON auth.users(federation_kind, federation_issuer, federation_subject) + WHERE federation_kind IS NOT NULL; + +COMMENT ON COLUMN auth.users.federation_kind IS + 'Trust chain that owns this identity: oidc | ocm | magic_link. NULL for pure local users. See docs/plan/ocm.md and docs/plan/federated-login.md.'; + +COMMENT ON COLUMN auth.users.federation_issuer IS + 'Authority that mints the subject id. OIDC: iss URL. OCM: peer domain. Magic-link: NULL. NOTE: legacy rows may still hold the OXICLOUD_OIDC_PROVIDER_NAME display label; Phase B of the federation-identity rename backfills these to real issuer URLs.'; + +COMMENT ON COLUMN auth.users.federation_subject IS + 'Stable identifier for this user within the federation_issuer. OIDC: sub claim (stable per RFC 7519 §4.1.2). OCM: federated address (protocol has no separate stable id in 1.1). Magic-link: NULL.'; diff --git a/src/application/adapters/plugin_user_lifecycle_hook.rs b/src/application/adapters/plugin_user_lifecycle_hook.rs index 8bfe24a4..8dc91567 100644 --- a/src/application/adapters/plugin_user_lifecycle_hook.rs +++ b/src/application/adapters/plugin_user_lifecycle_hook.rs @@ -110,8 +110,9 @@ mod tests { "alice@example.com".to_string(), Some("alice".to_string()), None, - None, - None, + None, // federation_kind + None, // federation_issuer + None, // federation_subject UserRole::User, 0, false, @@ -152,8 +153,9 @@ mod tests { "bob@example.com".to_string(), None, None, - None, - None, + None, // federation_kind + None, // federation_issuer + None, // federation_subject UserRole::User, 0, false, diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index aea17d3b..f121d615 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -131,7 +131,7 @@ pub struct AdminUserSummaryDto { pub is_external: bool, /// TRUE when the user has a server-verifiable password on file /// (`password_hash IS NOT NULL`). The admin table uses this - /// alongside `oidc_provider` and `opaque_registered` to render + /// alongside `federation_issuer` and `opaque_registered` to render /// the user's full capability set: a `password` chip lights up /// here, an OIDC provider name renders the SSO badge, an /// envelope-on-file flips the OPAQUE chip. A user with none of @@ -171,7 +171,7 @@ impl From for AdminUserSummaryDto { storage_used_bytes: entry.storage_used_bytes, last_login_at: entry.last_login_at, active: entry.active, - auth_provider: entry.oidc_provider.unwrap_or_else(|| "local".to_string()), + auth_provider: entry.federation_issuer.unwrap_or_else(|| "local".to_string()), is_external: entry.is_external, has_password: entry.has_password, opaque_registered: entry.opaque_registered, @@ -208,7 +208,7 @@ impl From for UserDto { last_login_at: p.last_login_at, active: p.active, // Some(provider) moves the String; None still allocates "local". - auth_provider: p.oidc_provider.unwrap_or_else(|| "local".to_string()), + auth_provider: p.federation_issuer.unwrap_or_else(|| "local".to_string()), image: p.image, can_edit_image, is_external: p.is_external, diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index bdeb6c44..dda496ae 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -194,10 +194,14 @@ pub trait UserStoragePort: Send + Sync + 'static { /// Changes a user's password async fn change_password(&self, user_id: Uuid, password_hash: &str) -> Result<(), DomainError>; - /// Finds a user by OIDC provider + subject pair - async fn get_user_by_oidc_subject( + /// Finds a user by federation (issuer, subject) pair. Historically + /// called for OIDC lookups (the only federation kind in-tree at rename + /// time); after Phase B/C of the federation-identity rename the + /// caller passes the true `iss` URL rather than a display label. See + /// `docs/plan/ocm.md § Schema rename` for the transition. + async fn get_user_by_federation_subject( &self, - provider: &str, + issuer: &str, subject: &str, ) -> Result; @@ -383,12 +387,12 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// OIDC Back-Channel Logout fallback when the IdP didn't supply a `sid`: /// revoke every session belonging to the user identified by - /// `(oidc_provider, oidc_subject)`. Returns the affected user id, or - /// `None` if we don't know that user. - async fn revoke_user_sessions_by_oidc_subject( + /// `(federation_issuer, federation_subject)`. Returns the affected + /// user id, or `None` if we don't know that user. + async fn revoke_user_sessions_by_federation_subject( &self, - oidc_provider: &str, - oidc_subject: &str, + issuer: &str, + subject: &str, ) -> Result, DomainError>; } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 8725fcef..0af2088d 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -560,8 +560,9 @@ impl AuthApplicationService { dto.email.clone(), dto.username.clone(), password_hash, - None, - None, + None, // federation_kind: local password registration + None, // federation_issuer + None, // federation_subject role, quota, false, @@ -662,8 +663,9 @@ impl AuthApplicationService { email, Some(username.clone()), Some(password_hash), - None, - None, + None, // federation_kind: setup admin is local + None, // federation_issuer + None, // federation_subject role, quota, false, @@ -1523,7 +1525,7 @@ impl AuthApplicationService { .await? } else if let Some(sub) = claims.sub.as_ref() { self.session_storage - .revoke_user_sessions_by_oidc_subject(&provider_name, sub) + .revoke_user_sessions_by_federation_subject(&provider_name, sub) .await? .into_iter() .collect() @@ -2810,8 +2812,9 @@ impl AuthApplicationService { email, Some(dto.username.clone()), Some(password_hash), - None, - None, + None, // federation_kind: admin-created external, no federation link yet + None, // federation_issuer + None, // federation_subject UserRole::User, 0, true, @@ -2821,8 +2824,9 @@ impl AuthApplicationService { email, Some(dto.username.clone()), Some(password_hash), - None, - None, + None, // federation_kind: admin-created local user + None, // federation_issuer + None, // federation_subject role, quota, false, @@ -3395,7 +3399,7 @@ impl AuthApplicationService { // 5. Look up existing user by OIDC subject let user = match self .user_storage - .get_user_by_oidc_subject(&provider_name, &claims.sub) + .get_user_by_federation_subject(&provider_name, &claims.sub) .await { Ok(mut existing_user) => { @@ -3511,6 +3515,14 @@ impl AuthApplicationService { oidc_email, Some(username.clone()), None, + Some(crate::domain::entities::user::FederationKind::Oidc), + // TODO Phase B: `provider_name` still carries the + // OXICLOUD_OIDC_PROVIDER_NAME display label instead + // of the true `iss` URL. Lazy-rebind on subsequent + // logins converts the row (see docs/plan/ocm.md + // § Rename PR — Phase B). First-login value is the + // label for backwards compatibility with existing + // rows. Some(provider_name.clone()), Some(claims.sub.clone()), role, diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index a78f8c50..b0171ac3 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -225,12 +225,18 @@ impl MagicLinkInviteService { // External users are created without a username or password. // `password_hash IS NULL` is the canonical no-password marker. + // + // federation_kind stays None in Phase A of the federation-identity + // rename — magic-link externals get their `federation_kind` stamp + // in a future PR when the invite handler is refactored to opt into + // the composable federation model. Behaviour is unchanged today. let mut user = User::new( normalised_email.to_string(), None, None, - None, - None, + None, // federation_kind + None, // federation_issuer + None, // federation_subject UserRole::User, 0, true, @@ -1006,15 +1012,21 @@ mod tests { use crate::domain::entities::user::{User, UserRole}; fn user(password: Option<&str>, oidc: Option<(&str, &str)>) -> User { - let (provider, subject) = match oidc { - Some((p, s)) => (Some(p.to_string()), Some(s.to_string())), - None => (None, None), + use crate::domain::entities::user::FederationKind; + let (kind, issuer, subject) = match oidc { + Some((p, s)) => ( + Some(FederationKind::Oidc), + Some(p.to_string()), + Some(s.to_string()), + ), + None => (None, None, None), }; User::new( "test@example.com".to_string(), None, password.map(str::to_string), - provider, + kind, + issuer, subject, UserRole::User, 0, diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index a12e50be..1661ebe0 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -28,6 +28,62 @@ impl std::fmt::Display for UserRole { } } +/// The trust chain that owns a user's identity. NULL on `auth.users` +/// means "pure local user, no external federation" — the common case for +/// password/OPAQUE accounts. +/// +/// See `docs/plan/ocm.md § Identity & auth model` for the full model +/// including the `(federation_kind, federation_issuer, federation_subject)` +/// composite identity key. +/// +/// - `Oidc`: authenticated via an OIDC provider; `federation_issuer` = +/// the id_token `iss` claim, `federation_subject` = the `sub` claim. +/// Note: legacy rows may still hold the OXICLOUD_OIDC_PROVIDER_NAME +/// display label as `federation_issuer` until Phase B of the rename +/// backfills them to real issuer URLs. +/// - `Ocm`: OCM 1.1 federated principal (future — `docs/plan/ocm.md`). +/// `federation_issuer` = peer domain, `federation_subject` = federated +/// address (e.g. `alice@remote.example.com`). +/// - `MagicLink`: external invitee whose only auth is mailbox +/// possession. Both `federation_issuer` and `federation_subject` +/// remain NULL for this kind — the identity is the local `email`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FederationKind { + MagicLink, + Ocm, + Oidc, +} + +impl FederationKind { + /// Canonical DB / wire spelling — matches the CHECK constraint on + /// `auth.users.federation_kind`. + pub fn as_str(self) -> &'static str { + match self { + FederationKind::MagicLink => "magic_link", + FederationKind::Ocm => "ocm", + FederationKind::Oidc => "oidc", + } + } + + /// Parse the DB / wire spelling. Returns `None` for anything other + /// than the three canonical values — the CHECK constraint on the + /// column and the enum variants are the source of truth. + pub fn parse(s: &str) -> Option { + match s { + "magic_link" => Some(FederationKind::MagicLink), + "ocm" => Some(FederationKind::Ocm), + "oidc" => Some(FederationKind::Oidc), + _ => None, + } + } +} + +impl std::fmt::Display for FederationKind { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + /// Authorization-relevant account flags, fetched without the heavyweight /// profile columns. The full user row drags `image` along — a data URI of /// up to 512 KiB — which per-request guards (`require_internal_user`, @@ -72,8 +128,12 @@ pub struct User { updated_at: DateTime, last_login_at: Option>, active: bool, - oidc_provider: Option, - oidc_subject: Option, + /// Which trust chain minted the (issuer, subject) pair. `None` on + /// pure local users (password/OPAQUE). See [`FederationKind`] for the + /// three federation flavours. + federation_kind: Option, + federation_issuer: Option, + federation_subject: Option, image: Option, /// TRUE = grant-only external recipient (magic-link, OIDC-only, OCM /// federated). FALSE = storage-owning internal user. Hooks that @@ -161,8 +221,9 @@ pub struct UserParts { pub updated_at: DateTime, pub last_login_at: Option>, pub active: bool, - pub oidc_provider: Option, - pub oidc_subject: Option, + pub federation_kind: Option, + pub federation_issuer: Option, + pub federation_subject: Option, pub image: Option, pub is_external: bool, pub given_name: Option, @@ -190,8 +251,9 @@ impl User { updated_at, last_login_at, active, - oidc_provider, - oidc_subject, + federation_kind, + federation_issuer, + federation_subject, image, is_external, given_name, @@ -213,8 +275,9 @@ impl User { updated_at, last_login_at, active, - oidc_provider, - oidc_subject, + federation_kind, + federation_issuer, + federation_subject, image, is_external, given_name, @@ -230,7 +293,7 @@ impl User { /// /// One unified constructor for every kind of user (internal, OIDC-linked, /// external). The credential slots and the `is_external` marker are all - /// caller-controlled — what makes a user "OIDC" is `oidc_subject = + /// caller-controlled — what makes a user "OIDC" is `federation_subject = /// Some(_)`, what makes them "external" is `is_external = true`. There /// are no hidden sentinel values; an absent credential is `None`. /// @@ -239,7 +302,7 @@ impl User { /// * `username` — optional handle (2-64 chars, no `@`) /// * `password_hash` — pre-hashed via PasswordHasherPort, or `None` if /// the user has no password yet (magic-link or OIDC bootstrap) - /// * `oidc_provider`, `oidc_subject` — both `Some` when the user is + /// * `federation_issuer`, `federation_subject` — both `Some` when the user is /// linked to an external IdP, both `None` otherwise /// * `role` — `Admin` is rejected when `is_external = true` (mirrors the /// `users_external_not_admin` DB CHECK constraint) @@ -251,8 +314,9 @@ impl User { email: String, username: Option, password_hash: Option, - oidc_provider: Option, - oidc_subject: Option, + federation_kind: Option, + federation_issuer: Option, + federation_subject: Option, role: UserRole, storage_quota_bytes: i64, is_external: bool, @@ -280,12 +344,26 @@ impl User { "External users must have storage_quota_bytes = 0".to_string(), )); } - // OIDC linkage is all-or-nothing: both provider and subject set, - // or neither. The DB has a UNIQUE index on (provider, subject) - // WHERE both non-NULL; partial state would corrupt that. - if oidc_provider.is_some() != oidc_subject.is_some() { + // Federation linkage is all-or-nothing: both issuer and subject set, + // or neither. The DB has a UNIQUE index on + // (federation_kind, federation_issuer, federation_subject) WHERE + // federation_kind IS NOT NULL; partial state would corrupt that. + // + // For kinds that don't carry an authority-issued subject + // (MagicLink today — identity is the local email), both fields + // stay None even when `federation_kind` is set. The check below + // only fires on inconsistent partial state. + if federation_issuer.is_some() != federation_subject.is_some() { return Err(UserError::ValidationError( - "oidc_provider and oidc_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 + // schema keys anti-duplicate on the composite (kind, issuer, + // subject) and a NULL kind would defeat the uniqueness. + if federation_issuer.is_some() && federation_kind.is_none() { + return Err(UserError::ValidationError( + "federation_kind is required when federation_issuer/subject are set".to_string(), )); } @@ -302,8 +380,9 @@ impl User { updated_at: now, last_login_at: None, active: true, - oidc_provider, - oidc_subject, + federation_kind, + federation_issuer, + federation_subject, image: None, is_external, given_name: None, @@ -355,8 +434,9 @@ impl User { updated_at, last_login_at, active, - oidc_provider: None, - oidc_subject: None, + federation_kind: None, + federation_issuer: None, + federation_subject: None, image: None, // `from_data` is the minimal-args reconstruction path used by // tests and JWT-claim-based principal hydration (which doesn't @@ -387,8 +467,9 @@ impl User { updated_at: DateTime, last_login_at: Option>, active: bool, - oidc_provider: Option, - oidc_subject: Option, + federation_kind: Option, + federation_issuer: Option, + federation_subject: Option, image: Option, is_external: bool, given_name: Option, @@ -413,8 +494,9 @@ impl User { updated_at, last_login_at, active, - oidc_provider, - oidc_subject, + federation_kind, + federation_issuer, + federation_subject, image, is_external, given_name, @@ -556,12 +638,16 @@ impl User { format!("{}…", &self.id.to_string()[..8]) } - pub fn oidc_provider(&self) -> Option<&str> { - self.oidc_provider.as_deref() + pub fn federation_kind(&self) -> Option { + self.federation_kind } - pub fn oidc_subject(&self) -> Option<&str> { - self.oidc_subject.as_deref() + pub fn federation_issuer(&self) -> Option<&str> { + self.federation_issuer.as_deref() + } + + pub fn federation_subject(&self) -> Option<&str> { + self.federation_subject.as_deref() } pub fn image(&self) -> Option<&str> { @@ -739,7 +825,7 @@ impl User { /// Returns true if this is an OIDC-only user (no password) pub fn is_oidc_user(&self) -> bool { - self.oidc_provider.is_some() + self.federation_issuer.is_some() } /// Returns true iff this user has any non-magic-link authentication @@ -748,7 +834,7 @@ impl User { /// the negation of this; the `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS` /// flag widens the policy at the service layer (`magic_link_eligibility`). pub fn has_login_credential(&self) -> bool { - self.password_hash.is_some() || self.oidc_subject.is_some() + self.password_hash.is_some() || self.federation_subject.is_some() } /// Set the password hash. The new password must be hashed externally @@ -891,9 +977,10 @@ mod tests { Utc::now(), None, true, - None, - None, - None, + None, // federation_kind + None, // federation_issuer + None, // federation_subject + None, // image false, given.map(str::to_string), family.map(str::to_string), diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index ac41dae2..a25f7c69 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -81,16 +81,16 @@ pub trait SessionRepository: Send + Sync + 'static { async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> SessionRepositoryResult>; /// Revokes every session belonging to the user identified by - /// `(oidc_provider, oidc_subject)`. + /// `(federation_issuer, federation_subject)`. /// /// Fallback path for the Back-Channel Logout handler when the IdP /// omits `sid` from the logout_token — coarser than sid-based /// revocation (kills the user's other devices too). Returns the /// user id of the affected account, or `None` if no matching user. - async fn revoke_user_sessions_by_oidc_subject( + async fn revoke_user_sessions_by_federation_subject( &self, - oidc_provider: &str, - oidc_subject: &str, + issuer: &str, + subject: &str, ) -> SessionRepositoryResult>; /// Deletes expired sessions diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 0ff4f659..101ba4fd 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -45,7 +45,7 @@ pub struct UserListEntry { pub storage_used_bytes: i64, pub last_login_at: Option>, pub active: bool, - pub oidc_provider: Option, + pub federation_issuer: Option, pub is_external: bool, /// TRUE when `auth.users.password_hash IS NOT NULL` — user has a /// server-verifiable password on file (legacy or admin-set). @@ -53,7 +53,7 @@ pub struct UserListEntry { /// envelope): a fully-migrated user carries BOTH — password for /// the fallback / operator flows, envelope for the actual login. /// A user with `has_password = false AND !opaque_registered AND - /// oidc_provider IS NULL` is passwordless — the only path in is + /// federation_issuer IS NULL` is passwordless — the only path in is /// via magic-link (or, for externals, whatever grant they hold). pub has_password: bool, /// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user @@ -173,10 +173,10 @@ pub trait UserRepository: Send + Sync + 'static { /// Deletes a user async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>; - /// Finds a user by OIDC provider + subject pair - async fn get_user_by_oidc_subject( + /// Finds a user by federation (issuer, subject) pair. + async fn get_user_by_federation_subject( &self, - provider: &str, + issuer: &str, subject: &str, ) -> UserRepositoryResult; diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 0ac4372a..2b1ff485 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -390,12 +390,17 @@ impl SessionRepository for SessionPgRepository { /// Back-Channel Logout fallback — the IdP omitted `sid` in the /// logout_token, so we revoke every session belonging to the user - /// identified by (oidc_provider, oidc_subject). Users are looked up - /// through the existing auth.users columns. - async fn revoke_user_sessions_by_oidc_subject( + /// identified by (federation_issuer, federation_subject). Users are + /// looked up through auth.users; the query is federation-kind-agnostic + /// today (matches any user regardless of federation_kind) — an OCM + /// notification arriving here would find only OIDC users because that's + /// the only path that writes federation_issuer today, but the schema + /// admits OCM rows too, so this method may see multi-kind matches once + /// OCM lands. Restrict by federation_kind then if that becomes ambiguous. + async fn revoke_user_sessions_by_federation_subject( &self, - oidc_provider: &str, - oidc_subject: &str, + issuer: &str, + subject: &str, ) -> SessionRepositoryResult> { // Two-step: look up the user first (deterministic error class if // the user is unknown), then revoke. Combining into a single @@ -404,11 +409,11 @@ impl SessionRepository for SessionPgRepository { let user_row = sqlx::query( r#" SELECT id FROM auth.users - WHERE oidc_provider = $1 AND oidc_subject = $2 + WHERE federation_issuer = $1 AND federation_subject = $2 "#, ) - .bind(oidc_provider) - .bind(oidc_subject) + .bind(issuer) + .bind(subject) .fetch_optional(&*self.pool) .await .map_err(Self::map_sqlx_error)?; @@ -432,9 +437,9 @@ impl SessionRepository for SessionPgRepository { tracing::info!( target: "audit", - event = "oidc.backchannel_logout_by_sub", - oidc_provider = %oidc_provider, - oidc_subject = %oidc_subject, + event = "federation.backchannel_logout_by_sub", + federation_issuer = %issuer, + federation_subject = %subject, user_id = %user_id, revoked_count = result.rows_affected(), "👮🏻‍♂️ OIDC backchannel-logout revoked all user sessions by sub" @@ -586,12 +591,12 @@ impl SessionStoragePort for SessionPgRepository { .map_err(DomainError::from) } - async fn revoke_user_sessions_by_oidc_subject( + async fn revoke_user_sessions_by_federation_subject( &self, - oidc_provider: &str, - oidc_subject: &str, + issuer: &str, + subject: &str, ) -> Result, DomainError> { - SessionRepository::revoke_user_sessions_by_oidc_subject(self, oidc_provider, oidc_subject) + SessionRepository::revoke_user_sessions_by_federation_subject(self, issuer, subject) .await .map_err(DomainError::from) } diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 132b4c21..54ba9022 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -282,12 +282,13 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, image, is_external, + federation_kind, federation_issuer, federation_subject, + image, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share, ui_preferences ) VALUES ( $1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 + $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22 ) RETURNING * "#, @@ -303,8 +304,9 @@ impl UserRepository for UserPgRepository { .bind(user_clone.updated_at()) .bind(user_clone.last_login_at()) .bind(user_clone.is_active()) - .bind(user_clone.oidc_provider()) - .bind(user_clone.oidc_subject()) + .bind(user_clone.federation_kind().map(|k| k.as_str())) + .bind(user_clone.federation_issuer()) + .bind(user_clone.federation_subject()) .bind(user_clone.image()) .bind(user_clone.is_external()) .bind(user_clone.given_name()) @@ -339,7 +341,7 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, image, is_external, + federation_kind, federation_issuer, federation_subject, image, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share, ui_preferences FROM auth.users @@ -370,8 +372,11 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), - row.get("oidc_provider"), - row.get("oidc_subject"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), row.get("image"), row.get("is_external"), row.get("given_name"), @@ -391,7 +396,7 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, image, is_external, + federation_kind, federation_issuer, federation_subject, image, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share, ui_preferences FROM auth.users @@ -422,8 +427,11 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), - row.get("oidc_provider"), - row.get("oidc_subject"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), row.get("image"), row.get("is_external"), row.get("given_name"), @@ -443,7 +451,7 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, image, is_external, + federation_kind, federation_issuer, federation_subject, image, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share, ui_preferences FROM auth.users @@ -474,8 +482,11 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), - row.get("oidc_provider"), - row.get("oidc_subject"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), row.get("image"), row.get("is_external"), row.get("given_name"), @@ -512,7 +523,7 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, is_external, + federation_kind, federation_issuer, federation_subject, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share FROM auth.users WHERE id = ANY($1) @@ -544,8 +555,11 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), - row.get("oidc_provider"), - row.get("oidc_subject"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), None, // image — not projected (notification-recipient path) row.get("is_external"), row.get("given_name"), @@ -693,7 +707,7 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, image, is_external, + federation_kind, federation_issuer, federation_subject, image, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share, ui_preferences FROM auth.users @@ -731,8 +745,11 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), - row.get("oidc_provider"), - row.get("oidc_subject"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), row.get("image"), row.get("is_external"), row.get("given_name"), @@ -780,13 +797,13 @@ impl UserRepository for UserPgRepository { // pays. `has_password` on the password_hash column tells // the admin table whether a server-verifiable password is // on file; combined with the two OPAQUE flags and - // oidc_provider, the SPA derives the full "capability + // federation_issuer, the SPA derives the full "capability // set" per user (password / OPAQUE / SSO / passwordless). r#" SELECT id, username, email, role::text, storage_quota_bytes, storage_used_bytes, - last_login_at, active, oidc_provider, is_external, + last_login_at, active, federation_issuer, is_external, (password_hash IS NOT NULL) AS has_password, (opaque_envelope IS NOT NULL) AS opaque_registered, (opaque_migrated_at IS NOT NULL) AS opaque_migrated @@ -815,7 +832,7 @@ impl UserRepository for UserPgRepository { storage_used_bytes, last_login_at, active, - oidc_provider, + federation_issuer, is_external, has_password, opaque_registered, @@ -833,7 +850,7 @@ impl UserRepository for UserPgRepository { storage_used_bytes, last_login_at, active, - oidc_provider, + federation_issuer, is_external, has_password, opaque_registered, @@ -856,7 +873,7 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, image, is_external, + federation_kind, federation_issuer, federation_subject, image, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share, ui_preferences FROM auth.users @@ -894,8 +911,11 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), - row.get("oidc_provider"), - row.get("oidc_subject"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), row.get("image"), row.get("is_external"), row.get("given_name"), @@ -999,7 +1019,7 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, image, is_external, + federation_kind, federation_issuer, federation_subject, image, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share, ui_preferences FROM auth.users @@ -1034,8 +1054,11 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), - row.get("oidc_provider"), - row.get("oidc_subject"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), row.get("image"), row.get("is_external"), row.get("given_name"), @@ -1068,7 +1091,7 @@ impl UserRepository for UserPgRepository { } /// Finds a user by OIDC provider + subject pair - async fn get_user_by_oidc_subject( + async fn get_user_by_federation_subject( &self, provider: &str, subject: &str, @@ -1079,11 +1102,11 @@ impl UserRepository for UserPgRepository { id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, oidc_subject, image, is_external, + federation_kind, federation_issuer, federation_subject, image, is_external, given_name, family_name, email_verified_at, preferred_locale, notify_on_share, ui_preferences FROM auth.users - WHERE oidc_provider = $1 AND oidc_subject = $2 + WHERE federation_issuer = $1 AND federation_subject = $2 "#, ) .bind(provider) @@ -1110,8 +1133,11 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), - row.get("oidc_provider"), - row.get("oidc_subject"), + row.get::, _>("federation_kind") + .as_deref() + .and_then(crate::domain::entities::user::FederationKind::parse), + row.get("federation_issuer"), + row.get("federation_subject"), row.get("image"), row.get("is_external"), row.get("given_name"), @@ -1367,12 +1393,12 @@ impl UserStoragePort for UserPgRepository { .map_err(DomainError::from) } - async fn get_user_by_oidc_subject( + async fn get_user_by_federation_subject( &self, provider: &str, subject: &str, ) -> Result { - UserRepository::get_user_by_oidc_subject(self, provider, subject) + UserRepository::get_user_by_federation_subject(self, provider, subject) .await .map_err(DomainError::from) } @@ -1441,7 +1467,7 @@ mod integration_tests { id, username, email, password_hash, role, storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, - oidc_provider, is_external + federation_issuer, is_external ) VALUES ( $1, $2, $3, NULL, $4::auth.userrole, $5, 0, @@ -1515,7 +1541,7 @@ 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].oidc_provider.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