OAuth2 with Keycloak fails with 401 on Userinfo endpoint

I’m not sure how to debug this, but I am using Keycloak with OAuthenticator integration and Generic OAuth endpoint connections.

I had to do some configuration via environment variables instead of the OAuthenticator documentation with TLJH setup steps, but effectively I have this:

ENV VARS (defined in SystemD unit overrides):

OAUTH2_AUTHORIZE_URL=https://auth.REDACTED/realms/jupyter/protocol/openid-connect/auth
OAUTH2_TOKEN_URL=https://auth.REDACTED/realms/jupyter/protocol/openid-connect/token
OAUTH2_TOKEN_URL=https://auth.REDACTED/realms/jupyter/protocol/openid-connect/userinfo
OAUTH2_USERNAME_KEY=email
OAUTH_LOGOUT_REDIRECT_URL=https://auth.REDACTED/realms/jupyter/protocol/openid-connect/logout
OAUTH_CLIENT_ID=REDACTED
OAUTH_CLIENT_SECRET=REDACTED

The TLJH config YAML contains:
(NOTE: it did not work without the aforementioned env vars being set so I think there’s something odd going on there)

users:
  admin:
  - sysadmin
https:
  enabled: true
  tls:
    cert: /etc/ssl/certs/ssl-cert-snakeoil.pem
    key: /etc/ssl/private/ssl-cert-snakeoil.key
JupyterHub:
  authenticator_class: generic-oauth
GenericOAuthenticator:
  client_id: REDACTED
  client_secret: REDACTED
  authorize_url:
    https://auth.REDACTED/realms/jupyter/protocol/openid-connect/auth
  token_url:
    https://auth.REDACTED/realms/jupyter/protocol/openid-connect/token
  userdata_url:
    https://auth.dark-chaos.net/realms/jupyter/protocol/openid-connect/userinfo
  scope: ["openid", "email", "groups"]
  username_claim: email
  auth_state_groups_key: oauth_user.groups
  admin_groups: {"administrator"}
  oauth_callback_url: https://jupyter.REDACTED/hub/oauth_callback
auth:
  type: generic-oauth

During authentication, the handoff from TLJH to Keycloak works fine, and it hands back a code/token for exchange. However, during the exchange going back from Keycloak to TLJH, we get a 500 Internal Server Error, with this traceback:

Oct 19 00:00:31 jupyter python3[6829]: [E 2025-10-19 00:00:31.933 JupyterHub oauth2:857] Error fetching 401 POST https://auth.REDACTED/realms/jupyter/protocol/openid-connect/userinfo:
Oct 19 00:00:31 jupyter python3[6829]: [E 2025-10-19 00:00:31.933 JupyterHub web:1934] Uncaught exception GET /hub/oauth_callback?state=REDACTED&session_state=REDACTED&iss=https%3A%2F%2Fauth.REDACTED%2Frealms%2Fjupyter&code=REDACTED (10.200.5.20)
Oct 19 00:00:31 jupyter python3[6829]:     HTTPServerRequest(protocol='https', host='jupyter.REDACTED', method='GET', uri='/hub/oauth_callback?state=REDACTED&session_state=REDACTED&iss=https%3A%2F%2Fauth.REDACTED%2Frealms%2Fjupyter&code=REDACTED', version='HTTP/1.1', remote_ip='10.200.5.20')
Oct 19 00:00:31 jupyter python3[6829]:     Traceback (most recent call last):
...
Oct 19 00:00:31 jupyter python3[6829]:     tornado.httpclient.HTTPClientError: HTTP 401: Unauthorized

I’m unable to determine why it’s doing a 401 Unauthorized response. Though, it’s behaving almost like it isn’t properly authenticating to the userinfo endpoint.

Is there a way to increase the log verbosity to see what exactly is being sent (headers included), or am I missing something in my configuration to make this work? It’s behaving as if the system isn’t providing an Authorization header with an access token in the header to the backend. If you do that it works.

(And before you address the use of the snakeoil cert on a system being selfsigned / insecure, jupyter is served to the world with an NGINX proxy in front of it with proper SSL, so the snakeoil cert is to just let the SSL handoff to the backend work. It hasn’t given any problems here so far)

Is it a typo while pasting here or you have OAUTH2_TOKEN_URL set twice in your systemd service file?

1 Like

Whoops, that’s definitely a problem.

Changed that so the second one is the USERDATA endpoint (userinfo in keycloak) and now it’s just 403ing when handing off to https://auth.REDACTED/realms/jupyter/protocol/openid-connect/userinfo.

So, 401 gone, 403 is now the problem.

Could you post all the logs in DEBUG mode while attempting to authenticate? Also, could you check the logs on Keycloak side as well?

Give me some extra time to get the data - been traveling for work this week and it’s caused me limited access to the system lately.

1 Like

This config works well with keycloak (v26) and TLJH (http://tljh.jupyter.org/)

Content of: /opt/tljh/config/jupyterhub_config.d/keycloak.py

c.JupyterHub.authenticator_class = “generic-oauth”

c.GenericOAuthenticator.client_id = “${KC_CLIENT_ID}”
c.GenericOAuthenticator.client_secret = “${KC_CLIENT_SECRET}”
c.GenericOAuthenticator.oauth_callback_url = “https://${TLJH_SERVER}/hub/oauth_callback”

c.GenericOAuthenticator.authorize_url = “https://${KEYCLOAK_SERVER}/auth/realms/${KC_REALM}/protocol/openid-connect/auth”
c.GenericOAuthenticator.token_url = “https://${KEYCLOAK_SERVER}/auth/realms/${KC_REALM}/protocol/openid-connect/token”
c.GenericOAuthenticator.userdata_url = “https://${KEYCLOAK_SERVER}/auth/realms/${KC_REALM}/protocol/openid-connect/userinfo”

c.GenericOAuthenticator.login_service = “Keycloak login”
c.GenericOAuthenticator.username_claim = “preferred_username”
c.GenericOAuthenticator.scope = [“openid”]

c.GenericOAuthenticator.allow_all = True
c.GenericOAuthenticator.auto_login = True

I also have another instance running Z2JH (https://z2jh.jupyter.org) and this is the config in the helm values.yaml:

hub:
  config:
    Authenticator:
      enable_auth_state: False
    GenericOAuthenticator:
      client_id: ${KC_CLIENT_ID}
      client_secret: ${KC_CLIENT_SECRET}
      oauth_callback_url: https://${Z2JH_SERVER}/hub/oauth_callback
      authorize_url: https://${KEYCLOAK_SERVER}auth/realms/${KC_REALM}/protocol/openid-connect/auth
      token_url: https://${KEYCLOAK_SERVER}/auth/realms/${KC_REALM}/protocol/openid-connect/token
      userdata_url: https://${KEYCLOAK_SERVER}/auth/realms/${KC_REALM}/protocol/openid-connect/userinfo
      login_service: keycloak
      username_claim: preferred_username
      userdata_params:
        state: state
      # Allow all Keycloak users
      allow_existing_users: true
      auto_login: true
      admin_users:
        - danifr
    JupyterHub:
      authenticator_class: generic-oauth

Hope this helps.

2 Likes

Well, I’m not sure if this is a ‘new’ problem or not, but this is what Keycloak says the error is:

2025-10-25 03:11:13,841 WARN [org.keycloak.events] (executor-thread-15) type="USER_INFO_REQUEST_ERROR", realmId="8b92e7d8-c1aa-4ffc-b5f6-62e15c8ef55e", realmName="jupyter", clientId="null", userId="null", ipAddress="10.200.5.22", error="access_denied", reason="Missing openid scope", auth_method="validate_access_token"

I’m going to hazard a guess that something in Jupyter isn’t passing the scope properly to the userinfo endpoint… because if I use Postman with the Keycloak endpoints and auth tokens, etc. it works fine and doesn’t throw this error (PROBABLY because Postman includes the scope in the initial request… or something else is fubar)

Well, in your initial post you mentioned that you had to pass all OAuth related stuff as environment variables as your TLJH config is being ignored. But there is no env var setting OAuth scope (because it cannot be set by env var) and so the error you are getting?!

I advise you to setup GenericOAuthenticator config using JupyterHub config file and check if all config parameters are properly passed.

1 Like