Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CAS-over-OIDC Bridge

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

How it works

  1. A CAS client redirects the user to /login?service=<callback-url>.
  2. If there's no existing bridge session, the user is bounced to Keycloak to authenticate (standard OIDC authorization code flow).
  3. 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.
  4. 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 after SERVICE_TICKET_TTL seconds.
  5. If the user already has a bridge session (e.g. hitting /login for 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.

Requirements

  • Python 3.9+
  • A Keycloak realm with a confidential OIDC client for this app
  • pip install -r requirements.txt (Flask, Authlib, urllib3)

Configuration

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.

Keycloak client setup

  • 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.

Endpoints

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"}.

/p3/serviceValidate response

<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).

/logout

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.

Running

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.py

For 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.

Security notes

  • Service allowlist is exact-match, fail-closed. An empty CAS_ALLOWED_SERVICES means every service validation fails; there is no wildcard or prefix matching, to avoid open-redirect risk.
  • The service used after Keycloak login always comes from the bridge's own session, set in /login, never from the /oidc/callback query string — this prevents redirect tampering / login mix-up attacks.
  • Tickets are one-time-use, enforced by an atomic DELETE ... RETURNING so 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.

Data store

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.

Flow

flow

About

CAS-over-OIDC Bridge

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages