|
| 1 | +# Refreshing user authentication |
| 2 | + |
| 3 | +JupyterHub has a mechanism called [`refresh_user`](inv:jupyterhub:py:method#jupyterhub.auth.Authenticator.refresh_user) that is meant to _refresh_ information from the Authentication provider periodically. |
| 4 | +This allows you to make sure things like group membership or other authorization info is up-to-date. |
| 5 | +In OAuth, this can also mean making sure the access token has not expired. |
| 6 | +This is particularly useful in deployments where an access token from the oauth provider is passed to the Server environment, |
| 7 | +e.g. for access to data sources, git repos, etc.. |
| 8 | +You don't want to start a server passing an expired token, do you? |
| 9 | + |
| 10 | +OAuthenticator 17.2 introduces support in all OAuthenticator classes for refreshing user info via this mechanism, including requesting new access tokens if a `refresh_token` is available from the oauth provider. |
| 11 | + |
| 12 | +```{seealso} |
| 13 | +- [More about refresh tokens](https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/) |
| 14 | +``` |
| 15 | + |
| 16 | +How it works: |
| 17 | + |
| 18 | +- Every time a user takes an authenticated action with JupyterHub |
| 19 | + (making an API request, launching a server, visiting a page, etc.), |
| 20 | + JupyterHub checks when the last time auth info was loaded from the provider. |
| 21 | +- If the auth info is older than [Authenticator.auth_refresh_age](inv:jupyterhub:py:attribute#jupyterhub.auth.Authenticator.auth_refresh_age), the auth info is refreshed, |
| 22 | + i.e. the user model is retrieved anew with the current access token, and any changes are applied (usually there aren't any). |
| 23 | + The default value for this age is five minutes. |
| 24 | + You can consider it an expiring cache of the information we retrieved from the OAuth provider. |
| 25 | +- If the access token is expired and a refresh token is a available, |
| 26 | + a new access token is retrieved via the [refresh_token grant](https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/) |
| 27 | +- If no auth info is retrievable (e.g. no refresh token and access token is expired or both are expired or revoked), |
| 28 | + then the user must login again before they are able to take actions in JupyterHub |
| 29 | + because at this point their authorization state is unknown and could no longer be valid. |
| 30 | + |
| 31 | +There is also an option [Authenticator.refresh_pre_spawn](inv:jupyterhub:py:attribute#jupyterhub.auth.Authenticator.refresh_pre_spawn) which can be enabled: |
| 32 | + |
| 33 | +```python |
| 34 | +c.Authenticator.refresh_pre_spawn = True |
| 35 | +``` |
| 36 | + |
| 37 | +to ensure auth is up-to-date before launching a server. |
| 38 | +This is most useful when the server is being passed an access token |
| 39 | +because it ensures the token is valid when the server starts. |
| 40 | + |
| 41 | +## Refreshing tokens from user sessions |
| 42 | + |
| 43 | +```{warning} |
| 44 | +This example requires granting users read access to their own `auth_state`. |
| 45 | +If you plan to provide users with access tokens, |
| 46 | +`auth_state` does not typically include information your users won't have access to with the token itself, |
| 47 | +but it is worth making sure that your Authenticator configuration doesn't put anything in `auth_state` |
| 48 | +that you do not want users to be able to see. |
| 49 | +``` |
| 50 | + |
| 51 | +If your user sessions use access tokens from your oauth provider and those tokens may expire during user sessions, |
| 52 | +you can rely on this mechanism to get fresh access tokens from JupyterHub. |
| 53 | + |
| 54 | +The first step is to grant the _server_ token access to read auth state for its owner. |
| 55 | +Users do not have permission to read their own auth state by default, |
| 56 | +but `auth_state` is where the `access_token` is stored. |
| 57 | +We need to grant the `admin:auth_state!user` scope to both the `user` and `server` roles, |
| 58 | +so that requests with `$JUPYTERHUB_API_TOKEN` will have permission to read the access token: |
| 59 | + |
| 60 | +```python |
| 61 | +c.JupyterHub.load_roles = [ |
| 62 | + { |
| 63 | + "name": "user", |
| 64 | + "scopes": [ |
| 65 | + "self", |
| 66 | + "admin:auth_state!user", |
| 67 | + ], |
| 68 | + }, |
| 69 | + { |
| 70 | + "name": "server", |
| 71 | + "scopes": [ |
| 72 | + "users:activity!user", |
| 73 | + "access:servers!server", |
| 74 | + "admin:auth_state!user", |
| 75 | + ], |
| 76 | + }, |
| 77 | +] |
| 78 | +``` |
| 79 | + |
| 80 | +We then also need to make sure "auth state" is enabled |
| 81 | +(it is enabled by default in the jupyterhub helm chart): |
| 82 | + |
| 83 | +```python |
| 84 | +c.Authenticator.enable_auth_state = True |
| 85 | +# also set $JUPYTERHUB_CRYPT_KEY env to 32-byte string |
| 86 | +# e.g. with `openssl rand -hex 32` |
| 87 | +``` |
| 88 | + |
| 89 | +At this point: |
| 90 | + |
| 91 | +1. When a user logs in, the OAuth user info and access token are encrypted and persisted in the Hub database. |
| 92 | +2. When the server token requests the user model at `/hub/api/user`, an `auth_state` field will be present, containing the current auth state. |
| 93 | +3. Further, when accessing `/hub/api/user` the `refresh_user` logic is triggered if `auth_refresh_age` has elapsed since the last refresh. |
| 94 | + |
| 95 | +This means that you can access `/hub/api/user` with `$JUPYTERHUB_API_TOKEN` and it will **always return a valid access token**, |
| 96 | +even if the currently stored token has expired when the request is made. |
| 97 | + |
| 98 | +To retrieve the access token, make a request to `${JUPYTERHUB_API_URL}/hub/user` with `${JUPYTERHUB_API_TOKEN}`, e.g. from Python: |
| 99 | + |
| 100 | +```python |
| 101 | +import os |
| 102 | +import requests |
| 103 | + |
| 104 | +hub_token = os.environ["JUPYTERHUB_API_TOKEN"] |
| 105 | +hub_api_url = os.environ["JUPYTERHUB_API_URL"] |
| 106 | +user_url = hub_api_url + "/user" |
| 107 | + |
| 108 | +r = requests.get(user_url, headers={"Authorization": f"Bearer {hub_token}"}) |
| 109 | +user = r.json() |
| 110 | +access_token = user["auth_state"]["access_token"] |
| 111 | +``` |
| 112 | + |
| 113 | +The `access_token` retrieved here should always be a fresh, valid access token, |
| 114 | +and will be updated by the `refresh_user` functionality when it expires. |
| 115 | + |
| 116 | +```{note} |
| 117 | +If you get a KeyError on `auth_state`, it means the request does not have the `admin:auth_state!user` permission. |
| 118 | +Check your `load_roles` config, relaunch the user server, and try again. |
| 119 | +``` |
| 120 | + |
| 121 | +## Disabling refresh |
| 122 | + |
| 123 | +The time-based refresh_user trigger is enabled by default in JupyterHub if `auth_state` is enabled. |
| 124 | +It can be disabled by setting: |
| 125 | + |
| 126 | +```python |
| 127 | +c.Authenticator.auth_refresh_age = 0 |
| 128 | +``` |
| 129 | + |
| 130 | +in which case the new `refresh_user` method will not be called. |
| 131 | +This is equivalent to the behavior of OAuthenticator 17.1 and earlier, |
| 132 | +where the default `refresh_user` was called, but did nothing. |
0 commit comments