Repository: jupyterhub/jupyterhub

Issue:
'_xsrf' argument missing from POST while calling INFO - GET METHOD REST API
### Bug description

Hi Team, 

I have set up JupyterHub using the below latest _docker_ image command:

`docker run -p 8000:8000 -d --name jupyterhub jupyterhub/jupyterhub jupyterhub
`
While calling the get Info GET method API using the Postman tool getting the below error:

**API Endpoint**: http://localhost:8000/hub/api/info

**Error Response:** 

```
{
    "status": 403,
    "message": "'_xsrf' argument missing from POST"
} 
```

**Additional Logs:** 

2023-06-23 12:24:21 [W 2023-06-23 06:54:21.430 JupyterHub web:1869] 403 GET /hub/api/info (::ffff:172.17.0.1): '_xsrf' argument missing from POST

Can you please let me know which token is required here and how to call the other API in the case of GET and POST API methods?

Please let me know if you need more information.



Discussion:
Thank you for opening your first issue in this project! Engagement like this is essential for open source projects! :hugs:
<br>If you haven't done so already, check out [Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md). Also, please try to follow the issue template as it helps other other community members to contribute more effectively.
![welcome](https://raw.githubusercontent.com/jupyterhub/.github/master/images/welcome.jpg)
You can meet the other [Jovyans](https://jupyter.readthedocs.io/en/latest/community/content-community.html?highlight=jovyan#what-is-a-jovyan) by joining our [Discourse forum](http://discourse.jupyter.org/). There is also an intro thread there where you can stop by and say Hi! :wave:
<br>Welcome to the Jupyter community! :tada:

Closing as a question rather than a bug, I'll respond in https://discourse.jupyter.org/t/xsrf-argument-missing-from-post-while-calling-info-get-method-rest-api/20083
Hmmm, reopening because I think one thing is off at least in jupyterhub's behavior.

Making a GET request shouldn't error with a message like `{"status": 403, "message": "'_xsrf' argument missing from POST"}`, but perhaps like `{"status": 403, "message": "'_xsrf' argument missing from query parameters"}` or more directly like `{"status": 403, "message": "JupyterHub API token not provided"}`
> Can you please let me know which token is required here and how to call the other API in the case of GET and POST API methods?

When accessing the jupyterhub REST API, you need to authorize your requests. I think you need to use an API token passed in the header, like described in https://jupyterhub.readthedocs.io/en/stable/howto/rest.html.
Yes, I think the error message should definitely be better when credentials are missing, because the xsrf note sends people in completely the wrong direction.

Resolving patch:
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -89,6 +89,11 @@ def check_xsrf_cookie(self):
         if not hasattr(self, '_jupyterhub_user'):
             # called too early to check if we're token-authenticated
             return
+        if self._jupyterhub_user is None and 'Origin' not in self.request.headers:
+            # don't raise xsrf if auth failed
+            # don't apply this shortcut to actual cross-site requests, which have an 'Origin' header,
+            # which would reveal if there are credentials present
+            return
         if getattr(self, '_token_authenticated', False):
             # if token-authenticated, ignore XSRF
             return
diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py
--- a/jupyterhub/handlers/base.py
+++ b/jupyterhub/handlers/base.py
@@ -236,11 +236,13 @@ def set_default_headers(self):
     def check_xsrf_cookie(self):
         try:
             return super().check_xsrf_cookie()
-        except Exception as e:
-            # ensure _juptyerhub_user is defined on rejected requests
+        except web.HTTPError as e:
+            # ensure _jupyterhub_user is defined on rejected requests
             if not hasattr(self, "_jupyterhub_user"):
                 self._jupyterhub_user = None
             self._resolve_roles_and_scopes()
+            # rewrite message because we use this on methods other than POST
+            e.log_message = e.log_message.replace("POST", self.request.method)
             raise
 
     @property
diff --git a/jupyterhub/scopes.py b/jupyterhub/scopes.py
--- a/jupyterhub/scopes.py
+++ b/jupyterhub/scopes.py
@@ -845,6 +845,15 @@ def needs_scope(*scopes):
     def scope_decorator(func):
         @functools.wraps(func)
         def _auth_func(self, *args, **kwargs):
+            if not self.current_user:
+                # not authenticated at all, fail with more generic message
+                # this is the most likely permission error - missing or mis-specified credentials,
+                # don't indicate that they have insufficient permissions.
+                raise web.HTTPError(
+                    403,
+                    "Missing or invalid credentials.",
+                )
+
             sig = inspect.signature(func)
             bound_sig = sig.bind(self, *args, **kwargs)
             bound_sig.apply_defaults()
@@ -853,6 +862,11 @@ def _auth_func(self, *args, **kwargs):
                 self.expanded_scopes = {}
                 self.parsed_scopes = {}
 
+            try:
+                end_point = self.request.path
+            except AttributeError:
+                end_point = self.__name__
+
             s_kwargs = {}
             for resource in {'user', 'server', 'group', 'service'}:
                 resource_name = resource + '_name'
@@ -860,14 +874,10 @@ def _auth_func(self, *args, **kwargs):
                     resource_value = bound_sig.arguments[resource_name]
                     s_kwargs[resource] = resource_value
             for scope in scopes:
-                app_log.debug("Checking access via scope %s", scope)
+                app_log.debug("Checking access to %s via scope %s", end_point, scope)
                 has_access = _check_scope_access(self, scope, **s_kwargs)
                 if has_access:
                     return func(self, *args, **kwargs)
-            try:
-                end_point = self.request.path
-            except AttributeError:
-                end_point = self.__name__
             app_log.warning(
                 "Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format(
                     end_point, ", ".join(scopes), ", ".join(self.expanded_scopes)


Test patch:
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -122,6 +122,41 @@ async def test_xsrf_check(app, username, method, path, xsrf_in_url):
         assert r.status_code == 403
 
 
+@mark.parametrize(
+    "auth, expected_message",
+    [
+        ("", "Missing or invalid credentials"),
+        ("cookie_no_xsrf", "'_xsrf' argument missing from GET"),
+        ("cookie_xsrf_mismatch", "XSRF cookie does not match GET argument"),
+        ("token_no_scope", "requires any of [list:users]"),
+        ("cookie_no_scope", "requires any of [list:users]"),
+    ],
+)
+async def test_permission_error_messages(app, user, auth, expected_message):
+    # 1. no credentials, should be 403 and not mention xsrf
+
+    url = public_url(app, path="hub/api/users")
+
+    kwargs = {}
+    kwargs["headers"] = headers = {}
+    kwargs["params"] = params = {}
+    if auth == "token_no_scope":
+        token = user.new_api_token()
+        headers["Authorization"] = f"Bearer {token}"
+    elif "cookie" in auth:
+        cookies = kwargs["cookies"] = await app.login_user(user.name)
+        if auth == "cookie_no_scope":
+            params["_xsrf"] = cookies["_xsrf"]
+        if auth == "cookie_xsrf_mismatch":
+            params["_xsrf"] = "somethingelse"
+
+    r = await async_requests.get(url, **kwargs)
+    assert r.status_code == 403
+    response = r.json()
+    message = response["message"]
+    assert expected_message in message
+
+
 # --------------
 # User API tests
 # --------------