Repository: jupyterhub/jupyterhub

Issue:
Metrics endpoint doesn't accept token request anymore
### Bug description
Prior to version 2 the hub/metrics endpoint could be accessed via token and this was used e.g. to scrape the metrics of a protected jupyterhub instance via prometheus. 
Since the latest update the metrics endpoint does not allow token based access anymore (see https://github.com/jupyterhub/jupyterhub/pull/3686)

Thanks to @minrk for pointing this out. 

Suggestion: Mention at e.g. the metrics configuration that its a known problem and a workaround is available:

```
from jupyterhub.handlers.metrics import MetricsHandler
MetricsHandler._accept_token_auth = True
```

#### Expected behaviour
Token auth works

#### Actual behaviour
Jupyterhub returns 403 forbidden

### How to reproduce

Request the hub/metrics endpoint with token authorization

### Your personal set up

 - OS: CentOS Stream 9
 - Version(s): jupyterhub 2.0.2

</details>

- <details><summary>Configuration</summary>

Not relevant - PR that introduced the problem was mentioned previously.

</details>

- <details><summary>Logs</summary>

```
WARNING:JupyterHub:403 GET /jupyterhub-dev/hub/metrics (@10.11.193.72) 1.86ms
[D 2022-01-18 13:18:12.616 JupyterHub base:1322] No template for 403
```
</details>



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:


Resolving patch:
diff --git a/jupyterhub/handlers/metrics.py b/jupyterhub/handlers/metrics.py
--- a/jupyterhub/handlers/metrics.py
+++ b/jupyterhub/handlers/metrics.py
@@ -12,6 +12,8 @@ class MetricsHandler(BaseHandler):
     Handler to serve Prometheus metrics
     """
 
+    _accept_token_auth = True
+
     @metrics_authentication
     async def get(self):
         self.set_header('Content-Type', CONTENT_TYPE_LATEST)
diff --git a/jupyterhub/scopes.py b/jupyterhub/scopes.py
--- a/jupyterhub/scopes.py
+++ b/jupyterhub/scopes.py
@@ -131,6 +131,9 @@
         'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
     },
     'shutdown': {'description': 'Shutdown the hub.'},
+    'read:metrics': {
+        'description': "Read prometheus metrics.",
+    },
 }
 
 
diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py
--- a/jupyterhub/utils.py
+++ b/jupyterhub/utils.py
@@ -320,9 +320,11 @@ def admin_only(self):
 @auth_decorator
 def metrics_authentication(self):
     """Decorator for restricting access to metrics"""
-    user = self.current_user
-    if user is None and self.authenticate_prometheus:
-        raise web.HTTPError(403)
+    if not self.authenticate_prometheus:
+        return
+    scope = 'read:metrics'
+    if scope not in self.parsed_scopes:
+        raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'")
 
 
 # Token utilities


Test patch:
diff --git a/jupyterhub/tests/test_metrics.py b/jupyterhub/tests/test_metrics.py
--- a/jupyterhub/tests/test_metrics.py
+++ b/jupyterhub/tests/test_metrics.py
@@ -1,9 +1,13 @@
 import json
+from unittest import mock
+
+import pytest
 
-from .utils import add_user
 from .utils import api_request
+from .utils import get_page
 from jupyterhub import metrics
 from jupyterhub import orm
+from jupyterhub import roles
 
 
 async def test_total_users(app):
@@ -32,3 +36,42 @@ async def test_total_users(app):
 
     sample = metrics.TOTAL_USERS.collect()[0].samples[0]
     assert sample.value == num_users
+
+
+@pytest.mark.parametrize(
+    "authenticate_prometheus, authenticated, authorized, success",
+    [
+        (True, True, True, True),
+        (True, True, False, False),
+        (True, False, False, False),
+        (False, True, True, True),
+        (False, False, False, True),
+    ],
+)
+async def test_metrics_auth(
+    app,
+    authenticate_prometheus,
+    authenticated,
+    authorized,
+    success,
+    create_temp_role,
+    user,
+):
+    if authorized:
+        role = create_temp_role(["read:metrics"])
+        roles.grant_role(app.db, user, role)
+
+    headers = {}
+    if authenticated:
+        token = user.new_api_token()
+        headers["Authorization"] = f"token {token}"
+
+    with mock.patch.dict(
+        app.tornado_settings, {"authenticate_prometheus": authenticate_prometheus}
+    ):
+        r = await get_page("metrics", app, headers=headers)
+    if success:
+        assert r.status_code == 200
+    else:
+        assert r.status_code == 403
+        assert 'read:metrics' in r.text
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -1110,19 +1110,6 @@ async def test_server_not_running_api_request_legacy_status(app):
     assert r.status_code == 503
 
 
-async def test_metrics_no_auth(app):
-    r = await get_page("metrics", app)
-    assert r.status_code == 403
-
-
-async def test_metrics_auth(app):
-    cookies = await app.login_user('river')
-    metrics_url = ujoin(public_host(app), app.hub.base_url, 'metrics')
-    r = await get_page("metrics", app, cookies=cookies)
-    assert r.status_code == 200
-    assert r.url == metrics_url
-
-
 async def test_health_check_request(app):
     r = await get_page('health', app)
     assert r.status_code == 200