Skip to content

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 keycloak service in docker-compose.yaml, gated behind the sso compose 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 (secret campuscore-secret) with a groups claim mapper
  • a SAML client (keycloak-saml in its entity/ACS URLs) that signs assertions and maps email, given_name, family_name, and groups attributes
  • groups staff and students
  • a user tester / test123 in the staff group
  • A seed_keycloak_sso management command that wires two CampusCore SSOProvider rows to the realm - OIDC (slug keycloak) and SAML (slug keycloak-saml) - and maps the staff group 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:

bash scripts/stack.sh up --sso

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 from CC_HOSTNAME + CC_KEYCLOAK_PORT, default http://localhost:8085) fixes the issuer and browser-facing URLs.
  • KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true lets the backchannel endpoints (token, userinfo, JWKS, SAML descriptor) resolve to keycloak:8080, which the web container 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_id to preferred_username on the keycloak provider, log in, and check the user's profile in Django admin - student_id should read tester and sso_provider/external_id should be stamped.
  • Profile claims (SAML): the realm's SAML client sends email, given_name, and family_name attributes; point claim_student_id at any of them on the keycloak-saml provider and verify the same way.
  • SAML external ID: with claim_external_id blank, 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_sso refuses to run when IS_CLOUD_ENV is 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.py pins 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:8085 on the main checkout.