Skip to content

Keycloak OAuth

Updated 4 min read

Superset runs the OAuth flow itself, through Flask-AppBuilder. The gateway routes; it does not authenticate.

nebariapp:
auth:
enabled: true
provider: keycloak
provisionClient: true
enforceAtGateway: false
redirectURI: "/oauth-authorized/keycloak"
scopes: [openid, profile, email]

An Envoy SecurityPolicy in front of Superset would authenticate the user and forward an already-authorized request — and Superset would have no idea who they were, because Flask-AppBuilder’s user model is populated by its own OAuth callback. You would get a double login, or a logged-in gateway session in front of a Superset that still shows a login page.

Letting Superset own the flow is what makes AUTH_ROLES_MAPPING work at all.

redirectURI: /oauth-authorized/keycloak is Flask-AppBuilder’s callback path for a provider named keycloak. Rename the provider in OAUTH_PROVIDERS and this must change to match.

With provisionClient: true the operator creates a confidential client in the realm and writes a Secret named <release>-nebari-superset-oidc-client:

KeyContents
client-idOIDC client ID
client-secretclient secret
issuer-urlKeycloak issuer URL

Inject it into Superset with extraEnvRaw:

superset:
extraEnvRaw:
- name: OAUTH_CLIENT_ID
valueFrom:
secretKeyRef:
name: superset-nebari-superset-oidc-client
key: client-id
- name: OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: superset-nebari-superset-oidc-client
key: client-secret
- name: OAUTH_SERVER_METADATA_URL
valueFrom:
secretKeyRef:
name: superset-nebari-superset-oidc-client
key: issuer-url
optional: true

Confirm it exists before debugging anything else:

Terminal window
kubectl -n superset get secret superset-nebari-superset-oidc-client -o jsonpath='{.data}' | jq 'keys'

OAuth is configured through superset.configOverrides.oauth_config, a Python snippet the upstream chart appends to superset_config.py. The complete block is in examples/nebari-values.yaml; the parts that matter:

class CustomSecurityManager(SupersetSecurityManager):
def oauth_user_info(self, provider, response=None):
if provider == 'keycloak':
me = self.appbuilder.sm.oauth_remotes[provider].userinfo()
roles = me.get('roles', me.get('groups', []))
return {
'username': me.get('preferred_username', ''),
'first_name': me.get('given_name', ''),
'last_name': me.get('family_name', ''),
'email': me.get('email', ''),
'role_keys': roles,
}
return super().oauth_user_info(provider, response)
CUSTOM_SECURITY_MANAGER = CustomSecurityManager
AUTH_TYPE = AUTH_OAUTH

The security manager exists to produce role_keys. Flask-AppBuilder looks those up in AUTH_ROLES_MAPPING; without the override there are no role keys and every user falls through to the registration role.

Note the fallback: me.get('roles', me.get('groups', [])). Keycloak may present the information as realm roles or as group membership depending on the mappers configured on the client, and the code accepts either.

AUTH_USER_REGISTRATION = True
AUTH_USER_REGISTRATION_ROLE = "Gamma"
AUTH_ROLES_MAPPING = {
'superset-admin': ['Admin'],
'superset-user': ['Gamma'],
'superset-public': ['Public'],
}
AUTH_ROLES_SYNC_AT_LOGIN = True
AUTH_ROLE_ADMIN = 'Admin'
AUTH_ROLE_PUBLIC = 'Public'
SettingEffect
AUTH_USER_REGISTRATIONCreates a Superset user on first login instead of rejecting unknown users.
AUTH_USER_REGISTRATION_ROLERole for a user matching nothing in the mapping. Gamma is Superset’s standard limited role.
AUTH_ROLES_MAPPINGKeycloak group or role name → Superset roles.
AUTH_ROLES_SYNC_AT_LOGINRe-evaluates roles on every login, so Keycloak stays authoritative. Without it, roles are assigned once at registration and drift forever.

The mapping keys must match the group names in nebariapp.auth.groups. They are two halves of one configuration and there is no validation tying them together.

nebariapp.auth.scopes requests openid, profile, and email from the operator side. The Superset side asks for more:

'client_kwargs': {'scope': 'openid profile email roles'},

The extra roles scope is what puts group or role information into the userinfo response that oauth_user_info reads. If every user lands in Gamma regardless of their Keycloak groups, this is the first thing to check — followed by whether the client actually has a mapper emitting the claim.

Log what Keycloak returns:

Terminal window
kubectl -n superset logs deploy/superset | grep "Keycloak userinfo"

The example security manager logs the full userinfo payload at info level, which shows exactly which claim is present and what it contains.

That payload includes the user’s email and name, on every login, in plain pod logs. It is a debugging aid rather than a setting to leave on: drop the logger.info line from oauth_config once role mapping works, or lower it to debug.

SymptomLikely cause
Redirect loopredirectURI does not match Flask-AppBuilder’s callback path, or the client’s registered redirect URI is stale.
Pod stuck in CreateContainerConfigErrorThe extraEnvRaw secret name or key does not match the actual secret. client-id and client-secret are not optional, so the container cannot start.
“Invalid client” from KeycloakThe secret exists but its client-id or client-secret is stale or empty, usually after the Keycloak client was re-provisioned or its secret rotated.
Login fails with an opaque Authlib errorissuer-url is missing from the secret, or present but empty. Missing is tolerated because it is the one key marked optional: true; either way the pod starts and server_metadata_url falls back to ''.
Every user is GammaThe roles claim is missing — scope or client mapper — or the group names do not match AUTH_ROLES_MAPPING.
Double login promptenforceAtGateway got set to true; check for a SecurityPolicy.
Login succeeds, then 500Often a SUPERSET_SECRET_KEY rotation — see Secret key.

AUTH_TYPE = AUTH_OAUTH replaces the local login form. The admin account the upstream chart bootstraps still exists in the metadata database and can be reached with AUTH_TYPE = AUTH_DB temporarily if you lock yourself out — useful when a role mapping change leaves nobody with Admin.