This repository contains a Keycloak build with:
- a custom user federation provider (
Dienst2) - a custom login theme (
chtheme)
- A user logs in with Google or SURFconext.
- An IdP mapper turns external claims into a Keycloak username used for lookup.
- Keycloak calls the
Dienst2federation provider with that username. - The provider resolves the person in Dienst2 and returns a federated Keycloak user (
WISVCH.<id>), including membership-related attributes. - Google groups are fetched by Dienst2 from Google Workspace and returned to Keycloak as groups.
- On first broker login, Keycloak auto-links the IdP account to the existing federated user (without confirmation).
- Keycloak issues OIDC tokens to your applications.
This means users are not managed as local Keycloak users. Dienst2 is the source of truth.
Run from the keycloak-build repository root:
mvn clean package
KEYCLOAK_VERSION="$(mvn -q -DforceStdout help:evaluate -Dexpression=keycloak.version)"
docker build --build-arg KEYCLOAK_VERSION=${KEYCLOAK_VERSION} -t keycloak-wisvch .pom.xml (<keycloak.version>) is the source of truth for the Keycloak runtime version.
CI reads that value and passes it to Docker as --build-arg KEYCLOAK_VERSION=..., so provider dependencies and image base version stay in sync.
The image copies:
target/keycloak-wisvch-custom-providers.jarto/opt/keycloak/providers/themes/chthemeto/opt/keycloak/themes
The repository includes a Docker Compose development stack with PostgreSQL. It mounts the theme and the locally built provider JAR, so it is separate from the production image build above.
cp .env.example .env
# Set the local/test credentials in .env.
./scripts/dev-up.sh.env is the single runtime configuration source; The values are used as follows:
KC_BOOTSTRAP_ADMIN_USERNAMEandKC_BOOTSTRAP_ADMIN_PASSWORDcreate the local admin-console account. Choose these yourself.KC_DB_PASSWORDprotects the local PostgreSQL database. Choose this yourself.DIENST2_BASE_URLandDIENST2_API_KEYconfigure the Dienst2 federation provider. It is best to set up a local Dienst2 instance, following the instructions in the Dienst2 repository. Use that instance's URL and API key here. If a local instance is not possible, ask in the Beheer chat for a development endpoint and API key that you may use; do not reuse a production key.SURFCONEXT_CLIENT_IDandSURFCONEXT_CLIENT_SECRETenableNetID test. Ask in the Beheer chat for an existing development credential first. If you need to create one, sign in to SURFconext SP Dashboard with eduID and the Beheer email account, select the test environment, and create a client secret there. Put its client ID and secret in.env.NetID testis not the real NetID login: it uses fake NetIDs. Ask in the Beheer chat for the test usernames and passwords.GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETenable Google login. Ask in the Beheer chat for an existing development credential first. Otherwise, in Google Cloud Console, open APIs & Services → Credentials, and create an OAuth client ID for a Web application. Complete the required consent-screen details, then copy the generated client ID and client secret into.env.
Keycloak is available at http://localhost:8181. The
admin credentials are KC_BOOTSTRAP_ADMIN_USERNAME and
KC_BOOTSTRAP_ADMIN_PASSWORD from .env.
After signing in, use the realm selector in the upper-left corner to switch from
the master realm to ch-dev. Configure or inspect the development identity
providers, federation provider, theme, and login flow in ch-dev, not master.
The initial realm is ch-dev, defined in realm/ch-dev-realm.json. It includes
the CH login theme, production-compatible Google and SURFconext mapping/login
behaviour, and the SURFconext test endpoints (shown as NetID test). It also
includes the automatic broker-linking flow, the CH user-profile attributes, and
the Dienst2 user-federation component. Its client secrets and Dienst2 API key
are placeholders resolved from .env at import time.
Test the login flow at http://localhost:8181/realms/ch-dev/account/.
- Theme changes are mounted directly; refresh the browser to see them. Theme and template caching is disabled for the local container.
- After changing Java/provider code, run
./scripts/dev-rebuild.sh. - Stop the stack with
./scripts/dev-down.sh. - After changing the realm JSON, run
./scripts/dev-reset.sh. It explicitly deletes the local database volume and re-imports the realm; startup imports never overwrite an existing realm.
Use your own URLs, client IDs, secrets, and API tokens.
Only settings that are required for this architecture are listed below.
Set these realm settings:
frontendUrl=https://<your-login-host>loginTheme=chtheme(if you want to use the bundled login theme from this repo)registrationAllowed=falserememberMe=falseresetPasswordAllowed=falseverifyEmail=falseloginWithEmailAllowed=falseduplicateEmailsAllowed=true
Disable all Required Actions.
Account linking in this setup is username-driven and automatic, so self-service and required-action interruptions should be off.
Add these custom user profile attributes:
google_usernamenetidmembership_statusformatted_name
Set them to non-editable.
Set visibility to match the current model:
google_username,netid,formatted_name: view byadminandusermembership_status: view byadminonly
Set membership_status as required for role user.
These attributes are populated from Dienst2 and used for authorization/state checks; users should not be able to edit them in Keycloak.
Create a top-level flow with alias link exisiting without confimation and add:
idp-detect-existing-broker-userasREQUIREDidp-auto-linkasREQUIRED
This makes first IdP login link directly to an existing user without a confirmation screen.
This flow must be top-level because IdPs reference it via firstBrokerLoginFlowAlias.
Add a User Federation provider:
- Provider ID:
Dienst2 baseUrl=https://<dienst2-host>apiEndpoint=/<dienst2-api-path>apiKey=<dienst2-api-token>enabled=true
Recommended cache policy:
MAX_LIFESPANwith a lifespan of60000milliseconds (60 seconds).
The provider additionally keeps successful Dienst2 person and Google-group lookups in a process-local cache for 60 seconds. This absorbs repeated broker lookups that intentionally bypass Keycloak's user cache. Failed and not-found lookups are not cached.
Behavior that drives the rest of the setup:
surfconext.<netid>is resolved via Dienst2netidgoogle.<localpart>is resolved via Dienst2google_username- resolved users are exposed as
WISVCH.<id> - Google groups are fetched through Dienst2 (which syncs from Google Workspace) and mapped to Keycloak groups
For each IdP, set:
syncMode=FORCEfirstBrokerLoginFlowAlias=link exisiting without confimationtrustEmail=false
Google IdP:
- Use Keycloak
googleprovider. - Optional restriction:
hostedDomain=<your-domain> - Keep
disableUserInfo=false. - Add mapper
oidc-username-idp-mapper:- template
${ALIAS}.${CLAIM.email | localpart} - target
LOCAL - sync mode
IMPORT
- template
SURFconext (or another OIDC IdP):
- Use
oidcprovider with your own OIDC endpoints and issuer. - Keep signature validation enabled (
validateSignature=true,useJwksUrl=true). - Use
clientAuthMethod=client_secret_postunless your provider requires a different method. - In the SURF SP Dashboard, set Subject type to Persistent (not Transient). Returning-user linking depends on stable user identifiers; with transient IDs, returning users cannot be linked reliably.
- Add mapper
oidc-username-idp-mapper:- template
${ALIAS}.${CLAIM.uids} - target
LOCAL - sync mode
FORCE
- template
Important coupling with federation code:
- aliases and username templates must produce usernames that match the federation lookup patterns (
google.*,surfconext.*), unless you also change the provider code.
