A minimal CAS server that authenticates users against
Keycloak via OIDC and speaks CAS 1.0 (/cas) CAS 3.0 (/cas/p3) protocol back to legacy
services that only understand CAS. Use this to put CAS-only apps behind a modern
Keycloak identity provider without touching either side.
Legacy App <--CAS--> This bridge <--OIDC--> Keycloak
- A CAS client redirects the user to
/login?service=<callback-url>. - If there's no existing bridge session, the user is bounced to Keycloak to authenticate (standard OIDC authorization code flow).
- On successful login, Keycloak redirects back to
/oidc/callback. The bridge starts a session, mints a one-time service ticket, and redirects the user back to the original service with?ticket=ST-...appended. - The service calls
/p3/serviceValidate(CAS 3.0, with attributes) or/validate(CAS 1.0, username only) to exchange the ticket for the authenticated identity. Tickets are single-use and expire afterSERVICE_TICKET_TTLseconds. - If the user already has a bridge session (e.g. hitting
/loginfor a second CAS app), step 2 is skipped and a new ticket is issued immediately — giving CAS-style single sign-on across every service behind the bridge.
- Python 3.9+
- A Keycloak realm with a confidential OIDC client for this app
pip install -r requirements.txt(Flask, Authlib, urllib3)
All configuration is via environment variables.
| Variable | Required | Description |
|---|---|---|
CAS_SECRET_KEY |
yes | Flask session signing key. Generate with python -c "import secrets; print(secrets.token_hex(32))". |
KEYCLOAK_BASE_URL |
yes | Base URL of your Keycloak server, e.g. https://auth.example.com. |
KEYCLOAK_REALM |
yes | Realm name. |
OIDC_CLIENT_ID |
yes | Client ID registered in Keycloak for this bridge. |
OIDC_CLIENT_SECRET |
yes | Client secret for the same client. |
CAS_ALLOWED_SERVICES |
yes | Comma-separated exact-match allowlist of service callback URLs. Empty = nothing validates (fail closed). |
CAS_BASE_URL |
yes | Public base URL of this app. Default http://localhost:5000. |
CAS_DATABASE |
no | Path to the SQLite ticket store. Default cas.db. |
SERVICE_TICKET_TTL |
no | Ticket lifetime in seconds. Default 60. |
CAS_TICKET_CLEANUP_INTERVAL |
no | How often (seconds) a background thread purges expired, unconsumed tickets. Default 300. Set 0 or negative to disable. |
CAS_SESSION_LIFETIME |
no | Bridge session lifetime in seconds. Default 28800 (8h). |
KEYCLOAK_CA_BUNDLE |
no | Path to a PEM file if Keycloak's TLS cert is issued by an internal/private CA. |
CAS_DEV_INSECURE_COOKIES |
no | true disables the Secure cookie flag. Local dev only. |
CAS_DEV_INSECURE_SKIP_KEYCLOAK_TLS_VERIFY |
no | true disables TLS verification against Keycloak. Local dev only — never in staging/production. |
- Client authentication: On (confidential client)
- Valid redirect URI:
<CAS_BASE_URL>/oidc/callback - Scopes:
openid profile email(already requested by the app)
To populate roles and groups in the CAS response, add these as ID token mappers on the client (not just access token or userinfo):
- Client roles mapper → emits
resource_access.<client>.roles - Group Membership mapper → emits
groups
If these mappers only target the access token, the bridge won't see them —
resource_access and groups are read from the parsed ID token / userinfo
claims, not decoded from the access token.
| Endpoint | Purpose |
|---|---|
GET /login?service=<url> |
Entry point for CAS clients. Starts or reuses a session, redirects with a service ticket. |
GET /oidc/callback |
Keycloak redirects here after login. Not called directly by clients. |
GET /p3/serviceValidate?service=<url>&ticket=<st> |
CAS 3.0 ticket validation. Returns XML with user + attributes. |
GET /validate?service=<url>&ticket=<st> |
CAS 1.0 ticket validation. Returns yes\n<username>\n or no\n. |
GET /logout?service=<url> |
Clears the bridge session, then redirects to Keycloak's end-session endpoint (ending the Keycloak SSO session too). If service is given and allowlisted, Keycloak is asked to send the user back there afterward via post_logout_redirect_uri. |
GET /health |
Liveness check, returns {"status": "ok"}. |
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationSuccess>
<cas:user>jdoe</cas:user>
<cas:attributes>
<cas:email>jdoe@example.com</cas:email>
<cas:firstName>Jane</cas:firstName>
<cas:lastName>Doe</cas:lastName>
<cas:displayName>Jane Doe</cas:displayName>
<cas:roles>USER</cas:roles>
<cas:roles>ENGINEER</cas:roles>
<cas:groups>engineering</cas:groups>
<cas:groups>developers</cas:groups>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>| CAS attribute | Source (OIDC claim) |
|---|---|
cas:user |
preferred_username (falls back to email, then sub) |
cas:email |
email |
cas:firstName |
given_name |
cas:lastName |
family_name |
cas:displayName |
name |
cas:roles (0+) |
resource_access.*.roles, flattened and deduplicated across all clients |
cas:groups (0+) |
groups, leading / stripped, hierarchy otherwise preserved |
On failure, both endpoints return <cas:authenticationFailure code="..."> with
INVALID_REQUEST (missing params) or INVALID_TICKET (unknown, reused,
expired, or service-mismatched ticket).
Logging out clears the bridge's own session and then redirects to Keycloak's RP-initiated logout endpoint:
<KEYCLOAK_BASE_URL>/realms/<KEYCLOAK_REALM>/protocol/openid-connect/logout
This ends the user's Keycloak SSO session too, not just their session with
this bridge — so they're logged out of every other app sharing that
Keycloak session. The redirect includes id_token_hint (captured at login)
so Keycloak knows which session to end; if ?service= is passed and passes
the allowlist, it's forwarded as post_logout_redirect_uri.
For post_logout_redirect_uri to actually be honored, add the relevant
callback URLs to Valid post logout redirect URIs on the Keycloak client
— by default Keycloak ignores this parameter unless the URI is registered.
export CAS_SECRET_KEY=... KEYCLOAK_BASE_URL=... KEYCLOAK_REALM=... \
OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... \
CAS_ALLOWED_SERVICES="https://app1.example.com/cas/callback,https://app2.example.com/cas/callback"
python app.pyFor production, run behind a real WSGI server (gunicorn, uwsgi) with TLS
terminated at the app or a reverse proxy in front of it — SESSION_COOKIE_SECURE
depends on the connection actually being HTTPS.
- Service allowlist is exact-match, fail-closed. An empty
CAS_ALLOWED_SERVICESmeans every service validation fails; there is no wildcard or prefix matching, to avoid open-redirect risk. - The
serviceused after Keycloak login always comes from the bridge's own session, set in/login, never from the/oidc/callbackquery string — this prevents redirect tampering / login mix-up attacks. - Tickets are one-time-use, enforced by an atomic
DELETE ... RETURNINGso two concurrent validation calls for the same ticket can't both succeed. - TLS verification against Keycloak is on by default and can only be disabled via an explicit dev-only environment variable that logs a warning on startup — don't set it outside local development.
Tickets are kept in a SQLite database (CAS_DATABASE, default cas.db) with
WAL mode enabled. Expired, unconsumed tickets are purged once on startup, and
then again every CAS_TICKET_CLEANUP_INTERVAL seconds (default 300) by a
background daemon thread — so long-running processes don't accumulate stale
rows between restarts. Set CAS_TICKET_CLEANUP_INTERVAL to 0 to disable
the periodic sweep and rely on the startup purge only. Under a multi-worker
server (e.g. several gunicorn workers), each worker runs its own cleanup
thread; that's harmless since the purge is an idempotent DELETE.
