Enterprise Edition

Single Sign-On with Keycloak

This guide covers signing in to SWIRL with Keycloak (OpenID Connect), and obtaining each user's Microsoft 365 (Microsoft Graph) access through Keycloak so there is no separate Microsoft sign-in.

1. Assumptions and supported scope

1.1 What is supported

CapabilityDetail
Login with KeycloakOpenID Connect Authorization Code flow with PKCE. The browser completes the flow and returns to the SWIRL Galaxy UI, which forwards the access token to the SWIRL backend. SWIRL provisions each user from the verified email claim as a standard, non-administrator account.
ID token verificationRecommended for production. When a realm JWKS URI is configured, SWIRL verifies the ID token signature, expiry, and audience against the realm signing keys.
Per-user Microsoft 365 accessSWIRL obtains each user's delegated Microsoft Graph token through Keycloak, so there is no separate Microsoft sign-in. Two methods, selectable per Authenticator: token exchange and brokered retrieval.
Other brokered resourcesThe same per-user delegated pattern extends to other resources brokered through Keycloak, such as ServiceNow.
ObservabilityEvery Keycloak interaction emits one structured log line; token values are never logged.

1.2 Environment assumptions

AssumptionWhy it matters
Keycloak brokers Microsoft Entra ID as an identity provider, in the realm SWIRL signs into. This is the source of the per-user Microsoft token. Without it, Phase 2 cannot run.
Access is always delegated and per-user. SWIRL only ever sees content the signed-in user can already see; there is no application-only (client-credentials) Graph access in this design.
All Microsoft 365 users are in a single Entra tenant. Brokering is configured per identity provider; a multi-tenant setup needs additional configuration.
Production endpoints use HTTPS; the realm issuer is reachable from the SWIRL host. SWIRL calls the realm userinfo, token, and broker or exchange endpoints from the server side.
Storing or copying brokered tokens is permitted by policy. Both Phase 2 methods involve a per-user token transiting or resting in SWIRL; see Section 4.8.
No Conditional Access policy forces interactive sign-in for the brokered Entra app. SWIRL refreshes tokens to run searches outside an active session; see Section 4.7.

1.3 How Microsoft access arrives

  • SWIRL does not run its own Microsoft OAuth sign-in for these users; the Microsoft token arrives through Keycloak.
  • No application-only Graph access; everything is delegated to the signed-in user.
  • This guide covers wiring Keycloak and SWIRL together, not installing either product.

2. Architecture at a glance

Four parties: the SWIRL Galaxy front end in the browser, the SWIRL backend, your Keycloak realm, and Microsoft Entra with Microsoft Graph brokered behind Keycloak.

Browser SWIRL Galaxy UI PKCE login SWIRL backend validate token (userinfo) token brokering M365 connectors Keycloak realm OIDC provider Entra broker token exchange Microsoft Entra Graph API delegated scopes access token userinfo exchange / broker brokered IdP Graph calls with the per-user token
Figure 1. Components and the direction of trust. SWIRL does not run a separate Microsoft sign-in; the Microsoft token is obtained through Keycloak and used by SWIRL's M365 connectors.

2.1 Two configuration surfaces

SWIRL's Keycloak settings live in two places that must agree:

SurfaceDrivesSet by
Environment file (.env) The "Login with Keycloak" button in the Galaxy UI (the served oidcConfig block). python swirl.py config_default_api_settings reads the KEYCLOAK_* variables.
Authenticator row (database) Backend token validation, user provisioning, and Microsoft 365 token brokering. The Keycloak entry in DefaultAuthenticators.json, loaded by swirl.py load_data; or the admin console.

The clientId and issuer in oidcConfig, and the client_id and endpoints in the Authenticator row, must reference the same Keycloak client and realm. If the login button is missing, check the environment surface; if login starts but the backend rejects it, check the Authenticator row.

3. Phase 1: Login with Keycloak

Users log in by clicking a Login with Keycloak button on /galaxy/login. The button runs a browser-based OpenID Connect flow (Authorization Code with PKCE) against Keycloak; the browser obtains the token, hands it to the SWIRL backend for validation, and SWIRL provisions a standard, non-administrator account on first login.

There are two separate Keycloak surfaces; do not confuse them. The Login with Keycloak button on the login page (this section) is driven by the oidcConfig environment surface (Section 3.1) and runs in the browser. A second, server-side flow at /swirl/callback/keycloak-callback is driven by the Authenticator row and backs the user-menu "connect" action (Section 3.5). Setting only the Authenticator row does not put a button on the login page; that requires Section 3.1.
Browser / GalaxyKeycloakSWIRL backend 1. Click Login with Keycloak: authorize (code + PKCE S256, redirect_uri = /galaxy/oidc-callback) 2. Login and MFA; redirect to /galaxy/oidc-callback?code=... 3. Browser exchanges code for token (PKCE, public client, no secret) 4. access and ID tokens 5. POST /oidc_authenticate (token in OIDC-Token header) 6. GET userinfo (and verify ID token if JWKS set) 7. claims; provision user 8. SWIRL session issued
Figure 2. The login-page button. The browser runs the whole OIDC exchange (steps 1 to 4) and holds only the client id, never a secret, so the Keycloak client must be public. The browser then posts the token to SWIRL (step 5), which validates it via userinfo. The redirect URI is a Galaxy page, /galaxy/oidc-callback.

3.1 Make the button appear (the environment surface)

The button shows on /galaxy/login only when the served oidcConfig lists Keycloak as active. That block is generated from two environment variables; setting the Authenticator row in the admin console does not add the button. In the SWIRL app's .env:

KEYCLOAK_ISSUER=https://<keycloak-host>/realms/<realm>
#   include the legacy path if your Keycloak uses it: https://<keycloak-host>/auth/realms/<realm>
KEYCLOAK_AUTH_CLIENT_ID=<keycloak-client-id>
KEYCLOAK_SCOPE=openid email profile      # optional; this is the default

python swirl.py config_default_api_settings   # writes oidcConfig.Keycloak; then hard-refresh /galaxy/login
Docker: the variables must reach the container. A host .env is read by Docker Compose only for ${...} interpolation; it is not injected into the container. Because config_default_api_settings runs inside the container, the SWIRL service must pass the variables through in its environment: block, or the button never appears despite a correct .env:
services:
  swirl:
    environment:
      KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
      KEYCLOAK_AUTH_CLIENT_ID: ${KEYCLOAK_AUTH_CLIENT_ID}
      KEYCLOAK_SCOPE: ${KEYCLOAK_SCOPE}

A container restart then runs config_default_api_settings on startup. For a deployment that serves Django collected static, also run python manage.py collectstatic --noinput. Confirm the served default config now has oidcConfig.Keycloak.active = true.

3.2 Keycloak client

The button runs the flow in the browser and holds only the client id, never a secret, so:

  1. Client authentication = Off (a public client). A confidential client cannot complete this browser flow.
  2. PKCE Method: S256.
  3. Valid redirect URIs include https://<swirl-fqdn>/galaxy/oidc-callback; Web origins https://<swirl-fqdn>.
  4. Default scopes openid email profile; email claim present and verified.

3.3 SWIRL Authenticator (token validation)

After the browser posts the token, the SWIRL backend validates it by calling userinfo, so a Keycloak Authenticator row must exist. It ships in swirl/fixtures/DefaultAuthenticators.json (loaded by python swirl.py load_data); substitute the placeholders, or set the same values in the admin console:

user_data_url  https://<keycloak-host>/realms/<realm>/protocol/openid-connect/userinfo
jwks_uri       https://<keycloak-host>/realms/<realm>/protocol/openid-connect/certs
client_id      <keycloak-client-id>          # same as KEYCLOAK_AUTH_CLIENT_ID
app_uri        https://<swirl-fqdn>

For the login-page button the fields that matter are user_data_url and jwks_uri. The code-exchange fields (use_basic_auth, exchange_code_params, token_uri) are not used by this flow; they belong to the server-side flow in Section 3.5.

Keep jwks_uri set in production so SWIRL verifies the ID token signature, expiry, and audience. Clearing it falls back to an unverified userinfo probe, suitable for development only.

3.4 What the user sees

Keycloak users click Login with Keycloak, authenticate at Keycloak (password, MFA, whatever the realm requires), and are returned and logged in, with their SWIRL account created on first login. The username and password form on the same page is for local SWIRL accounts only (for example the admin); externally authenticated users have no local password and do not use it.

3.5 Alternate: the server-side flow

SWIRL also exposes a server-side redirect flow at /swirl/callback/keycloak-callback, used by the user-menu "connect" action and any code-exchange login. It uses the same Authenticator row but exchanges the code on the server. If you use it, register /swirl/callback/keycloak-callback as a redirect URI and match the client type:

Keycloak clientAuthenticator fields
Confidential (Client authentication On) use_basic_auth: true, client_secret = the Keycloak client secret
Public (Client authentication Off) use_basic_auth: false; the shipped exchange_code_params: {"client_id": "{client_id}"} supplies the id
If you use both the login-page button (3.1 to 3.2) and this server-side flow, the client must be public (the button requires it), so use the public row and register both /galaxy/oidc-callback and /swirl/callback/keycloak-callback. A confidential client with use_basic_auth: false, or a public client without the client_id, fails the server-side token request with invalid_client.
If a build adds Authenticator columns and the database is not migrated, the dispatch cache fails to load and all authenticators disappear from the UI. Run python manage.py migrate after deploying such a build.

4. Phase 2: Microsoft 365 token delegation

After a Keycloak login, SWIRL obtains the user's Microsoft Graph token through Keycloak and stores it under the Microsoft identity for the M365 connectors. Choose one method per Authenticator with broker_mode.

Use the exchange method unless token exchange is unavailable, or the Microsoft refresh issue in Section 4.6 affects your Keycloak version. The harvest method requires SWIRL to hold a copy of the broker's Azure application credentials, which exchange avoids.

4.1 The two methods

MethodHow it worksSWIRL holds
exchange
recommended
SWIRL exchanges the user's Keycloak token for the Microsoft Graph token (RFC 8693, targeting the Entra identity provider). Keycloak refreshes the upstream token and remains the single authority. Only short-lived Graph tokens; no Azure client secret.
harvest
fallback
SWIRL reads the user's stored Microsoft token from the Keycloak broker endpoint, then refreshes it directly against Microsoft. The user's Microsoft refresh token, and a copy of the broker's Azure app credentials.
Method: exchange SWIRLKeycloakEntra exchange (subject token) refresh upstream Graph token short-lived Graph token later: Graph call (per-user)
Figure 3a. Keycloak stays the chokepoint; SWIRL holds no Azure secret.
Method: harvest SWIRLKeycloakMicrosoft GET broker/<alias>/token access + refresh token refresh DIRECT (broker's Azure creds) fresh Graph token later: Graph call (per-user)
Figure 3b. SWIRL refreshes against Microsoft directly; it must hold the broker's Azure credentials.

4.2 Keycloak shared prerequisites

  1. Configure Microsoft Entra ID as a brokered identity provider in the realm SWIRL logs into. Note its alias, for example microsoft; it is reused exactly in Section 4.8.
  2. Set the identity provider's default scopes to the Microsoft Graph delegated scopes SWIRL needs, plus offline_access. These broker scopes are distinct from the login scopes in Section 3; see Appendix A.

4.3 Keycloak exchange method

  1. Enable token exchange on Keycloak. The exact feature flags depend on the major version: older versions use the preview features token-exchange and admin-fine-grained-authz; newer versions ship a generally-available standard token exchange with a different setup. This is the most version-sensitive step in the integration; confirm the procedure for the deployed version first.
  2. Create a confidential client for SWIRL to perform the exchange, separate from the public login client. Record its client id and secret for Section 4.8.
  3. Grant that confidential client the token-exchange permission targeting the Entra identity provider. Without this grant the exchange returns an authorization error.

4.4 Keycloak harvest method

  1. On the Entra brokered identity provider, set Store Tokens and Stored Tokens Readable to on.
  2. Grant the SWIRL login client the broker read-token role, for example by adding broker:read-token to the realm default roles, so its access token can read the stored broker token.

4.5 Microsoft Entra configuration

  1. The Entra application Keycloak brokers must hold the delegated Microsoft Graph permissions SWIRL needs, with administrator consent. Typical sets:
    • OneDrive and SharePoint: Files.Read.All, Sites.Read.All
    • Outlook mail: Mail.Read
    • Teams, Calendar, and others: as required
  2. Include offline_access so a refresh token can be issued.
  3. Set the Entra application redirect URI to the Keycloak broker endpoint: https://<keycloak-host>/realms/<realm>/broker/<alias>/endpoint.

4.6 Microsoft token refresh and Keycloak version

Keycloak issue 32361 (open) describes Keycloak omitting the scope parameter when refreshing a Microsoft token, which breaks the token-exchange refresh for Microsoft on affected versions. Confirm the deployed Keycloak version; if affected, either apply the available workaround or set broker_mode = harvest, which refreshes against Microsoft directly and is not affected.

4.7 Conditional Access, MFA, and token lifetimes

SWIRL refreshes tokens to run searches outside an active sign-in session, including scheduled and background searches. Confirm that no Conditional Access or MFA policy on the brokered Entra app forces interactive sign-in on token refresh, and that the access-token and refresh-token lifetimes are long enough for background use. A policy that requires a fresh interactive sign-in will break unattended Graph calls regardless of method.

4.8 SWIRL Authenticator brokering fields

Set these on the same Keycloak Authenticator from Section 3.3:

Fieldexchangeharvest
broker_modeexchangeharvest
broker_token_mapMaps the brokered identity-provider alias to the SWIRL identity to store the token under, for example {"microsoft": "Microsoft"}.
exchange_client_id / exchange_client_secret The confidential exchange client from Section 4.3.Not used.
The Microsoft Authenticator's client_id / client_secret Not required. Must be the same Azure app credentials Keycloak's broker uses, because SWIRL refreshes the harvested token directly against Microsoft.
The key in broker_token_map (the "microsoft" above) must be character-for-character identical to the Keycloak Entra identity-provider alias from Section 4.2, and it is case-sensitive. A mismatch produces no token and no error; the M365 search returns nothing.
Bootstrap exchange at login works. Re-exchanging a token outside an active session needs a durable Keycloak credential for the user; the browser PKCE login currently forwards only the access token, so scheduled and background M365 searches in exchange mode are limited until an offline Keycloak token is captured. The harvest method does not have this constraint, because it holds the user's Microsoft refresh token directly.

5. Validation and observability

5.1 What to verify

  1. The Galaxy login page shows a Login with Keycloak button.
  2. A Keycloak login creates a SWIRL user mapped from the email claim. Confirm it is not a superuser and lands in the standard auto-provisioned group (SWIRL admin, Users section).
  3. After login, a per-user Microsoft token is stored under the Microsoft identity.
  4. An M365 search returns content only if an M365 SearchProvider is active and configured to use the Microsoft identity. The token alone is not enough; confirm at least one M365 provider, such as OneDrive or SharePoint, is active before testing search.

5.2 Reading the logs

Every Keycloak interaction emits one structured line through a dedicated logger; token values are never logged, only lengths, booleans, and expiries:

grep 'KC ' logs/django.log logs/celery-*-worker.log

# example lines
KC login    status=ok  idp=Keycloak
KC exchange status=ok  idp=Keycloak alias=microsoft http=200 has_refresh=false expires_in=34
KC harvest  status=ok  idp=Keycloak alias=microsoft http=200

6. Troubleshooting

SymptomLikely cause and fix
All authenticators vanish from the UI after a deploy. New Authenticator columns without a migration; the dispatch cache fails to load. Run python manage.py migrate.
No "Login with Keycloak" button on /galaxy/login. The oidcConfig environment surface is not set. Configuring the Authenticator row alone does not add the button. Set KEYCLOAK_ISSUER and KEYCLOAK_AUTH_CLIENT_ID, run swirl.py config_default_api_settings, redeploy static, hard refresh (Section 3.1). On Docker, a correct host .env is not enough: the variables must also be passed through the SWIRL service's environment: block, or the container never sees them.
Login-page button redirects to Keycloak but never logs in. The client is confidential; the browser flow needs a public client and /galaxy/oidc-callback in the redirect URIs (Section 3.2).
Keycloak shows "Invalid redirect URI". Register the URI for the flow you use: /galaxy/oidc-callback (login-page button) or /swirl/callback/keycloak-callback (server-side flow). Add it exactly.
Server-side flow: login succeeds at Keycloak, then the callback logs exchange_code_for_token() ... 401 invalid_client and the browser gets a 403. The Keycloak client type and the authenticator's use_basic_auth do not match (Section 3.5). Confidential: use_basic_auth: true plus the secret. Public: use_basic_auth: false with exchange_code_params: {"client_id": "{client_id}"}. Restart SWIRL after the change.
Login fails with a PKCE error after the client-auth fix. The Keycloak client PKCE Method is plain; SWIRL sends S256. Set it to S256.
Login works but no Microsoft token is stored; M365 search is empty. The broker_token_map key does not match the Keycloak Entra IdP alias exactly (case-sensitive). Align them.
Exchange works but the Microsoft token does not refresh. Keycloak issue 32361 for the deployed version. Apply the workaround or set broker_mode = harvest.
Harvest works but the direct refresh fails. The Microsoft Authenticator is not carrying the broker's Azure app credentials. Set the same client_id and client_secret as Keycloak's broker app.
M365 search returns nothing for a valid user. Missing delegated Graph scopes or admin consent, offline_access not requested, or no active M365 SearchProvider. Confirm Sections 4.5 and 5.1.
Background or scheduled M365 searches fail while interactive search works. Exchange mode without an offline Keycloak token (Section 4.8), or a Conditional Access policy forcing interactive sign-in (Section 4.7).

Appendix A: Environment variables and scopes

VariablePurpose
KEYCLOAK_ISSUERRealm issuer URL, https://<host>/realms/<realm>. Realm endpoints derive from it.
KEYCLOAK_AUTH_CLIENT_IDPublic PKCE login client id; rendered into the Galaxy login button.
KEYCLOAK_SCOPELogin scopes; default openid email profile.

Applied with python swirl.py config_default_api_settings.

The three scopes

Scope setWhereContains
Login scopeKEYCLOAK_SCOPE and the Authenticatoropenid email profile
Broker scope, Keycloak to EntraThe Entra identity provider in KeycloakMicrosoft Graph delegated scopes plus offline_access
Entra app permissionsThe Azure app registrationThe same delegated Graph permissions, admin-consented

Appendix B: Authenticator fields reference

The Keycloak entry in DefaultAuthenticators.json ships these pre-set; in normal use an admin edits only the four placeholders in Section 3.3 and the brokering fields in Section 4.8.

FieldRole
idpIdentity provider key; must match the oidcConfig key (Keycloak).
client_id / client_secretLogin client; secret empty for public PKCE.
user_data_url + user_data_headersUserinfo endpoint and bearer header; SWIRL reads the user claims here after the token exchange.
jwks_uriRealm JWKS endpoint; recommended in production for ID token verification.
auth_uri / token_uriRealm authorize and token endpoints; SWIRL exchanges the code at the token endpoint server-side.
is_code_challengePKCE; true. The Keycloak client PKCE Method must be S256.
use_basic_auth / exchange_code_paramsToken-endpoint client identification; must match the Keycloak client type (Section 3.3).
app_uriThe SWIRL application base URL.
broker_token_mapBrokered alias to SWIRL identity map, for example {"microsoft": "Microsoft"}; key must match the IdP alias exactly.
broker_modeharvest or exchange.
exchange_client_id / exchange_client_secretConfidential client for token exchange (exchange method only).

Appendix C: References

  • RFC 8693, OAuth 2.0 Token Exchange, the mechanism behind the exchange method.
  • Keycloak issue 32361 (open), Microsoft token refresh fails due to a missing scope parameter; gates exchange-mode refresh for Microsoft. See Section 4.6.
  • Keycloak issues 14644 and 46373, brokered external IdP tokens are not refreshed on retrieve; context for why harvest refreshes against Microsoft directly.