About federation#

A Plone site running the [server] layer can be the provider that another Plone site signs in against. Federate two Plone sites runs exactly that, in a browser, in a few minutes.

This page is about the parts of it that are not obvious, and the three things that go wrong.

The issuer is a string, and it is compared as one#

A relying party fetches the discovery document from a URL, reads the issuer field inside it, and compares the two byte for byte. If they differ, it refuses the document.

That comparison is in the specification, and it is what stops an attacker serving a document that claims to speak for somebody else. It is also unforgiving in ways that read as bugs.

http://id.localhost and http://id.localhost/ are different issuers. So is the same host on a different port. So is https where the document says http.

This is why the issuer is a registry setting, pas.plugins.identity.server_issuer, and why the server builds every URL in the discovery document from it rather than from portal_url.

Deriving endpoints from portal_url would make that byte comparison depend on a proxy header, a virtual host, or a trailing slash. It would work in development and fail behind a load balancer, which is the worst possible place for it to fail.

If a sign-in fails with a mismatched iss, that setting is where to look first.

One URL has to resolve in two places#

The browser is redirected to the provider's issuer URL. The relying party's own process fetches the discovery document from the same URL.

Those are two different network positions, and they have to agree on what the name means. In the demo stack, Traefik carries a network alias so the hostname resolves the same way inside a container and outside it.

There is deliberately no second, internal URL to configure. A provider answers only to the name it publishes, because that name is the issuer, and the issuer is what everything is checked against. An internal alias that differed would be a second issuer, and the comparison above would fail.

OAuth endpoints are not REST API services#

@@oauth-authorize and its siblings are Zope browser views on the site root.

They are not plone.restapi services, so a routing rule that sends /++api++ to the backend does not carry them. Without a router of their own, they reach the Volto frontend instead, which knows nothing about them.

This is what makes configuring the issuer load-bearing rather than fussy. The issuer determines the URLs in the discovery document, and those URLs have to be routed to the backend by a rule that was written for them specifically.

How a Plone provider authenticates its own users#

This is the part that had to be built, and the reason is a genuine mismatch between two things Plone already had.

@@oauth-authorize is a browser view, so the visitor arriving at it has to be a Zope principal. A Volto sign-in does not produce one. Volto keeps its token in an auth_token cookie and sends it as an Authorization header on its own API calls, and plone.restapi's JWT plugin is the only reader of that header.

Measured on a running site: the authorize view with only the cookie answers as anonymous, and the same request with the token in a header authenticates. Two halves that each work, unable to talk to each other.

So the [server] layer installs a PAS plugin that reads that cookie, for the authorization endpoint and nothing else. Everywhere else the site behaves exactly as plone.restapi left it, because widening the reader would mean every request in the site now accepts a credential from a cookie, which is a different security posture than the one the site was configured with.

There was a second problem in the same area. This add-on's /login route replaces Volto's, and it used to offer providers and the magic link only. An identity provider has no providers of its own, so it showed "no sign-in options" and no way in at all. The password form is back, controlled by RAZZLE_IDENTITY_SHOW_PLONE_LOGIN.

Note

process.env.RAZZLE_SOMETHING, written out literally, is substituted into the browser bundle when the frontend is built, so a value supplied to a running container reaches the Node process and never the browser. This setting is read through Volto's runtimeConfig instead, which resolves process.env on the server and window.env on the client, so it is an ordinary environment variable and both demo sites run the same image.

Groups cross, but only as far as you let them#

A Plone provider releases a groups claim, and a relying party can map it onto local groups. Neither half assumes the other.

The provider states a fact: these are the groups this person is in over here. It has no idea what they mean anywhere else, and it never says what they should grant.

The relying party decides. It starts with an empty map, so a peer's groups grant nothing at all until somebody says which of them mean something on this site. A provider group with no row grants nothing and is never created here, which matters because a group name is whatever the far end's directory happens to call it: minting local groups from it would let anyone who can name a group over there create one here.

Two sites in a federation do not have the same groups just because they run the same package. That is why the plone-identity driver ships an empty group map even though it knows the peer releases the claim.

Revocation is the hard half#

Adding a membership is easy; taking one back is where federated group membership differs from local group membership. A membership revoked at the provider has to stop granting here, and nobody is going to notice that by hand, so every sign-in reconciles.

A reconciliation that simply wrote what the provider said would also erase every group an administrator granted locally, and every group a second provider granted, silently, on the next sign-in.

So each identity records what its own provider granted. A sign-in adds what is newly granted and removes only what that same provider granted before. A group granted by hand is never touched, and two providers cannot revoke each other's grants.

One consequence is worth stating: clearing a group map does not take its grants back. Clearing is at least as likely to mean that the map is being rewritten as that every grant should be revoked, so a provider with an empty map touches no membership at all.

Two stacks, on purpose#

The repository has two federation setups, and they are not redundant.

backend/tests/federation/docker-compose.yml runs the same two roles headless, with published ports, asserted by pytest -m docker. It has to run in CI, so it gets no browser and no real provider.

The demo stack exists for what CI cannot do. A real browser, real cookies between sibling .localhost hosts, and Volto's real login and callback routes.

Both are driven from the same identitydemo package, and they differ only in four URLs that come from the environment.

Two claims are not registered, and are released anyway#

description and groups have no registered OIDC claim to be. Both are released under profile anyway, rather than under a private scope of their own.

The reasoning is the same for each. A relying party that does not recognise a claim ignores it. Both names are read as-is elsewhere: groups is what Keycloak, Okta, and Entra all call it. And a namespaced claim only this server's own peers would understand buys nothing but a second thing to configure at both ends.

That is the whole of the extension, and it is not a general one. A field a site adds to its UserProfile type still has no claim to go in, and inventing one per site would emit something no other implementation can read. The extension point for that is a private scope releasing namespaced claims. It is deliberately not built, because it needs a naming decision that should be made once, by somebody who has a second implementation to be compatible with.

groups rides on a display scope#

This is a deliberate trade, and worth stating plainly.

profile is a scope a relying party asks for in order to show something about a person. Group membership is authorization data. Releasing it under profile means every relying party granted that scope receives the group list, whether it maps groups or not.

What the server does control is the content:

  • AuthenticatedUsers is never released. Every principal with a session is in it, so it says nothing about anybody, and a relying party that mapped it would grant its local counterpart to every federated user.

  • The claim is omitted entirely for a user in no other group, rather than sent as an empty list.

If your site's group names are themselves sensitive, do not grant profile to clients you would not grant the group list to.

Where to go next#