- Java 97.4%
- FreeMarker 2.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| src/main | ||
| .gitignore | ||
| docker-compose.yml | ||
| pom.xml | ||
| README.md | ||
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
Ciff 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(markseat.groupMultivalued).
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.
- Authentication → Flows → Create flow
post-broker-persona(Basic flow). - Add step → Persona (Seat) Selection → Required.
- 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(orusername) — 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
OIDC seat clients: add "Persona Seat (identity override)" on the OIDC client the same way. It always overrides the standard claims (
sub,email_verified,preferred_username,given_name,family_name) from the note. The Persona attribute → claim name list adds custom-named claims (e.g.firstName/lastNamefor the SP) — same left/right semantics as the SAML mapper; rows targeting a standard claim are skipped (already set). Thesuboverride 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-SERVICES0047warning); 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.