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