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
| Capability | Detail |
|---|---|
| Login with Keycloak | OpenID 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 verification | Recommended 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 access | SWIRL 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 resources | The same per-user delegated pattern extends to other resources brokered through Keycloak, such as ServiceNow. |
| Observability | Every Keycloak interaction emits one structured log line; token values are never logged. |
1.2 Environment assumptions
| Assumption | Why 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.
2.1 Two configuration surfaces
SWIRL's Keycloak settings live in two places that must agree:
| Surface | Drives | Set 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.
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./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.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:
- Client authentication = Off (a public client). A confidential client cannot complete this browser flow.
- PKCE Method:
S256. - Valid redirect URIs include
https://<swirl-fqdn>/galaxy/oidc-callback; Web originshttps://<swirl-fqdn>. - 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.
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 client | Authenticator 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 |
/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.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
| Method | How it works | SWIRL 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. |
4.2 Keycloak shared prerequisites
- 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. - 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
- Enable token exchange on Keycloak. The exact feature flags depend on the major version: older versions
use the preview features
token-exchangeandadmin-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. - 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.
- 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
- On the Entra brokered identity provider, set Store Tokens and Stored Tokens Readable to on.
- Grant the SWIRL login client the
brokerread-tokenrole, for example by addingbroker:read-tokento the realm default roles, so its access token can read the stored broker token.
4.5 Microsoft Entra configuration
- 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
- OneDrive and SharePoint:
- Include
offline_accessso a refresh token can be issued. - 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
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:
| Field | exchange | harvest |
|---|---|---|
broker_mode | exchange | harvest |
broker_token_map | Maps 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. |
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.5. Validation and observability
5.1 What to verify
- The Galaxy login page shows a Login with Keycloak button.
- 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).
- After login, a per-user Microsoft token is stored under the
Microsoftidentity. - An M365 search returns content only if an M365 SearchProvider is active and configured to use the
Microsoftidentity. 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=2006. Troubleshooting
| Symptom | Likely 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
| Variable | Purpose |
|---|---|
KEYCLOAK_ISSUER | Realm issuer URL, https://<host>/realms/<realm>. Realm endpoints derive from it. |
KEYCLOAK_AUTH_CLIENT_ID | Public PKCE login client id; rendered into the Galaxy login button. |
KEYCLOAK_SCOPE | Login scopes; default openid email profile. |
Applied with python swirl.py config_default_api_settings.
The three scopes
| Scope set | Where | Contains |
|---|---|---|
| Login scope | KEYCLOAK_SCOPE and the Authenticator | openid email profile |
| Broker scope, Keycloak to Entra | The Entra identity provider in Keycloak | Microsoft Graph delegated scopes plus offline_access |
| Entra app permissions | The Azure app registration | The 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.
| Field | Role |
|---|---|
idp | Identity provider key; must match the oidcConfig key (Keycloak). |
client_id / client_secret | Login client; secret empty for public PKCE. |
user_data_url + user_data_headers | Userinfo endpoint and bearer header; SWIRL reads the user claims here after the token exchange. |
jwks_uri | Realm JWKS endpoint; recommended in production for ID token verification. |
auth_uri / token_uri | Realm authorize and token endpoints; SWIRL exchanges the code at the token endpoint server-side. |
is_code_challenge | PKCE; true. The Keycloak client PKCE Method must be S256. |
use_basic_auth / exchange_code_params | Token-endpoint client identification; must match the Keycloak client type (Section 3.3). |
app_uri | The SWIRL application base URL. |
broker_token_map | Brokered alias to SWIRL identity map, for example {"microsoft": "Microsoft"}; key must match the IdP alias exactly. |
broker_mode | harvest or exchange. |
exchange_client_id / exchange_client_secret | Confidential 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
scopeparameter; 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.