No description
  • Java 97.4%
  • FreeMarker 2.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
seatbroker 0f14176149 keycloak-persona-authenticator: shared external SSO seats via login-time identity override
Keycloak authenticator + protocol mappers that let many internal users
share a fixed number of external SSO seats. In the Post Broker Login flow
it authorizes the real user, resolves which seat personas they may assume
(group- or claim/regex-based ACL over brokered LDAP groups, many-to-many
seat-group tags, optional gated self option), shows a picker or
auto-selects, and records the chosen persona as a per-client user-session
note. The session user stays the real user (no swap, no cross-client
session conflicts); custom SAML and OIDC mappers then override the
outgoing NameID/claims (sub, email, and a dynamic persona-attribute ->
target-name list) so the SP counts one seat. Includes localized picker/
deny messages (EN/DE), per-client self and seat-key config on the mapper,
audit logging, docker-compose, and a full README.
2026-08-31 03:35:50 +00:00
src/main keycloak-persona-authenticator: shared external SSO seats via login-time identity override 2026-08-31 03:35:50 +00:00
.gitignore keycloak-persona-authenticator: shared external SSO seats via login-time identity override 2026-08-31 03:35:50 +00:00
docker-compose.yml keycloak-persona-authenticator: shared external SSO seats via login-time identity override 2026-08-31 03:35:50 +00:00
pom.xml keycloak-persona-authenticator: shared external SSO seats via login-time identity override 2026-08-31 03:35:50 +00:00
README.md keycloak-persona-authenticator: shared external SSO seats via login-time identity override 2026-08-31 03:35:50 +00:00

keycloak-persona-authenticator

A Keycloak authenticator that lets many internal users share a fixed number of external SSO seats. Real users sign in through your company IdP; at login they become a shared "seat" identity (a persona) that the external SP counts as one seat — ACL-gated, self-service, and audited.


1. The problem

External SaaS (Acme, AWS, …) sell team plans with a fixed seat count and offer SSO (SAML/OIDC). The SP counts seats by the identity it receives — the SAML NameID / email / OIDC sub. Before SSO you could share one login via a shared mailbox. With SSO every internal user brings their own identity, so the seat count explodes and the shared-login trick is gone.

This middleware sits between the SP and your company IdP and remaps identity at login: several internal people present as the same seat persona, so the SP sees one seat no matter who logged in.


2. How it works

Keycloak runs as an identity broker + IdP in its own realm ("the broker realm"). The external SP is a client in that realm. The realm brokers authentication to your company Keycloak (or any IdP) via an Identity Provider entry.

 External SP ──SAML/OIDC──►  Broker realm (this Keycloak)
                               │  1. redirect to company IdP (Identity Provider)
                               │  2. user authenticates there, returns
                               │  3. First/Post Broker Login flow runs:
                               │        ┌─────────────────────────────────────┐
                               │        │  Persona (Seat) Selection  ← authn SPI│
                               │        │   • real user known (alice)          │
                               │        │   • ACL: which seats may they take?   │
                               │        │   • auto-select one, or show picker   │
                               │        │   • record persona in a session note  │
                               │        └─────────────────────────────────────┘
                               │  4. issue SAML assertion:
                               │        Persona SAML mapper reads the note and
                               │        overrides NameID (+ optional email attr)
                               ▼
                        assertion carries the PERSONA's email → SP counts one seat
                        (Keycloak session user stays the REAL user)

Identity is overridden in the assertion, not by swapping the session user. The authenticator records the chosen persona as a per-client user-session note; the session user stays the real user. A custom protocol mapper reads that note at assertion time and rewrites the outgoing identity to the persona — SAML (PersonaSamlMapper: NameID + optional email attribute) or OIDC (PersonaOidcMapper: sub/email/preferred_username). Add it to each seat client.

Why not swap the session user? Because a browser SSO session can hold only one user. Swapping caused "You are already authenticated as different user" whenever a second client (another seat app, or the account console) was hit in the same browser. The assertion-override approach keeps one coherent session (the real user) and changes only what each SP sees — no conflicts, and it's per-client.


3. Topology — one realm is enough

You do not need two realms. The two-realm idea only applies if your company IdP is another realm inside the same Keycloak. When you broker to a separate company Keycloak (the normal case), one realm holds everything:

broker realm
├── Identity provider  → company Keycloak      (+ groups mapper, + Post Broker Login flow)
├── personas           = local users (the shared seats)
├── clients            = the external SPs
└── post-broker-persona flow  = the Persona (Seat) Selection step

Real users are federated in from the company IdP; personas and SP clients live alongside them; the swap happens in the post-broker flow.


4. Core concepts

Concept What it is
Real user The person, federated in from the company IdP (e.g. alice).
Persona / seat A local Keycloak user representing one shared external seat. Carries seat.key (+ tags). N real users can become the same persona simultaneously.
Seat client An external SP client that enforces a persona. Detected automatically: a client is a seat client iff a persona pool exists for its key. Everything else (account console, normal apps) passes through untouched.
Seat-key Groups personas + a client into one pool. Defaults to the client's clientId; optionally overridden with a seat.key client attribute.
Entitlement Which seats a real user may take, derived from their group memberships.
Self Optional extra picker option: proceed as your own identity (the SP counts your own seat). Gated.

Pure impersonation, no seat pool. There is deliberately no "seat taken" / concurrency / checkout logic. Any number of users may be the same persona at once — that's the whole point.


5. Two ACL modes

Selected with SEATBROKER_ACL_MODE.

group (default) — zero external state, all in Keycloak groups

  • Personas for key C = members of group /personas/C.
  • A real user may take seats for C iff they are in group /seat-access/C.
  • Good for a small static setup you click together in the admin console.

claim — flat, regex-driven, scales with your LDAP/company groups

Entitlement comes from the brokered groups claim, not local groups. Best when your company IdP already emits ACL groups (e.g. from LDAP).

  • The real user's brokered groups land on a user attribute (default groups) via an IdP Attribute Importer mapper.
  • Each group value is matched against SEATBROKER_KEY_REGEX:
    • capture group 1 = seat-key (which pool / client),
    • capture group 2 (optional) = a seat-group tag.
  • Personas carry a multi-valued tag attribute (default seat.group). A real user entitled to a set of tags may pick any persona whose tags intersect that set (union across the user's memberships).
  • If a matched group has no group-2 tag, it is wholesale: all personas for that key.
  • All comparisons case-insensitive (uppercase LDAP names line up with lowercase config).

Example (SEATBROKER_KEY_REGEX=^ACL_KEYCLOAK_SEATACCESS_([^_]+)_(.+)$):

personas:   seat-sales  seat.key=Acme  seat.group=[sales]
            seat-support  seat.key=Acme  seat.group=[support]
            seat-both     seat.key=Acme  seat.group=[sales, support]

user alice groups:
   ACL_KEYCLOAK_SEATACCESS_Acme_Sales     → key acme, tag sales
   ACL_KEYCLOAK_SEATACCESS_Acme_support     → key acme, tag support
   → entitled tags {sales, support}
   → picker offers seat-sales, seat-support AND seat-both

drop the _support group → entitled {sales} → offers seat-sales + seat-both

6. Full setup (real case: SAML SP via a SAML proxy, company Keycloak, LDAP groups)

Everything below is in one broker realm.

6.1 Broker to the company IdP

Identity providers → Add → OIDC or SAML → point at your company Keycloak. Real users federate in on first login.

6.2 Emit the ACL groups from the company IdP

On the company Keycloak, on the client the broker realm uses, add a Group Membership mapper:

  • Token Claim Name: groups
  • Full group path: OFF (flat names like ACL_KEYCLOAK_SEATACCESS_Acme_Sales)
  • Add to ID + access token: On

(If groups come from LDAP, your existing LDAP group mapper already imports them as Keycloak groups; this just puts them in the token.)

6.3 Land the groups on the user (broker realm)

Identity providers → your broker → Mappers → Add → Attribute Importer:

  • Claim: groups
  • User Attribute: groups
  • Sync mode override: FORCE (re-import every login)

This creates a user attribute, not groups. Check the user's Attributes tab, not the Groups tab.

6.4 Keycloak 26 gotcha — unmanaged attributes

KC 26 enables the declarative user profile and drops undeclared attributes by default. So groups, seat.key, seat.group silently vanish unless you either:

  • Realm settings → User profile → Unmanaged Attributes → Enabled, or
  • declare groups, seat.key, seat.group (mark seat.group Multivalued).

All user attributes are stored as lists internally, so they show as JSON arrays even for one value — that's normal.

6.5 Post Broker Login flow

The browser flow does not reliably resume its steps after an IdP redirect, so the persona step must run in the Post Broker Login flow.

  1. Authentication → Flows → Create flow post-broker-persona (Basic flow).
  2. Add step → Persona (Seat) Selection → Required.
  3. Identity providers → your broker → Advanced settings → Post broker login flow = post-broker-persona.

(Leave the browser flow default. The Persona step does nothing there.)

6.6 Create personas (the shared seats)

Users → Add user, one per seat:

  • Email = the address the SP counts as the seat.
  • Attributes: seat.key = the pool (e.g. Acme), seat.group = one or more tags (multivalued), seat.label = optional picker hint.
  • No credentials (login target only).

6.7 The SP client

Create the SP client (SAML) as usual. It becomes a seat client automatically because personas exist for its key. Its seat-key resolves as: seat.key client attribute → Seat key on the Persona mapper → client id. If the client id is ugly (e.g. a random generated client id), the easiest clean key is the Seat key field on the mapper (next step) — no kcadm needed. The client-attribute route still works:

kcadm.sh update clients/<UUID> -r <broker-realm> -s 'attributes."seat.key"=Acme'

6.8 Add the Persona SAML mapper to the SP client (REQUIRED)

The identity override happens here — without it the SP sees the real user. On the SAML seat client → Client scopes / Dedicated scope → Mappers → Add mapper → By configuration → "Persona Seat (identity override)":

  • Seat key (pool): the seat pool this client uses (matches personas' seat.key). Set it here to avoid a client attribute; blank = client id.
  • Name ID Format: set it to the same NameID format the client uses (e.g. email). Keycloak only invokes the NameID override when these match — leaving it unset means the NameID is not overridden.
  • NameID source: email (or username) — what the persona's NameID becomes.
  • Persona attribute → SAML attribute name (dynamic list): add a row per attribute. Left = a persona field (email, firstName, lastName, username, id) or any persona user attribute name; Right = the SAML attribute name your SP reads (e.g. email, firstName, lastName). The persona's value replaces any value other mappers produced for that attribute. One left may map to several rights.

The mapper is a no-op when the user chose "self" or the client isn't a seat login, so the real user's own identity flows through untouched.

Make sure the client's NameID format matches (typically email), and that no other NameID/email mapper re-adds the real user's value after this one.

OIDC seat clients: add "Persona Seat (identity override)" on the OIDC client the same way. It always overrides the standard claims (sub, email, email_verified, preferred_username, given_name, family_name) from the note. The Persona attribute → claim name list adds custom-named claims (e.g. firstName/lastName for the SP) — same left/right semantics as the SAML mapper; rows targeting a standard claim are skipped (already set). The sub override is toggleable (turn off if the SP counts seats by email only).

6.9 Turn on claim mode + configure

Set on the Keycloak process (env, -D, or SPI args), then restart:

SEATBROKER_ACL_MODE=claim
SEATBROKER_KEY_REGEX=^ACL_KEYCLOAK_SEATACCESS_([^_]+)_(.+)$
SEATBROKER_TAG_ATTR=seat.group
SEATBROKER_SELF_TAG=            # empty=off, * = always, or a tag

Startup log confirms the effective config:

persona ACL mode=claim groupsAttr=groups tagAttr=seat.group selfTag='' keyRegex=^ACL_KEYCLOAK_SEATACCESS_([^_]+)_(.+)$

7. Configuration reference

Each key is read from SPI scope → environment variable → system property → default (first non-blank wins).

Env var SPI arg (--spi-authenticator-persona-seat-authenticator-…) Default Applies Meaning
SEATBROKER_ACL_MODE …-acl-mode group both group or claim.
SEATBROKER_GROUPS_ATTR …-groups-attr groups claim User attribute holding the brokered groups.
SEATBROKER_KEY_REGEX …-key-regex ^seat-access-(.+)$ claim group 1 = seat-key, optional group 2 = tag.
SEATBROKER_TAG_ATTR …-tag-attr seat.group claim Persona multi-valued seat-group tag attribute.
SEATBROKER_SELF_TAG …-self-tag `` (off) claim `` off, * always, else a tag that unlocks "self".
SEATBROKER_ALWAYS_PROMPT …-always-prompt false both true shows the picker even when only one option matches (confirmation); false auto-selects it.

Persona (user) attributes

Attribute Multivalued Meaning
seat.key no Which pool the persona belongs to (matches the client's seat-key).
seat.group yes Seat-group tags (claim mode).
seat.label no Optional label shown in the picker.

Client

Attribute Meaning
seat.key Optional override of the seat-key; defaults to the client id. Set via kcadm or the Persona mapper's Seat key field (client attribute wins).
seat.self Optional per-client override of the self setting. off/false/no = never, on/true/yes/* = always, any other value = a tag the user must hold. Unset → global SEATBROKER_SELF_TAG. Set via kcadm.

8. LDAP / group naming convention (claim mode)

ACL_KEYCLOAK_SEATACCESS_<SeatKey>_<Tag>
                         │          └── seat-group tag (matches persona seat.group)
                         └── seat-key (matches client seat-key / clientId)

Onboarding a user to a seat = adding them to one LDAP group. No Keycloak clicks. A user in several such groups is entitled to the union.


9. The picker

Shown only when 2+ options match; a single match auto-selects (set SEATBROKER_ALWAYS_PROMPT=true to force the picker even for one, e.g. as a confirmation step). Each row shows username – email (label). Built from theme-resources/templates/persona-select.ftl.

Denied message & localization

The "no seat" page uses the message key seatBrokerNoSeat, resolved via realm localization first (Realm settings → Localization → add a translation for the key, per locale), then the bundle shipped in theme-resources/messages/messages_<locale>.properties (EN + DE included) as fallback. Override it per-realm/locale without rebuilding by adding the key in realm localization.

10. Self option

When enabled (SEATBROKER_SELF_TAG), a leading row lets the user proceed as their own identity (no swap) — the SP counts their own seat. The row shows the real user's own username + email (no hardcoded text, nothing to localize). The choice is re-checked server-side and audited as seat.self.

Use * to always offer it, or a tag (e.g. self) so only users in ACL_KEYCLOAK_SEATACCESS_<Key>_self get it.

Per-client override: the seat.self client attribute overrides the global setting for one client (off/on/*/a tag). So you can leave the global off and enable self only on the Acme client, or global * and disable it on specific clients. Set via kcadm (no admin-UI panel for client attributes).


11. Audit & attribution

After the swap, the real user is gone from the Keycloak session and the SP cannot tell your users apart (by design). The authenticator's log is therefore the only attribution record. Logged to seatbroker.audit:

  • seat.assume real_user=… -> persona=… client=… — a swap happened.
  • seat.self real_user=… client=… — proceeded as own identity.
  • seat.deny real_user=… client=… reason=… — denied.

For production, extend audit/AuditLogger to also write an append-only store (DB/SIEM) — this mapping is your compliance record for "who used seat X at time T".


12. Security notes

  • Seat-sharing means loss of per-user attribution at the SP — the whole point. Keep the audit trail above.
  • The picked value from the form is re-validated server-side; the picker is a hint, never trusted.
  • Personas should have no interactive credentials — they are swap targets, not people.
  • Fails closed: not a seat client → passthrough as real user; seat client but no entitlement / disabled persona / out-of-ACL choice → ACCESS_DENIED.
  • This flow runs on every brokered login in the realm, including the realm's own account console — the seat-client passthrough is what keeps those working.
  • The SPI implements an internal Keycloak API (you'll see a KC-SERVICES0047 warning); it may need adjusting across major Keycloak upgrades.

13. Build & deploy

mvn package                                   # → target/keycloak-persona-authenticator.jar
cp target/keycloak-persona-authenticator.jar $KEYCLOAK_HOME/providers/
$KEYCLOAK_HOME/bin/kc.sh build                # prod dist; start-dev auto-builds

Docker: mount the jar into /opt/keycloak/providers/ and restart (see docker-compose.yml). start-dev rebuilds automatically.


14. Troubleshooting

Always start with:

docker logs <kc> | grep -i seat | tail
Symptom Cause Fix
No seat.* lines at all, user logs in as themselves Persona step not on the executed path It must be Required in the Post Broker Login flow on the IdP — not the browser flow.
seat.deny … no personas for user/client Entitlement/tag/key mismatch Check the group regex captures, the client's seat-key, and that personas exist for that key.
Picker shows but wrong/'1' option Tag mismatch Persona seat.group values vs the user's entitled tags; confirm attribute name and multivalued.
seat.deny … persona user missing/disabled getUserById failed Persona disabled or wrong id.
SP shows the real user, not the persona Persona SAML mapper not on the client (or a later mapper overrides NameID) Add "Persona Seat (identity override)" to the SAML seat client; check NameID format.
"You are already authenticated as different user 'xy'" Old session-user-swap behavior colliding across clients Use the current jar — identity is overridden in the assertion, the session user no longer changes.
Attribute empty on the user KC 26 dropped an undeclared attribute Enable Unmanaged Attributes or declare it in the User profile.
Groups claim empty No Group Membership mapper on the company-IdP client Add it, Full group path OFF.
Regex extracts the wrong key (e.g. acme_sales) Single-capture regex on a two-segment name Use the two-capture regex.

15. Extending — custom ACL

Implement acl.AclService (source entitlements from a DB, HR system, HTTP service, …) and load it in PersonaAuthenticatorFactory.init(). The three methods: isSeatClient, personasFor, and (optional) allowsSelf. seatKeyOf and validate have sensible defaults.


16. Layout

pom.xml
docker-compose.yml
src/main/java/com/seatbroker/persona/
  PersonaAuthenticator.java         # flow step: ACL → options → pick → session note
  PersonaAuthenticatorFactory.java  # registration + config (ACL mode, regex, tags, self)
  SeatConstants.java                # per-client persona note key (writer/reader contract)
  acl/AclService.java               # ACL interface (+ seatKeyOf/validate defaults)
  acl/GroupAclService.java          # group-backed ACL (default mode)
  acl/ClaimAclService.java          # regex-over-brokered-groups ACL (claim mode)
  acl/PersonaOption.java            # one option (persona or "self")
  mapper/PersonaResolver.java       # shared: resolve persona from the session note
  mapper/PersonaSamlMapper.java     # SAML: override NameID (+ email attr) from the note
  mapper/PersonaOidcMapper.java     # OIDC: override sub/email/preferred_username
  audit/AuditLogger.java            # seat.assume / seat.self / seat.deny
src/main/resources/
  META-INF/services/org.keycloak.authentication.AuthenticatorFactory
  META-INF/services/org.keycloak.protocol.ProtocolMapper
  theme-resources/templates/persona-select.ftl   # the picker (username – email (label))

17. Compatibility

Built and tested against Keycloak 26.0.7 (Quarkus dist), Java 17. Uses internal authenticator SPIs — review on major Keycloak upgrades.