Local SSO Testing (Keycloak)¶
How to exercise the real OIDC and SAML login flows - discovery, RS256 id_token verification, signed SAML assertions, and group→role sync - against a spec-compliant identity provider running locally.
We use Keycloak because it is a faithful, compliant OIDC/SAML IdP (unlike lightweight mock IdPs, which cut corners like omitting jwks_uri or iss).
Testing against it proves the same code path that Okta, Google, and Microsoft Entra use in production.
What's included¶
- A
keycloakservice indocker-compose.yaml, gated behind thessocompose profile so it does not start (or slow down) a stack that isn't testing SSO. - A pre-baked realm at
infrastructure/keycloak/campus-realm.json: - realm
campus - confidential OIDC client
campuscore-oidc(secretcampuscore-secret) with agroupsclaim mapper - a SAML client (
keycloak-samlin its entity/ACS URLs) that signs assertions and mapsemail,given_name,family_name, andgroupsattributes - groups
staffandstudents - a user
tester/test123in thestaffgroup - A
seed_keycloak_ssomanagement command that wires two CampusCoreSSOProviderrows to the realm - OIDC (slugkeycloak) and SAML (slugkeycloak-saml) - and maps thestaffgroup to a role for both.
The realm file's URLs are ${CC_APP_URL} / ${CC_VITE_URL} placeholders.
Keycloak substitutes them from the container's environment at import time, and the compose file builds those variables from the same interpolation defaults as every other service - so one realm file serves the main checkout and every worktree stack.
Bring it up¶
In a worktree, one command does everything - starts the stack plus its own Keycloak and runs the seed:
The summary prints the stack's app URL and its Keycloak admin console URL (admin / admin).
Sign in at the app URL with tester / test123, via either "Sign in with Keycloak" (OIDC) or "Sign in with Keycloak (SAML)".
You land in the app provisioned as "Test User" with the keycloak-staff role assigned from the staff group.
--sso is effectively sticky: a later up without the flag leaves the existing Keycloak container running; stack.sh down removes it with the rest of the stack.
On the main checkout, the classic two-step flow is unchanged:
docker compose --profile sso up -d keycloak
docker compose exec web python manage.py seed_keycloak_sso
There Keycloak stays at http://localhost:8085 and the app at http://localhost:8000, exactly as the interpolation defaults render.
One caveat on the main checkout: SP-side URLs (the SAML entity ID, and the app URL the seed command prints) come from DomainService, and the main campuscore_app/.env usually sets CAMPUSCORE_SUBDOMAIN, which wins.
OIDC login works regardless (its redirect URI is request-derived), but SAML login and the printed URL need CAMPUSCORE_SUBDOMAIN blanked in campuscore_app/.env while testing, so the app's entity ID matches the realm's http://localhost:8000/... client.
Worktree stacks don't have this problem - their .stack.env overrides both domain variables automatically.
How the networking works¶
Keycloak must be reachable by the same issuer URL from two places that have different DNS: your browser (on the host) and the web container.
We use Keycloak's split-horizon hostname (the same pattern as MinIO's localhost:9000 vs minio:9000 here):
KC_HOSTNAME(built fromCC_HOSTNAME+CC_KEYCLOAK_PORT, defaulthttp://localhost:8085) fixes the issuer and browser-facing URLs.KC_HOSTNAME_BACKCHANNEL_DYNAMIC=truelets the backchannel endpoints (token, userinfo, JWKS, SAML descriptor) resolve tokeycloak:8080, which thewebcontainer fetches over the compose network.
So the OIDC SSOProvider.oidc_provider_url is http://keycloak:8080/realms/campus (backchannel discovery) and the SAML provider's metadata_url is that realm's /protocol/saml/descriptor, while the browser is redirected to the host-mapped port to authenticate.
Compose service DNS is project-scoped, so those keycloak:8080 values are correct in every stack without any per-stack configuration.
The SAML provider is deliberately metadata-URL-only (no pasted cert): start-dev keeps the realm in a container-local database, so the realm's signing keys change whenever the container is recreated, and the 15-minute metadata cache picks up the current cert where a pasted one would go stale.
A worktree stack's .stack.env also overrides CAMPUSCORE_SUBDOMAIN (blanked) and CUSTOM_DOMAIN_WITH_PROTOCOL (the stack's own URL), so the SP entity/ACS URLs the app advertises match the stack instead of claiming another environment's domain.
Validating claim mapping¶
The seeded providers double as a claim-mapping test bench, because the realm sends a known set of claims for tester:
- Profile claims (OIDC): set
claim_student_idtopreferred_usernameon thekeycloakprovider, log in, and check the user's profile in Django admin -student_idshould readtesterandsso_provider/external_idshould be stamped. - Profile claims (SAML): the realm's SAML client sends
email,given_name, andfamily_nameattributes; pointclaim_student_idat any of them on thekeycloak-samlprovider and verify the same way. - SAML external ID: with
claim_external_idblank, the new SocialAccount's uid is the NameID (the user's email). Set it to an attribute the realm sends (e.g.given_name), delete the user, log in again, and the uid is that attribute's value instead - the account is now linked by the mapped attribute.
Notes¶
- This is development-only. The realm ships with known dev credentials and
sslRequired: none;seed_keycloak_ssorefuses to run whenIS_CLOUD_ENVis set. - To reset, recreate the container - the realm re-imports fresh each start:
docker compose rm -sf keycloak && docker compose --profile sso up -d keycloak(works in a worktree and on the main checkout). - Logins driven from the Vite dev server work for both protocols: allauth derives the OIDC redirect URI and the SAML ACS from the live request, so the realm registers the Vite origin's variants alongside the app origin's. The SAML entity ID still comes from
DomainService(the app origin), which is why the realm client resolves either way. test_seed_keycloak_sso.pypins the seed command's constants against the realm file, so renaming a client or slug on one side fails in pytest, not in a browser session.- Keycloak's admin console (add users, groups, claim mappers) is at the URL the stack summary prints -
http://localhost:8085on the main checkout.