Accessing JupyterHub service programmatically

Hi, I have a JupyterHub service and I can access it from the web browser. However, I would like to also access its API from a JupyterLab server extension.

I tried using JUPYTERHUB_API_TOKEN to make API requests, but I get a 403 error. Most of the time it complains that I don’t have the correct scope. The service uses HubOAuthenticated.

When I get a token manually via the GUI, I can access the API of my service. What I think is going on is that the backend token does not have the correct scopes. I have tried to add scopes to the “server” role, but it didn’t work:

c.JupyterHub.load_roles = [
    {
        "name": "user",
        "description": "Allow users to access the grading-service",
        "scopes": [
            "self", "access:services!service=grading-service", "access:services",
        ],
    },
    # The token we have in the single-user servers should be able to access the service:
    {
        "name": "server",
        "scopes": ["access:services!service=grading-service", "inherit"]
    }
]

When playing around I sometimes get an “‘_xsrf’ argument missing from POST” error, I think it is trying to do an OAuth flow but that doesn’t work because I am not in the browser. I also tried to change the API to use HubAuthenticated (without O), but that didn’t help.

Is there a way to access the API from the backend, without involving the browser? Or is there any better way of communicating between extensions and services?

The ‘xsrf missing from POST’ is likely a red herring, and I think can happen as a fallback when there are no credentials passed.

Can you share how you are passing the token, and how your service authenticates requests?

Can you share the output of GET /hub/api/user with the token you are trying to use? that should show you the scopes the server token has.

Hi minrk,

thanks, it works now, your questions led me down the right path! I deleted the tokens manually and restarted the single-user server, just to be sure to have a fresh token.

One thing that is missing is that I don’t know how to get the base URL from inside the extension. I can get JUPYTERHUB_API_URL which is http://127.0.0.1:15001/hub/api, but you can’t access the service from there. Right now I’m hardcoding the external address https://example.com/services/... or the internal unproxied address. Is there any way to get either of these from code? I just have a ServerApp instance which doesn’t seem to have the external address.

In case anybody has the same problem in future, some notes (click to expand)

I’m passing the token by setting the following header:

headers = {
    'Authorization': 'token %s' % os.environ['JUPYTERHUB_API_TOKEN']
}
url = os.environ['JUPYTERHUB_API_URL'] + '/user'
# or
# url = 'https://example.com/services/grading-service/endpoint'
r = requests.get(url, headers=headers)

The service inherits from HubOAuthentication for authentication, as described here.

The neccessary addition to the JupyterHub configuration was

c.JupyterHub.load_roles = [
    # ...
    # The token we have in the single-user servers should be able to access the service:
    {
        "name": "server",
        "scopes": ["access:services!service=grading-service" ]
    }
]

/hub/api/user before:

{
  "kind": "user",
  "groups": [],
  "last_activity": "2023-06-15T16:22:28.505672Z",
  "admin": false,
  "name": "d177890a8ec87f974c11b3ff2bdd6940",
  "session_id": null,
  "scopes": [
    "access:servers!server=d177890a8ec87f974c11b3ff2bdd6940/",
    "read:users:activity!user=d177890a8ec87f974c11b3ff2bdd6940",
    "read:users:groups!user=d177890a8ec87f974c11b3ff2bdd6940",
    "read:users:name!user=d177890a8ec87f974c11b3ff2bdd6940",
    "users:activity!user=d177890a8ec87f974c11b3ff2bdd6940"
  ]
}

and after (see the first scope):

{
  "admin": false,
  "groups": [],
  "name": "d177890a8ec87f974c11b3ff2bdd6940",
  "kind": "user",
  "last_activity": "2023-06-15T16:33:04.939743Z",
  "session_id": null,
  "scopes": [
    "access:services!service=grading-service",
    "read:users:activity!user=d177890a8ec87f974c11b3ff2bdd6940",
    "read:users:groups!user=d177890a8ec87f974c11b3ff2bdd6940",
    "read:users:name!user=d177890a8ec87f974c11b3ff2bdd6940",
    "users:activity!user=d177890a8ec87f974c11b3ff2bdd6940"
  ]
}

(I’m not sure why access:servers!server=d177890a8ec87f974c11b3ff2bdd6940/ is missing now but one problem at a time…)

there isn’t a public API for this, in part because JupyterHub often doesn’t know its own public URL (it’s not required for JupyterHub to function, except when using subdomains for users). For now, you’d have to pass it to your service yourself via configuration, if you can’t get it from an incoming request. I’m exploring making it public, but it would still need to come explcitly from the user.

Is there a way to access the API from the backend, without involving the browser

Yes, this is the standard way to communicate with the JupyterHub API from an extension, and the API JUPYTERHUB_API_URL should generally be accessible from the extension.