Error 403 when trying to authenticate through AD FS

I’m trying to make JupyterHub work alongside AD FS. There is no specific documentation on how to do this, so I’m trying to use the GenericOAuthenticator:

hub:
  config:
    GenericOAuthenticator:
      client_id: [REDACTED]
      client_secret: [REDACTED]
      oauth_callback_url: https://my-jupyterhub-domain/hub/oauth_callback
      authorize_url: https://my-adfs-domain/adfs/oauth2/authorize
      token_url: https://my-adfs-domain/adfs/oauth2/token
      userdata_url: https://my-adfs-domain/userinfo
      scope:
        - openid
        - name
        - profile
        - email
      username_key: name
    JupyterHub:
      authenticator_class: generic-oauth

Pressing the “Sign in with OAuth 2.0” button and authenticating the user returns a 403 Forbidden error.

Here are the Jupyter logs during authentication:

[D 2021-08-24 11:54:31.709 JupyterHub proxy:832] Proxy: Fetching GET http://proxy-api:8001/api/routes
[I 2021-08-24 11:54:31.719 JupyterHub proxy:347] Checking routes
[D 2021-08-24 11:54:33.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.38ms
[D 2021-08-24 11:54:35.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.35ms
[D 2021-08-24 11:54:37.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.50ms
[D 2021-08-24 11:54:39.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.32ms
[D 2021-08-24 11:54:39.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 0.91ms
[D 2021-08-24 11:54:41.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.34ms
[D 2021-08-24 11:54:43.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.35ms
[D 2021-08-24 11:54:45.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.34ms
[D 2021-08-24 11:54:47.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.27ms
[D 2021-08-24 11:54:49.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.35ms
[D 2021-08-24 11:54:49.688 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.42ms
[D 2021-08-24 11:54:51.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.45ms
[D 2021-08-24 11:54:53.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.10ms
[D 2021-08-24 11:54:55.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.08ms
[D 2021-08-24 11:54:57.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.53ms
[D 2021-08-24 11:54:59.688 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.94ms
[D 2021-08-24 11:54:59.689 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.96ms
[D 2021-08-24 11:55:01.688 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.27ms
[D 2021-08-24 11:55:03.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.18ms
[D 2021-08-24 11:55:05.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.39ms
[D 2021-08-24 11:55:07.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.25ms
[D 2021-08-24 11:55:09.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.01ms
[D 2021-08-24 11:55:09.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.05ms
[D 2021-08-24 11:55:11.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.39ms
[D 2021-08-24 11:55:13.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.35ms
[D 2021-08-24 11:55:15.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.32ms
[D 2021-08-24 11:55:17.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.59ms
[D 2021-08-24 11:55:19.688 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 3.11ms
[D 2021-08-24 11:55:19.689 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 3.05ms
[D 2021-08-24 11:55:21.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.22ms
[D 2021-08-24 11:55:23.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.56ms
[D 2021-08-24 11:55:25.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.73ms
[D 2021-08-24 11:55:27.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.71ms
[D 2021-08-24 11:55:29.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.44ms
[D 2021-08-24 11:55:29.688 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 0.95ms
[D 2021-08-24 11:55:31.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.36ms
[D 2021-08-24 11:55:31.710 JupyterHub proxy:832] Proxy: Fetching GET http://proxy-api:8001/api/routes
[I 2021-08-24 11:55:31.715 JupyterHub proxy:347] Checking routes
[D 2021-08-24 11:55:33.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.70ms
[D 2021-08-24 11:55:35.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.79ms
[D 2021-08-24 11:55:37.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.39ms
[I 2021-08-24 11:55:38.606 JupyterHub log:189] 302 GET / -> /hub/ (@::[REDACTED_IP]) 1.56ms
[I 2021-08-24 11:55:38.623 JupyterHub log:189] 302 GET /hub/ -> /hub/login?next=%2Fhub%2F (@::[REDACTED_IP]) 1.48ms
[I 2021-08-24 11:55:38.646 JupyterHub log:189] 200 GET /hub/login?next=%2Fhub%2F (@::[REDACTED_IP]) 8.30ms
[D 2021-08-24 11:55:39.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.64ms
[D 2021-08-24 11:55:39.689 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.31ms
[I 2021-08-24 11:55:39.932 JupyterHub oauth2:111] OAuth redirect: 'https://my-jupyterhub-domain/hub/oauth_callback'
[D 2021-08-24 11:55:39.933 JupyterHub base:526] Setting cookie oauthenticator-state: {'httponly': True, 'expires_days': 1}
[I 2021-08-24 11:55:39.936 JupyterHub log:189] 302 GET /hub/oauth_login?next=%2Fhub%2F -> https://my-adfs-domain/adfs/oauth2/authorize?response_type=code&redirect_uri=https%3A%2F%2Fmy-jupyterhub-domain%2Fhub%2Foauth_callback&client_id=[REDACTED_ID]&state=[secret]&scope=openid+name+profile+email (@::[REDACTED_IP]) 5.32ms
[D 2021-08-24 11:55:41.685 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.33ms
[E 2021-08-24 11:55:42.188 JupyterHub generic:178] OAuth user contains no key name: {'sub': '[REDACTED]'}
[W 2021-08-24 11:55:42.188 JupyterHub base:768] Failed login for unknown user
[D 2021-08-24 11:55:42.189 JupyterHub base:1285] No template for 403
[W 2021-08-24 11:55:42.191 JupyterHub log:189] 403 GET /hub/oauth_callback?code=[secret]&state=[secret] (@::[REDACTED_IP]) 150.18ms
[D 2021-08-24 11:55:43.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.47ms
[D 2021-08-24 11:55:45.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.74ms
[D 2021-08-24 11:55:47.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.40ms
[D 2021-08-24 11:55:49.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.69ms
[D 2021-08-24 11:55:49.687 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 2.69ms
[D 2021-08-24 11:55:51.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.42ms
[D 2021-08-24 11:55:53.686 JupyterHub log:189] 200 GET /hub/health (@[REDACTED_IP]) 1.75ms

Any hints?

I was able to isolate the problem. It seems that AD FS’s /userinfo endpoint only returns the sub claim by design (docs). I changed the username_key parameter to sub just to check and I could authenticate, although JupyterHub can’t access more user data.

AD FS can be configured to return additional claims, but only inside the id_token. I see in GenericOAuthenticator’s code that it does not handle this situation (correct me if I’m wrong). The AzureAdOAuthenticator does handle that though.

It seems my only option now is to code a custom authenticator. I’m not totally comfortable doing that. Is there an alternative here? If not, any hints on how to convert the GenericOAuthenticator to my needs? What about deploying it to the Zero-to-JupyterHub-with-Kubernetes setup?

You don’t need to write a completely new authenticator. Instead you can subclass it and just override the relevant method(s). If you don’t want to build a new Hub container you can define your subclass in your Z2JH config file using hub.extraConfig

1 Like

Wait why? Why not use the AzureAdOAuthenticator if it does what you needed.

I was referring to the part it handles the id_token. The rest of the AzureAdOAuthenticator is geared towards AzureAD, but what I need is an authenticator for AD FS.

Maybe it’s just a matter of changing AzureAdOAuthenticator a little? Any guidances on that? Since we are talking about a security feature, I’m really scary of messing up something.

I’m using the following to authenticate against ADFS.

hub:
  config:
    Authenticator:
      [...]
      enable_auth_state: True
    AzureAdOAuthenticator:
      authorize_url: 'https://[adfs server]/adfs/oauth2/authorize'
      client_id: '[redacted]'
      client_secret: '[redacted]'
      login_service: '[redacted]'
      tenant_id: '[adfs server]'
      token_url: 'https://[adfs server]/adfs/oauth2/token/'
      userdata_url: 'https://[adfs server]/adfs/userinfo'
      username_claim: 'winaccountname'
    JupyterHub:
      authenticator_class: azuread
  extraConfig:
        auth.py: |
      """
      Custom Authenticator to use AD FS with JupyterHub
      """
      from jupyterhub.auth import LocalAuthenticator

      from oauthenticator.oauth2 import OAuthLoginHandler, OAuthCallbackHandler, OAuthenticator
      from oauthenticator.azuread import AzureAdOAuthenticator
      from oauthenticator.generic import GenericOAuthenticator, LocalGenericOAuthenticator


      class PostOAuthCallbackHandler(OAuthCallbackHandler):
          async def post(self):
              self.check_arguments()
              user = await self.login_user()
              if user is None:
                  # todo: custom error page?
                  raise web.HTTPError(403)
              self.redirect(self.get_next_url(user))
      
      
      class ADFSOAuthenticator(AzureAdOAuthenticator):
          login_handler = OAuthLoginHandler
          callback_handler = PostOAuthCallbackHandler

      class LocalADFSOAuthenticator(LocalGenericOAuthenticator, ADFSOAuthenticator):
          """A version that mixes in local system user creation"""
          pass
     

      c.JupyterHub.authenticator_class = ADFSOAuthenticator
      c.OAuthenticator.extra_authorize_params = {'resource': 'https://[jupyterhub url]', 'scope': 'openid allatclaims profile email groups', 'response_mode': 'form_post'}
      c.Authenticator.enable_auth_state = True
3 Likes

Thank you very much, @rdrake!

I did some cleanup and my config ended up like this:

hub:
  config:
    ADFSOAuthenticator:
      authorize_url: https://my-adfs-domain/adfs/oauth2/authorize
      client_id: [REDACTED]
      client_secret: [REDACTED]
      login_service: [REDACTED]
      tenant_id: my-adfs-domain
      token_url: https://my-adfs-domain/adfs/oauth2/token
      userdata_url: https://my-adfs-domain/adfs/userinfo
      username_claim: email
      oauth_callback_url: https://my-jupyterhub-domain/hub/oauth_callback
      enable_auth_state: True

  extraConfig:
      auth.py: |
        """
        Custom Authenticator to use AD FS with JupyterHub
        """
        from oauthenticator.oauth2 import OAuthLoginHandler, OAuthCallbackHandler
        from oauthenticator.azuread import AzureAdOAuthenticator
        from oauthenticator.generic import LocalGenericOAuthenticator

        class PostOAuthCallbackHandler(OAuthCallbackHandler):
            async def post(self):
                self.check_arguments()
                user = await self.login_user()

                if user is None:
                    raise web.HTTPError(403)

                self.redirect(self.get_next_url(user))
        
        class ADFSOAuthenticator(AzureAdOAuthenticator):
            login_handler = OAuthLoginHandler
            callback_handler = PostOAuthCallbackHandler

        class LocalADFSOAuthenticator(LocalGenericOAuthenticator, ADFSOAuthenticator):
            """A version that mixes in local system user creation"""
            pass
        
        c.JupyterHub.authenticator_class = ADFSOAuthenticator
        c.OAuthenticator.extra_authorize_params = {'resource': 'https://my-jupyterhub-domain/', 'scope': 'openid allatclaims profile email', 'response_mode': 'form_post'}
2 Likes

Thanks for this! It’s inspired me to hack the NativeAuthenticator (if possible).

Quick question … if you don’t mind …

How safe is it to add handlers in the way you do (extracted below) instead of redefining a get_handlers() method on the authenticator?

Apologies for my lack of familiarity with the jupyterhub and authenticator code bases (and tornado too) … but I was under the impression that handlers had to be “registered” with a get_handlers() method.

class ADFSOAuthenticator(AzureAdOAuthenticator):
          login_handler = OAuthLoginHandler
          callback_handler = PostOAuthCallbackHandler