[Q] Values set in Authenticator.post_auth_hook are not available in Spawner.auth_state_hook when using AzureAdOAuthenticator

Hello, members

1. Overview

I would like to understand how to use Spawner.auth_state_hook and Authenticator.post_auth_hook with AzureAdOAuthenticator.

2. Goal

After successful authentication, I want to retrieve user attributes from an LDAP server and use them (e.g., uidNumber, gidNumber, homeDirectory).

3. What I tried

I used Authenticator.post_auth_hook to fetch information from the LDAP server after authentication and added attributes such as uidNumber to auth_state.
Then, I attempted to use the retrieved uidNumber in Spawner.auth_state_hook.

4. Issue

When using Spawner.auth_state_hook, the values such as uidNumber set in Authenticator.post_auth_hook disappeared from auth_state.
After subclassing AzureAdOAuthenticator and re-setting uidNumber and related attributes in refresh_user, it started working.

5. Questions

  • Is there a way to use uidNumber and similar attributes in Spawner.auth_state_hook without reimplementing refresh_user?
  • Is it the correct approach to fetch values from LDAP in Authenticator.post_auth_hook and use them in Spawner.auth_state_hook?

6. Environment

  • CHART VERSION: 4.3.3
  • APP VERSION: 5.4.4
      def my_post_auth_hook(authenticator, handler, auth_model):
          # search LDAP
          if conn.entries:
              entry = conn.entries[0]
              auth_state = auth_model["auth_state"]
              auth_state["username"] = str(username)
              auth_state["uidNumber"] = str(entry.uidNumber)
              auth_state["gidNumber"] = str(entry.gidNumber)
              auth_state["homeDirectory"] = str(entry.homeDirectory)
          else:
              authenticator.log.error("Can't get LDAP entry: %s", dn)

          return auth_model

      def userdata_hook(spawner, auth_state):
          if not auth_state:
              return

          spawner.log.debug("userdata_hook")
          spawner.log.debug(auth_state)
          spawner.environment["NB_USER"] = auth_state['username']
          spawner.environment["NB_UID"] = auth_state.get("uidNumber", "")
          spawner.environment["NB_GID"] = auth_state.get("gidNumber", "")
          spawner.environment["HOME"] = auth_state.get("homeDirectory", "")

      c.Authenticator.allow_all = True
      c.Authenticator.enable_auth_state = True
      c.Authenticator.post_auth_hook = my_post_auth_hook
      c.Spawner.auth_state_hook = userdata_hook
      c.JupyterHub.authenticator_class = AzureAdOAuthenticatorInfo
      c.ServerApp.shutdown_no_activity_timeout = 150      

I added the following code

      class AzureAdOAuthenticatorInfo(AzureAdOAuthenticator):
          async def refresh_user(self, user, handler=None):
              # snip
              new_state = refreshed.get("auth_state") or {}
              refreshed["auth_state"] = new_state

              for key in ("uidNumber", "gidNumber", "homeDirectory", "username"):
                  if key in old_state and key not in new_state:
                      new_state[key] = old_state[key]

              return refreshed

Can you add some logging to both return paths in both hooks, and show us your JupyterHub debug logs?

1 Like

If refreshing user works for you, did you try setting AzureAdOAuthenticator.refresh_pre_spawn to True ?

Thank you for your comment.

From the logs, it can be seen that Spawner.auth_state_hook is called twice: once before refresh_user and once after it.

During the first call to Spawner.auth_state_hook, the custom value ("Z2JH_TEST": "DUMMY DUMMY DUMMY") set in Authenticator.post_auth_hook is still present in auth_state.

However, after refresh_user is executed, the auth_state is replaced by the result returned from super().refresh_user(), and the custom attributes such as uidNumber, gidNumber, homeDirectory, and Z2JH_TEST are no longer present.

As a result, during the second call to Spawner.auth_state_hook, these custom values have disappeared from auth_state, which eventually leads to errors such as KeyError: 'username'.

1. c.Authenticator.post_auth_hook

1.1 Before set custom value

[D 2026-04-09 00:23:08.578 JupyterHub <string>:51] auth_model:
{
  "name": "John Smith",
  "admin": null,
  "auth_state": {
    "access_token": "",
    "refresh_token": "",
    "id_token": "",
    "scope": [
      ""
    ],
    "token_response": {
      "token_type": "Bearer",
      "expires_in": "3599",
      "ext_expires_in": "3599",
      "expires_on": "1775697788",
      "access_token": "",
      "refresh_token": "",
      "id_token": ""
    },
    "user": {
      "aud": "19d5279a-15a9-43bc-8d97-7990af52e5ff",
      "iss": "https://sts.windows.net/3d7049f7-ce7c-4926-b7ae-2009ba790c24/",
      "iat": 1775693888,
      "nbf": 1775693888,
      "exp": 1775697788,
      "amr": [
        "pwd",
        "mfa"
      ],
      "family_name": "Smith",
      "given_name": "John",
      "ipaddr": "2001:db8:1234:5678:9abc:def0:1234:5678",
      "name": "John Smith",
      "oid": "7ea0f9b7-d4a3-4ec1-a68e-2790e051c738",
      "rh": "noCeichingu6shee9kaengaeghainguavugahgohloimuu8Weidaephai",
      "sid": "8158ffc5-bb91-4598-a2d0-0f636023cba0",
      "sub": "6a81d034-2e8d-4035-9b06-7b4968ccfc3b",
      "tid": "3d7049f7-ce7c-4926-b7ae-2009ba790c24",
      "unique_name": "jhon@example.com",
      "upn": "jhon@example.com",
      "uti": "eeng6Thu8thiewie0fux7p",
      "ver": "1.0"
    }
  }
}

1.2 After set custom value

[D 2026-04-09 00:23:08.619 JupyterHub <string>:89] return auth_model:

{
  "name": "jhon",
  "admin": null,
  "auth_state": {
    "access_token": "",
    "refresh_token": "",
    "id_token": "",
    "scope": [
      ""
    ],
    "token_response": {
      "token_type": "Bearer",
      "expires_in": "3599",
      "ext_expires_in": "3599",
      "expires_on": "1775697788",
      "access_token": "",
      "id_token": ""
    },
    "user": {
      "aud": "19d5279a-15a9-43bc-8d97-7990af52e5ff",
      "iss": "https://sts.windows.net/3d7049f7-ce7c-4926-b7ae-2009ba790c24/",
      "iat": 1775693888,
      "nbf": 1775693888,
      "exp": 1775697788,
      "amr": [
        "pwd",
        "mfa"
      ],
      "family_name": "Smith",
      "given_name": "John",
      "ipaddr": "2001:db8:1234:5678:9abc:def0:1234:5678",
      "name": "John Smith",
      "oid": "7ea0f9b7-d4a3-4ec1-a68e-2790e051c738",
      "rh": "noCeichingu6shee9kaengaeghainguavugahgohloimuu8Weidaephai",
      "sid": "8158ffc5-bb91-4598-a2d0-0f636023cba0",
      "sub": "6a81d034-2e8d-4035-9b06-7b4968ccfc3b",
      "tid": "3d7049f7-ce7c-4926-b7ae-2009ba790c24",
      "unique_name": "jhon@example.com",
      "upn": "jhon@example.com",
      "uti": "eeng6Thu8thiewie0fux7p",
      "ver": "1.0"
    },
    "username": "jhon",
    "uidNumber": "123456",
    "gidNumber": "987",
    "homeDirectory": "/nosuchdir",
    "Z2JH_TEST": "DUMMY DUMMY DUMMY" // <-- test value set
  }
}

2. First Spawner.auth_state_hook

[D 2026-04-09 00:23:08.692 JupyterHub scopes:1013] Checking access to /hub/spawn via scope servers!server=jhon/
[D 2026-04-09 00:23:08.694 JupyterHub <string>:93] userdata_hook
[D 2026-04-09 00:23:08.694 JupyterHub <string>:94] {'access_token': 
{
  "access_token": "",
  "refresh_token": "",
  "id_token": "",
  "scope": [
    ""
  ],
  "token_response": {
    "token_type": "Bearer",
    "expires_in": "3599",
    "ext_expires_in": "3599",
    "expires_on": "1775697788",
    "access_token": "",
    "id_token": ""
  },
  "user": {
    "aud": "19d5279a-15a9-43bc-8d97-7990af52e5ff",
    "iss": "https://sts.windows.net/3d7049f7-ce7c-4926-b7ae-2009ba790c24/",
    "iat": 1775693888,
    "nbf": 1775693888,
    "exp": 1775697788,
    "amr": [
      "pwd",
      "mfa"
    ],
    "family_name": "Smith",
    "given_name": "John",
    "ipaddr": "2001:db8:1234:5678:9abc:def0:1234:5678",
    "name": "John Smith",
    "oid": "7ea0f9b7-d4a3-4ec1-a68e-2790e051c738",
    "rh": "noCeichingu6shee9kaengaeghainguavugahgohloimuu8Weidaephai",
    "sid": "8158ffc5-bb91-4598-a2d0-0f636023cba0",
    "sub": "6a81d034-2e8d-4035-9b06-7b4968ccfc3b",
    "tid": "3d7049f7-ce7c-4926-b7ae-2009ba790c24",
    "unique_name": "jhon@example.com",
    "upn": "jhon@example.com",
    "uti": "eeng6Thu8thiewie0fux7p",
    "ver": "1.0"
  },
  "username": "jhon",
  "uidNumber": "123456",
  "gidNumber": "987",
  "homeDirectory": "/nosuchdir",
  "Z2JH_TEST": "DUMMY DUMMY DUMMY" // <-- Value stil exists
}

3. refresh user

3.1 Before refresh_user

[W 2026-04-09 00:23:08.695 JupyterHub <string>:105] BEFORE get_auth_state
[W 2026-04-09 00:23:08.695 JupyterHub <string>:107] BEFORE auth_state={'access_token':
{
  "access_token": "",
  "refresh_token": "",
  "id_token": "",
  "scope": [
    ""
  ],
  "token_response": {
    "token_type": "Bearer",
    "expires_in": "3599",
    "ext_expires_in": "3599",
    "expires_on": "1775697788",
    "access_token": "",
    "refresh_token": "",
    "id_token": ""
  },
  "user": {
    "aud": "19d5279a-15a9-43bc-8d97-7990af52e5ff",
    "iss": "https://sts.windows.net/3d7049f7-ce7c-4926-b7ae-2009ba790c24/",
    "iat": 1775693888,
    "nbf": 1775693888,
    "exp": 1775697788,
    "amr": [
      "pwd",
      "mfa"
    ],
    "family_name": "Smith",
    "given_name": "John",
    "ipaddr": "2001:db8:1234:5678:9abc:def0:1234:5678",
    "name": "John Smith",
    "oid": "7ea0f9b7-d4a3-4ec1-a68e-2790e051c738",
    "rh": "noCeichingu6shee9kaengaeghainguavugahgohloimuu8Weidaephai",
    "sid": "8158ffc5-bb91-4598-a2d0-0f636023cba0",
    "sub": "6a81d034-2e8d-4035-9b06-7b4968ccfc3b",
    "tid": "3d7049f7-ce7c-4926-b7ae-2009ba790c24",
    "unique_name": "jhon@example.com",
    "upn": "jhon@example.com",
    "uti": "eeng6Thu8thiewie0fux7p",
    "ver": "1.0"
  },
  "username": "jhon",
  "uidNumber": "123456",
  "gidNumber": "987",
  "homeDirectory": "/nosuchdir",
  "Z2JH_TEST": "DUMMY DUMMY DUMMY" // < Custom value still present.
}

3.2 After refresh user

[W 2026-04-09 00:23:08.697 JupyterHub <string>:111] AFTER super()
[W 2026-04-09 00:23:08.697 JupyterHub <string>:116] AFTER auth_state={'access_token':
{
  "access_token": "",
  "refresh_token": "",
  "id_token": "",
  "scope": [
    ""
  ],
  "token_response": {
    "token_type": "Bearer",
    "expires_in": "3599",
    "ext_expires_in": "3599",
    "expires_on": "1775697788",
    "access_token": "",
    "refresh_token": "",
    "id_token": ""
  },
  "user": {
    "aud": "19d5279a-15a9-43bc-8d97-7990af52e5ff",
    "iss": "https://sts.windows.net/3d7049f7-ce7c-4926-b7ae-2009ba790c24/",
    "iat": 1775693888,
    "nbf": 1775693888,
    "exp": 1775697788,
    "amr": [
      "pwd",
      "mfa"
    ],
    "family_name": "Smith",
    "given_name": "John",
    "ipaddr": "2001:db8:1234:5678:9abc:def0:1234:5678",
    "name": "John Smith",
    "oid": "7ea0f9b7-d4a3-4ec1-a68e-2790e051c738",
    "rh": "noCeichingu6shee9kaengaeghainguavugahgohloimuu8Weidaephai",
    "sid": "8158ffc5-bb91-4598-a2d0-0f636023cba0",
    "sub": "6a81d034-2e8d-4035-9b06-7b4968ccfc3b",
    "tid": "3d7049f7-ce7c-4926-b7ae-2009ba790c24",
    "unique_name": "jhon@example.com",
    "upn": "jhon@example.com",
    "uti": "eeng6Thu8thiewie0fux7p",
    "ver": "1.0"
  }
  // Custom value has been removed. "Z2JH_TEST": "DUMMY DUMMY DUMMY"
}

4. Second Spawner.auth_state_hook

[D 2026-04-09 00:23:08.767 JupyterHub <string>:93] userdata_hook
[D 2026-04-09 00:23:08.767 JupyterHub <string>:94] {'access_token': 
{
  "access_token": "",
  "refresh_token": "",
  "id_token": "",
  "scope": [
    ""
  ],
  "token_response": {
    "token_type": "Bearer",
    "expires_in": "3599",
    "ext_expires_in": "3599",
    "expires_on": "1775697788",
    "access_token": "",
    "id_token": ""
  },
  "user": {
    "aud": "19d5279a-15a9-43bc-8d97-7990af52e5ff",
    "iss": "https://sts.windows.net/3d7049f7-ce7c-4926-b7ae-2009ba790c24/",
    "iat": 1775693888,
    "nbf": 1775693888,
    "exp": 1775697788,
    "amr": [
      "pwd",
      "mfa"
    ],
    "family_name": "Smith",
    "given_name": "John",
    "ipaddr": "2001:db8:1234:5678:9abc:def0:1234:5678",
    "name": "John Smith",
    "oid": "7ea0f9b7-d4a3-4ec1-a68e-2790e051c738",
    "rh": "noCeichingu6shee9kaengaeghainguavugahgohloimuu8Weidaephai",
    "sid": "8158ffc5-bb91-4598-a2d0-0f636023cba0",
    "sub": "6a81d034-2e8d-4035-9b06-7b4968ccfc3b",
    "tid": "3d7049f7-ce7c-4926-b7ae-2009ba790c24",
    "unique_name": "jhon@example.com",
    "upn": "jhon@example.com",
    "uti": "eeng6Thu8thiewie0fux7p",
    "ver": "1.0"
  }
  // Custom value removed "Z2JH_TEST": "DUMMY DUMMY DUMMY"
}
[E 2026-04-09 00:23:08.767 JupyterHub user:1004] Unhandled error starting jhon's server: 'username'
    Traceback (most recent call last):
      File "/usr/local/lib/python3.12/site-packages/jupyterhub/user.py", line 897, in spawn
        await spawner.run_auth_state_hook(auth_state)
      File "/usr/local/lib/python3.12/site-packages/jupyterhub/spawner.py", line 1575, in run_auth_state_hook
        await maybe_future(self.auth_state_hook(self, auth_state))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "<string>", line 98, in userdata_hook
    KeyError: 'username'

5. Configuration

  extraConfig:
    SpawnerCustomConfig: |
      from oauthenticator.azuread import AzureAdOAuthenticator
      from ldap3 import Server, Connection, ALL, SUBTREE
      from hashlib import md5

      def my_post_auth_hook(authenticator, handler, auth_model):
          authenticator.log.debug("my_post_auth_hook called")
          authenticator.log.debug("auth_model: %s", auth_model)

          auth_state = auth_model.get("auth_state") or {}
          auth_state_user = auth_state.get("user") or {}
          # snip
          if conn.entries:
              entry = conn.entries[0]
              auth_state = auth_model["auth_state"]
              # SNIP
              auth_state["Z2JH_TEST"] = "DUMMY DUMMY DUMMY"
          # SNIP

          authenticator.log.debug("return auth_model: %s", auth_model)
          return auth_model

      def userdata_hook(spawner, auth_state):
          spawner.log.debug("userdata_hook")
          spawner.log.debug(auth_state)
          if not auth_state:
              return

          spawner.environment["NB_USER"] = auth_state['username']
          # snip

      class AzureAdOAuthenticatorInfo(AzureAdOAuthenticator):
          async def refresh_user(self, user, handler=None):
            self.log.warning("BEFORE get_auth_state")
            before_state = await user.get_auth_state() or {}
            self.log.warning("BEFORE auth_state=%r", before_state)

            refreshed = await super().refresh_user(user, handler)

            self.log.warning("AFTER super()")
            after_state = (
                refreshed.get("auth_state") if isinstance(refreshed, dict)
                else await user.get_auth_state()
            ) or {}
            self.log.warning("AFTER auth_state=%r", after_state)
            return refreshed

      c.Authenticator.allow_all = True
      c.Authenticator.enable_auth_state = True
      c.Authenticator.post_auth_hook = my_post_auth_hook
      c.Spawner.auth_state_hook = userdata_hook
      c.JupyterHub.authenticator_class = AzureAdOAuthenticatorInfo
      c.ServerApp.shutdown_no_activity_timeout = 150

Best regards,

Thank you for your comment.

I tried setting AzureAdOAuthenticator.refresh_pre_spawn = True as suggested, but the issue still persists.
Could you please confirm if this configuration is correct and intended?

c.Authenticator.allow_all = True
c.Authenticator.enable_auth_state = True
c.Authenticator.post_auth_hook = my_post_auth_hook
c.Spawner.auth_state_hook = userdata_hook
#c.JupyterHub.authenticator_class = AzureAdOAuthenticatorInfo
c.ServerApp.shutdown_no_activity_timeout = 150
c.JupyterHub.authenticator_class = AzureAdOAuthenticator # <- Use AzureAdOAuthenticator itself
c.AzureAdOAuthenticator.refresh_pre_spawn = True # <- Here

Sorry @hiroyuki-sato I misunderstood your comment.

So, in the refresh_user method of AzureAdOAuthenticator, the auth_mode is reset everytime the refresh_user is called. If there is no need for refreshing user in your case, you can disable it using c.Authenticator.auth_refresh_age = 0.

If you need to refresh user, I think what you were doing, ie, overloading refresh_user method is way to go.

1 Like

Hello, @mahendrapaipuri Thank you for your comment.

I have summarized my current understanding below. Could you please confirm if it is correct?

1. Customization methods.

Regarding the customization approach, could you please confirm whether my understanding below is correct?

  • Additional attributes should be added using Authenticator.post_auth_hook.
  • Values such as uidNumber, gidNumber, and homeDirectory can be stored in auth_state in Authenticator.post_auth_hook.
  • Environment variables such as NB_UID should then be set from auth_state in Spawner.auth_state_hook (or pre_spawn_start).
  • refresh_user should be overridden or disabled only if necessary.

2. refresh_user behaviors

My understanding is that refresh_user is used to periodically refresh authentication-related information such as access tokens.

If the access token has a sufficiently long lifetime and does not need to be refreshed, it may be possible to disable this behavior.
On the other hand, if the access token expires in a short period of time and needs to be refreshed regularly,
refresh_user needs to remain enabled.

Also, when refresh_user updates the authentication model, custom values previously added to auth_state may not be preserved automatically.
In such cases, it seems necessary to override refresh_user and copy or re-populate those custom values.

Is this understanding correct?

Best regards,

Yes, that is correct!! The ideal scenario is that your IDP sends this auxillary information directly so that you dont have to fetch them separably.

1 Like