Repository: huggingface/dataset-viewer

Issue:
Return an error when the JWT is not valid
Currently, we silently ignore errors in the JWT and try other authentication mechanisms.

Instead, we should return an error when the JWT is not valid. It will help trigger a JWT renewal, in particular.

We should give the reason as the error_code (passed in the X-Error-Code header) to be able to discriminate between a key error, an algorithm error, or an expiration error, among others


Discussion:

Resolving patch:
diff --git a/libs/libapi/src/libapi/authentication.py b/libs/libapi/src/libapi/authentication.py
--- a/libs/libapi/src/libapi/authentication.py
+++ b/libs/libapi/src/libapi/authentication.py
@@ -15,7 +15,7 @@
     ExternalAuthenticatedError,
     ExternalUnauthenticatedError,
 )
-from libapi.jwt_token import is_jwt_valid
+from libapi.jwt_token import validate_jwt
 
 
 class RequestAuth(AuthBase):
@@ -38,6 +38,19 @@ def __call__(self, r: PreparedRequest) -> PreparedRequest:
         return r
 
 
+def get_jwt_token(request: Optional[Request] = None) -> Optional[str]:
+    if not request:
+        return None
+    # x-api-token is deprecated and will be removed in the future
+    if token := request.headers.get("x-api-key"):
+        return token
+    authorization = request.headers.get("authorization")
+    if not authorization:
+        return None
+    token = authorization.removeprefix("Bearer jwt:")
+    return None if token == authorization else token
+
+
 def auth_check(
     dataset: str,
     external_auth_url: Optional[str] = None,
@@ -69,30 +82,17 @@ def auth_check(
         None: the dataset is authorized for the request
     """
     with StepProfiler(method="auth_check", step="all"):
-        with StepProfiler(method="auth_check", step="prepare parameters"):
-            if request:
+        with StepProfiler(method="auth_check", step="check JWT"):
+            if (jwt_token := get_jwt_token(request)) and hf_jwt_public_key and hf_jwt_algorithm:
+                validate_jwt(
+                    dataset=dataset, token=jwt_token, public_key=hf_jwt_public_key, algorithm=hf_jwt_algorithm
+                )
                 logging.debug(
-                    f"Looking if jwt is passed in request headers {request.headers} to bypass authentication."
+                    "By-passing the authentication step, because a valid JWT was passed in headers"
+                    f" for dataset {dataset}. JWT was: {jwt_token}"
                 )
-                HEADER = "x-api-key"
-                if token := request.headers.get(HEADER):
-                    if is_jwt_valid(
-                        dataset=dataset, token=token, public_key=hf_jwt_public_key, algorithm=hf_jwt_algorithm
-                    ):
-                        logging.debug(
-                            f"By-passing the authentication step, because a valid JWT was passed in header: '{HEADER}'"
-                            f" for dataset {dataset}. JWT was: {token}"
-                        )
-                        return True
-                    logging.debug(
-                        f"Error while validating the JWT passed in header: '{HEADER}' for dataset {dataset}. Trying"
-                        f" with the following authentication mechanisms. JWT was: {token}"
-                    )
-                else:
-                    logging.debug(
-                        f"No JWT was passed in header: '{HEADER}' for dataset {dataset}. Trying with the following"
-                        " authentication mechanisms."
-                    )
+                return True
+        with StepProfiler(method="auth_check", step="prepare parameters"):
             if external_auth_url is None:
                 return True
             try:
@@ -128,7 +128,7 @@ def auth_check(
                 "The dataset does not exist, or is not accessible without authentication (private or gated). Please"
                 " check the spelling of the dataset name or retry with authentication."
             )
-        elif response.status_code in [403, 404]:
+        elif response.status_code in {403, 404}:
             raise ExternalAuthenticatedError(
                 "The dataset does not exist, or is not accessible with the current credentials (private or gated)."
                 " Please check the spelling of the dataset name or retry with other authentication credentials."
diff --git a/libs/libapi/src/libapi/exceptions.py b/libs/libapi/src/libapi/exceptions.py
--- a/libs/libapi/src/libapi/exceptions.py
+++ b/libs/libapi/src/libapi/exceptions.py
@@ -13,6 +13,12 @@
     "ExternalUnauthenticatedError",
     "InvalidParameter",
     "JWKError",
+    "JWTExpiredSignature",
+    "JWTInvalidClaimRead",
+    "JWTInvalidClaimSub",
+    "JWTInvalidKeyOrAlgorithm",
+    "JWTInvalidSignature",
+    "JWTMissingRequiredClaim",
     "MissingProcessingStepsError",
     "MissingRequiredParameter",
     "ResponseNotFound",
@@ -39,7 +45,7 @@ def __init__(
 
 
 class AuthCheckHubRequestError(ApiError):
-    """Raised when the external authentication check failed or timed out."""
+    """The external authentication check failed or timed out."""
 
     def __init__(self, message: str, cause: Optional[BaseException] = None):
         super().__init__(
@@ -48,66 +54,111 @@ def __init__(self, message: str, cause: Optional[BaseException] = None):
 
 
 class ExternalAuthenticatedError(ApiError):
-    """Raised when the external authentication check failed while the user was authenticated.
+    """The external authentication check failed while the user was authenticated.
 
     Even if the external authentication server returns 403 in that case, we return 404 because
-    we don't know if the dataset exist or not. It's also coherent with how the Hugging Face Hub works."""
+    we don't know if the dataset exist or not. It's also coherent with how the Hugging Face Hub works.
+
+    TODO: should we return DatasetNotFoundError instead? maybe the error code is leaking existence of private datasets.
+    """
 
     def __init__(self, message: str):
         super().__init__(message, HTTPStatus.NOT_FOUND, "ExternalAuthenticatedError")
 
 
 class ExternalUnauthenticatedError(ApiError):
-    """Raised when the external authentication check failed while the user was unauthenticated."""
+    """The external authentication check failed while the user was unauthenticated."""
 
     def __init__(self, message: str):
         super().__init__(message, HTTPStatus.UNAUTHORIZED, "ExternalUnauthenticatedError")
 
 
 class InvalidParameterError(ApiError):
-    """Raised when a parameter has an invalid value."""
+    """A parameter has an invalid value."""
 
     def __init__(self, message: str):
         super().__init__(message, HTTPStatus.UNPROCESSABLE_ENTITY, "InvalidParameter")
 
 
 class JWKError(ApiError):
-    """Raised when the JWT key (JWK) could not be fetched or parsed."""
+    """The JWT key (JWK) could not be fetched or parsed."""
 
     def __init__(self, message: str, cause: Optional[BaseException] = None):
         super().__init__(message, HTTPStatus.INTERNAL_SERVER_ERROR, "JWKError", cause=cause, disclose_cause=False)
 
 
 class MissingRequiredParameterError(ApiError):
-    """Raised when a required parameter is missing."""
+    """A required parameter is missing."""
 
     def __init__(self, message: str):
         super().__init__(message, HTTPStatus.UNPROCESSABLE_ENTITY, "MissingRequiredParameter")
 
 
 class ResponseNotFoundError(ApiError):
-    """Raised when the response has not been found."""
+    """The response has not been found."""
 
     def __init__(self, message: str):
         super().__init__(message, HTTPStatus.NOT_FOUND, "ResponseNotFound")
 
 
 class ResponseNotReadyError(ApiError):
-    """Raised when the response has not been processed yet."""
+    """The response has not been processed yet."""
 
     def __init__(self, message: str):
         super().__init__(message, HTTPStatus.INTERNAL_SERVER_ERROR, "ResponseNotReady")
 
 
 class TransformRowsProcessingError(ApiError):
-    """Raised when there was an error when transforming rows to list."""
+    """There was an error when transforming rows to list."""
 
     def __init__(self, message: str, cause: Optional[BaseException] = None):
         super().__init__(message, HTTPStatus.INTERNAL_SERVER_ERROR, "TransformRowsProcessingError", cause, True)
 
 
+class JWTExpiredSignature(ApiError):
+    """The JWT signature has expired."""
+
+    def __init__(self, message: str, cause: Optional[BaseException] = None):
+        super().__init__(message, HTTPStatus.UNAUTHORIZED, "JWTExpiredSignature", cause, True)
+
+
+class JWTInvalidClaimRead(ApiError):
+    """The 'read' claim in the JWT payload is invalid."""
+
+    def __init__(self, message: str, cause: Optional[BaseException] = None):
+        super().__init__(message, HTTPStatus.UNAUTHORIZED, "JWTInvalidClaimRead", cause, True)
+
+
+class JWTInvalidClaimSub(ApiError):
+    """The 'sub' claim in the JWT payload is invalid."""
+
+    def __init__(self, message: str, cause: Optional[BaseException] = None):
+        super().__init__(message, HTTPStatus.UNAUTHORIZED, "JWTInvalidClaimSub", cause, True)
+
+
+class JWTInvalidKeyOrAlgorithm(ApiError):
+    """The key and the algorithm used to verify the JWT signature are not compatible."""
+
+    def __init__(self, message: str, cause: Optional[BaseException] = None):
+        super().__init__(message, HTTPStatus.UNAUTHORIZED, "JWTInvalidKeyOrAlgorithm", cause, True)
+
+
+class JWTInvalidSignature(ApiError):
+    """The JWT signature verification failed."""
+
+    def __init__(self, message: str, cause: Optional[BaseException] = None):
+        super().__init__(message, HTTPStatus.UNAUTHORIZED, "JWTInvalidSignature", cause, True)
+
+
+class JWTMissingRequiredClaim(ApiError):
+    """A claim is missing in the JWT payload."""
+
+    def __init__(self, message: str, cause: Optional[BaseException] = None):
+        super().__init__(message, HTTPStatus.UNAUTHORIZED, "JWTMissingRequiredClaim", cause, True)
+
+
 class UnexpectedApiError(ApiError):
-    """Raised when the server raised an unexpected error."""
+    """The server raised an unexpected error."""
 
     def __init__(self, message: str, cause: Optional[BaseException] = None):
         logging.error(message, exc_info=cause)
diff --git a/libs/libapi/src/libapi/jwt_token.py b/libs/libapi/src/libapi/jwt_token.py
--- a/libs/libapi/src/libapi/jwt_token.py
+++ b/libs/libapi/src/libapi/jwt_token.py
@@ -28,7 +28,16 @@
     RSAPSSAlgorithm,
 )
 
-from libapi.exceptions import JWKError
+from libapi.exceptions import (
+    JWKError,
+    JWTExpiredSignature,
+    JWTInvalidClaimRead,
+    JWTInvalidClaimSub,
+    JWTInvalidKeyOrAlgorithm,
+    JWTInvalidSignature,
+    JWTMissingRequiredClaim,
+    UnexpectedApiError,
+)
 
 ASYMMETRIC_ALGORITHMS = (ECAlgorithm, OKPAlgorithm, RSAAlgorithm, RSAPSSAlgorithm)
 SYMMETRIC_ALGORITHMS = (HMACAlgorithm,)
@@ -113,34 +122,25 @@ def fetch_jwt_public_key(
         raise JWKError(f"Failed to fetch or parse the JWT public key from {url}. ", cause=err) from err
 
 
-def is_jwt_valid(
-    dataset: str, token: Any, public_key: Optional[str], algorithm: Optional[str], verify_exp: Optional[bool] = True
-) -> bool:
+def validate_jwt(dataset: str, token: Any, public_key: str, algorithm: str, verify_exp: Optional[bool] = True) -> None:
     """
     Check if the JWT is valid for the dataset.
 
-    The JWT is decoded with the public key, and the "sub" claim must be:
-      {"repoName": <...>, "repoType": "dataset", "read": true}
-    where <...> is the dataset identifier.
+    The JWT is decoded with the public key, and the payload must be:
+      {"sub": "datasets/<...dataset identifier...>", "read": true, "exp": <...date...>}
 
-    Returns True only if all the conditions are met. Else, it returns False.
+    Raise an exception if any of the condition is not met.
 
     Args:
         dataset (str): the dataset identifier
         token (Any): the JWT token to decode
-        public_key (str|None): the public key to use to decode the JWT token
-        algorithm (str|None): the algorithm to use to decode the JWT token
+        public_key (str): the public key to use to decode the JWT token
+        algorithm (str): the algorithm to use to decode the JWT token
         verify_exp (bool|None): whether to verify the expiration of the JWT token. Default to True.
 
-    Returns:
-        bool: True if the JWT is valid for the input dataset, else False
+    Raise:
+
     """
-    if not public_key or not algorithm:
-        logging.debug(
-            f"Missing public key '{public_key}' or algorithm '{algorithm}' to decode JWT token. Skipping JWT"
-            " validation."
-        )
-        return False
     try:
         decoded = jwt.decode(
             jwt=token,
@@ -149,14 +149,34 @@ def is_jwt_valid(
             options={"require": ["exp", "sub", "read"], "verify_exp": verify_exp},
         )
         logging.debug(f"Decoded JWT is: '{public_key}'.")
-    except Exception:
+    except jwt.exceptions.MissingRequiredClaimError as e:
+        raise JWTMissingRequiredClaim("A claim is missing in the JWT payload.", e) from e
+    except jwt.exceptions.ExpiredSignatureError as e:
+        raise JWTExpiredSignature("The JWT signature has expired. Try to refresh the token.", e) from e
+    except jwt.exceptions.InvalidSignatureError as e:
+        raise JWTInvalidSignature(
+            "The JWT signature verification failed. Check the signing key and the algorithm.", e
+        ) from e
+    except (jwt.exceptions.InvalidKeyError, jwt.exceptions.InvalidAlgorithmError) as e:
+        raise JWTInvalidKeyOrAlgorithm(
+            (
+                "The key used to verify the signature is not compatible with the algorithm. Check the signing key and"
+                " the algorithm."
+            ),
+            e,
+        ) from e
+    except Exception as e:
         logging.debug(
             f"Missing public key '{public_key}' or algorithm '{algorithm}' to decode JWT token. Skipping JWT"
             " validation."
         )
-        return False
+        raise UnexpectedApiError("An error has occurred while decoding the JWT.", e) from e
     sub = decoded.get("sub")
     if not isinstance(sub, str) or not sub.startswith("datasets/") or sub.removeprefix("datasets/") != dataset:
-        return False
+        raise JWTInvalidClaimSub(
+            "The 'sub' claim in JWT payload is invalid. It should be in the form 'datasets/<...dataset"
+            " identifier...>'."
+        )
     read = decoded.get("read")
-    return read is True
+    if read is not True:
+        raise JWTInvalidClaimRead("The 'read' claim in JWT payload is invalid. It should be set to 'true'.")


Test patch:
diff --git a/libs/libapi/tests/test_authentication.py b/libs/libapi/tests/test_authentication.py
--- a/libs/libapi/tests/test_authentication.py
+++ b/libs/libapi/tests/test_authentication.py
@@ -4,7 +4,7 @@
 import datetime
 import time
 from contextlib import nullcontext as does_not_raise
-from typing import Any, Dict, Mapping, Optional
+from typing import Any, Mapping, Optional
 
 import jwt
 import pytest
@@ -35,38 +35,72 @@
 MFswDQYJKoZIhvcNAQEBBQADSgAwRwJAZTmplhS/Jd73ycVut7TglMObheQqXM7R
 ZYlwazLU4wpfIVIwOh9IsCZGSgLyFq42KWIikKLEs/yqx3pRGfq+rwIDAQAB
 -----END PUBLIC KEY-----"""
-
-dataset_ok = "dataset"
 exp_ok = datetime.datetime.now().timestamp() + 1000
 read_ok = True
-sub_ok = f"datasets/{dataset_ok}"
-payload_ok = {"sub": sub_ok, "read": read_ok, "exp": exp_ok}
 algorithm_rs256 = "RS256"
 
+dataset_public = "dataset_public"
+dataset_protected_with_access = "dataset_protected_with_access"
+dataset_protected_without_access = "dataset_protected_without_access"
+dataset_inexistent = "dataset_inexistent"
+dataset_throttled = "dataset_throttled"
+
+cookie_ok = "cookie ok"
+cookie_wrong = "cookie wrong"
+api_token_ok = "api token ok"
+api_token_wrong = "api token wrong"
+
 
 def auth_callback(request: WerkzeugRequest) -> WerkzeugResponse:
-    # return 401 if a cookie has been provided, 404 if a token has been provided,
-    # and 200 if none has been provided
-    #
-    # caveat: the returned status codes don't simulate the reality
-    # they're just used to check every case
-    return WerkzeugResponse(
-        status=401 if request.headers.get("cookie") else 404 if request.headers.get("authorization") else 200
-    )
+    """Simulates the https://huggingface.co/api/datasets/%s/auth-check Hub API endpoint.
+
+    It returns:
+    - 200: if the user can access the dataset
+    - 401: if the user is not authenticated
+    - 403: if the user is authenticated but can't access the dataset
+    - 404: if the user is authenticated but the dataset doesn't exist
+    - 429: if the user is authenticated but the request is throttled
 
+    Args:
+        request (WerkzeugRequest): the request sent to the endpoint
 
-def test_no_auth_check() -> None:
-    assert auth_check("dataset")
+    Returns:
+        WerkzeugResponse: the response sent by the endpoint
+    """
+    dataset = request.path.split("/")[-2]
+    if dataset == dataset_public:
+        # a public dataset always has read access
+        return WerkzeugResponse(status=200)
+    if request.headers.get("cookie") != cookie_ok and request.headers.get("authorization") != f"Bearer {api_token_ok}":
+        # the user is not authenticated
+        return WerkzeugResponse(status=401)
+    if dataset == dataset_protected_with_access:
+        # the user is authenticated and has access to the dataset
+        return WerkzeugResponse(status=200)
+    if dataset == dataset_protected_without_access:
+        # the user is authenticated but doesn't have access to the dataset
+        return WerkzeugResponse(status=403)
+    if dataset == dataset_inexistent:
+        # the user is authenticated but the dataset doesn't exist
+        return WerkzeugResponse(status=404)
+    if dataset == dataset_throttled:
+        # the user is authenticated but the request is throttled (too many requests)
+        return WerkzeugResponse(status=429)
+    raise RuntimeError(f"Unexpected dataset: {dataset}")
 
 
-def test_invalid_auth_check_url() -> None:
+def test_no_external_auth_check() -> None:
+    assert auth_check(dataset_public)
+
+
+def test_invalid_external_auth_check_url() -> None:
     with pytest.raises(ValueError):
-        auth_check("dataset", external_auth_url="https://auth.check/")
+        auth_check(dataset_public, external_auth_url="https://doesnotexist/")
 
 
 def test_unreachable_external_auth_check_service() -> None:
     with pytest.raises(AuthCheckHubRequestError):
-        auth_check("dataset", external_auth_url="https://auth.check/%s")
+        auth_check(dataset_public, external_auth_url="https://doesnotexist/%s")
 
 
 @pytest.mark.parametrize(
@@ -138,22 +172,20 @@ def create_request(headers: Mapping[str, str]) -> Request:
     )
 
 
-@pytest.mark.parametrize(
-    "headers,expectation",
-    [
-        ({"Cookie": "some cookie"}, pytest.raises(ExternalUnauthenticatedError)),
-        ({"Authorization": "Bearer invalid"}, pytest.raises(ExternalAuthenticatedError)),
-        ({}, does_not_raise()),
-    ],
-)
-def test_valid_responses_with_request(
+def get_jwt(dataset: str) -> str:
+    return jwt.encode(
+        {"sub": f"datasets/{dataset}", "read": read_ok, "exp": exp_ok}, private_key, algorithm=algorithm_rs256
+    )
+
+
+def assert_auth_headers(
     httpserver: HTTPServer,
     hf_endpoint: str,
     hf_auth_path: str,
+    dataset: str,
     headers: Mapping[str, str],
     expectation: Any,
 ) -> None:
-    dataset = "dataset"
     external_auth_url = hf_endpoint + hf_auth_path
     httpserver.expect_request(hf_auth_path % dataset).respond_with_handler(auth_callback)
     with expectation:
@@ -161,41 +193,121 @@ def test_valid_responses_with_request(
             dataset,
             external_auth_url=external_auth_url,
             request=create_request(headers=headers),
+            hf_jwt_public_key=public_key,
+            hf_jwt_algorithm=algorithm_rs256,
         )
 
 
-def raise_value_error(request: WerkzeugRequest) -> WerkzeugResponse:
-    return WerkzeugResponse(status=500)  # <- will raise ValueError in auth_check
+@pytest.mark.parametrize(
+    "headers,expectation",
+    [
+        ({}, does_not_raise()),
+        ({"Authorization": f"Bearer {api_token_wrong}"}, does_not_raise()),
+        ({"Authorization": api_token_ok}, does_not_raise()),
+        ({"Cookie": cookie_wrong}, does_not_raise()),
+        ({"Authorization": f"Bearer {api_token_ok}"}, does_not_raise()),
+        ({"Cookie": cookie_ok}, does_not_raise()),
+        ({"X-Api-Key": get_jwt(dataset_public)}, does_not_raise()),
+        ({"Authorization": f"Bearer {get_jwt(dataset_public)}"}, does_not_raise()),
+    ],
+)
+def test_external_auth_service_dataset_public(
+    httpserver: HTTPServer,
+    hf_endpoint: str,
+    hf_auth_path: str,
+    headers: Mapping[str, str],
+    expectation: Any,
+) -> None:
+    assert_auth_headers(httpserver, hf_endpoint, hf_auth_path, dataset_public, headers, expectation)
 
 
 @pytest.mark.parametrize(
-    "hf_jwt_public_key,header,payload,expectation",
+    "headers,expectation",
     [
-        (None, None, payload_ok, pytest.raises(ValueError)),
-        (None, "X-Api-Key", payload_ok, pytest.raises(ValueError)),
-        (public_key, None, payload_ok, pytest.raises(ValueError)),
-        (public_key, "X-Api-Key", {}, pytest.raises(ValueError)),
-        (public_key, "X-Api-Key", payload_ok, does_not_raise()),
-        (public_key, "x-api-key", payload_ok, does_not_raise()),
+        ({}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": f"Bearer {api_token_wrong}"}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": api_token_ok}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Cookie": cookie_wrong}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": f"Bearer {api_token_ok}"}, does_not_raise()),
+        ({"Cookie": cookie_ok}, does_not_raise()),
+        ({"X-Api-Key": get_jwt(dataset_protected_with_access)}, does_not_raise()),
+        ({"Authorization": f"Bearer jwt:{get_jwt(dataset_protected_with_access)}"}, does_not_raise()),
     ],
 )
-def test_bypass_auth_public_key(
+def test_external_auth_service_dataset_protected_with_access(
     httpserver: HTTPServer,
     hf_endpoint: str,
     hf_auth_path: str,
-    hf_jwt_public_key: Optional[str],
-    header: Optional[str],
-    payload: Dict[str, str],
+    headers: Mapping[str, str],
     expectation: Any,
 ) -> None:
-    external_auth_url = hf_endpoint + hf_auth_path
-    httpserver.expect_request(hf_auth_path % dataset_ok).respond_with_handler(raise_value_error)
-    headers = {header: jwt.encode(payload, private_key, algorithm=algorithm_rs256)} if header else {}
-    with expectation:
-        auth_check(
-            dataset=dataset_ok,
-            external_auth_url=external_auth_url,
-            request=create_request(headers=headers),
-            hf_jwt_public_key=hf_jwt_public_key,
-            hf_jwt_algorithm=algorithm_rs256,
-        )
+    assert_auth_headers(httpserver, hf_endpoint, hf_auth_path, dataset_protected_with_access, headers, expectation)
+
+
+@pytest.mark.parametrize(
+    "headers,expectation",
+    [
+        ({}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": f"Bearer {api_token_wrong}"}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": api_token_ok}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Cookie": cookie_wrong}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": f"Bearer {api_token_ok}"}, pytest.raises(ExternalAuthenticatedError)),
+        ({"Cookie": cookie_ok}, pytest.raises(ExternalAuthenticatedError)),
+        ({"X-Api-Key": get_jwt(dataset_protected_without_access)}, does_not_raise()),
+        ({"Authorization": f"Bearer jwt:{get_jwt(dataset_protected_without_access)}"}, does_not_raise()),
+    ],
+)
+def test_external_auth_service_dataset_protected_without_access(
+    httpserver: HTTPServer,
+    hf_endpoint: str,
+    hf_auth_path: str,
+    headers: Mapping[str, str],
+    expectation: Any,
+) -> None:
+    assert_auth_headers(httpserver, hf_endpoint, hf_auth_path, dataset_protected_without_access, headers, expectation)
+
+
+@pytest.mark.parametrize(
+    "headers,expectation",
+    [
+        ({}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": f"Bearer {api_token_wrong}"}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": api_token_ok}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Cookie": cookie_wrong}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": f"Bearer {api_token_ok}"}, pytest.raises(ExternalAuthenticatedError)),
+        ({"Cookie": cookie_ok}, pytest.raises(ExternalAuthenticatedError)),
+        ({"X-Api-Key": get_jwt(dataset_inexistent)}, does_not_raise()),
+        ({"Authorization": f"Bearer jwt:{get_jwt(dataset_inexistent)}"}, does_not_raise()),
+    ],
+)
+def test_external_auth_service_dataset_inexistent(
+    httpserver: HTTPServer,
+    hf_endpoint: str,
+    hf_auth_path: str,
+    headers: Mapping[str, str],
+    expectation: Any,
+) -> None:
+    assert_auth_headers(httpserver, hf_endpoint, hf_auth_path, dataset_inexistent, headers, expectation)
+
+
+@pytest.mark.parametrize(
+    "headers,expectation",
+    [
+        ({}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": f"Bearer {api_token_wrong}"}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": api_token_ok}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Cookie": cookie_wrong}, pytest.raises(ExternalUnauthenticatedError)),
+        ({"Authorization": f"Bearer {api_token_ok}"}, pytest.raises(ValueError)),
+        ({"Cookie": cookie_ok}, pytest.raises(ValueError)),
+        ({"X-Api-Key": get_jwt(dataset_throttled)}, does_not_raise()),
+        ({"Authorization": f"Bearer jwt:{get_jwt(dataset_throttled)}"}, does_not_raise()),
+    ],
+)
+def test_external_auth_service_dataset_throttled(
+    httpserver: HTTPServer,
+    hf_endpoint: str,
+    hf_auth_path: str,
+    headers: Mapping[str, str],
+    expectation: Any,
+) -> None:
+    assert_auth_headers(httpserver, hf_endpoint, hf_auth_path, dataset_throttled, headers, expectation)
diff --git a/libs/libapi/tests/test_jwt_token.py b/libs/libapi/tests/test_jwt_token.py
--- a/libs/libapi/tests/test_jwt_token.py
+++ b/libs/libapi/tests/test_jwt_token.py
@@ -3,12 +3,20 @@
 
 import datetime
 from contextlib import nullcontext as does_not_raise
-from typing import Any, Dict, Optional
+from typing import Any, Dict
 
 import jwt
 import pytest
 
-from libapi.jwt_token import is_jwt_valid, parse_jwt_public_key
+from libapi.exceptions import (
+    JWTExpiredSignature,
+    JWTInvalidClaimRead,
+    JWTInvalidClaimSub,
+    JWTInvalidKeyOrAlgorithm,
+    JWTInvalidSignature,
+    JWTMissingRequiredClaim,
+)
+from libapi.jwt_token import parse_jwt_public_key, validate_jwt
 
 HUB_JWT_KEYS = [{"crv": "Ed25519", "x": "-RBhgyNluwaIL5KFJb6ZOL2H1nmyI8mW4Z2EHGDGCXM", "kty": "OKP"}]
 HUB_JWT_ALGORITHM = "EdDSA"
@@ -37,7 +45,7 @@
     ],
 )
 def test_parse_jwk(
-    keys: Any,
+    keys: str,
     expectation: Any,
 ) -> None:
     with expectation:
@@ -53,16 +61,13 @@ def test_parse_jwk(
 
 
 def test_is_jwt_valid_with_ec() -> None:
-    assert (
-        is_jwt_valid(
-            dataset=DATASET_SEVERO_GLUE,
-            token=HUB_JWT_TOKEN_FOR_SEVERO_GLUE,
-            public_key=HUB_JWT_PUBLIC_KEY,
-            algorithm=HUB_JWT_ALGORITHM,
-            verify_exp=False,
-            # This is a test token generated on 2023/03/14, so we don't want to verify the exp.
-        )
-        is True
+    validate_jwt(
+        dataset=DATASET_SEVERO_GLUE,
+        token=HUB_JWT_TOKEN_FOR_SEVERO_GLUE,
+        public_key=HUB_JWT_PUBLIC_KEY,
+        algorithm=HUB_JWT_ALGORITHM,
+        verify_exp=False,
+        # This is a test token generated on 2023/03/14, so we don't want to verify the exp.
     )
 
 
@@ -75,7 +80,7 @@ def test_is_jwt_valid_with_ec() -> None:
 LlwzmCXVjEw5AiA7JnAFEb9+q82T71d3q/DxD0bWvb6hz5ASoBfXK2jGBQIgbaQp
 h4Tk6UJuj1xgKNs75Pk3pG2tj8AQiuBk3l62vRU=
 -----END RSA PRIVATE KEY-----"""
-public_key = """-----BEGIN PUBLIC KEY-----
+public_key_ok = """-----BEGIN PUBLIC KEY-----
 MFswDQYJKoZIhvcNAQEBBQADSgAwRwJAZTmplhS/Jd73ycVut7TglMObheQqXM7R
 ZYlwazLU4wpfIVIwOh9IsCZGSgLyFq42KWIikKLEs/yqx3pRGfq+rwIDAQAB
 -----END PUBLIC KEY-----"""
@@ -99,27 +104,94 @@ def test_is_jwt_valid_with_ec() -> None:
 read_wrong_1 = False
 read_wrong_2 = "True"
 payload_ok = {"sub": sub_ok, "read": read_ok, "exp": exp_ok}
-algorithm_rs256 = "RS256"
+algorithm_ok = "RS256"
+algorithm_wrong = "HS256"
+
+
+def encode_jwt(payload: Dict[str, Any]) -> str:
+    return jwt.encode(payload, private_key, algorithm=algorithm_ok)
+
+
+def assert_jwt(token: str, expectation: Any, public_key: str = public_key_ok, algorithm: str = algorithm_ok) -> None:
+    with expectation:
+        validate_jwt(dataset=dataset_ok, token=token, public_key=public_key, algorithm=algorithm)
 
 
 @pytest.mark.parametrize(
-    "public_key,payload,expected",
+    "public_key,expectation",
     [
-        (None, payload_ok, False),
-        (other_public_key, payload_ok, False),
-        (public_key, {}, False),
-        (public_key, {"sub": dataset_ok}, False),
-        (public_key, {"sub": sub_wrong_1, "read": read_ok, "exp": exp_ok}, False),
-        (public_key, {"sub": sub_wrong_2, "read": read_ok, "exp": exp_ok}, False),
-        (public_key, {"sub": sub_wrong_3, "read": read_ok, "exp": exp_ok}, False),
-        (public_key, {"sub": sub_wrong_4, "read": read_ok, "exp": exp_ok}, False),
-        (public_key, {"sub": sub_ok, "read": read_wrong_1, "exp": exp_ok}, False),
-        (public_key, {"sub": sub_ok, "read": read_wrong_2, "exp": exp_ok}, False),
-        (public_key, {"sub": sub_ok, "read": read_ok, "exp": wrong_exp_1}, False),
-        (public_key, {"sub": sub_ok, "read": read_ok, "exp": wrong_exp_2}, False),
-        (public_key, payload_ok, True),
+        (other_public_key, pytest.raises(JWTInvalidSignature)),
+        (public_key_ok, does_not_raise()),
     ],
 )
-def test_is_jwt_valid(public_key: Optional[str], payload: Dict[str, str], expected: bool) -> None:
-    token = jwt.encode(payload, private_key, algorithm=algorithm_rs256)
-    assert is_jwt_valid(dataset=dataset_ok, token=token, public_key=public_key, algorithm=algorithm_rs256) is expected
+def test_validate_jwt_public_key(public_key: str, expectation: Any) -> None:
+    assert_jwt(encode_jwt(payload_ok), expectation, public_key=public_key)
+
+
+@pytest.mark.parametrize(
+    "algorithm,expectation",
+    [
+        (algorithm_wrong, pytest.raises(JWTInvalidKeyOrAlgorithm)),
+        (algorithm_ok, does_not_raise()),
+    ],
+)
+def test_validate_jwt_algorithm(algorithm: str, expectation: Any) -> None:
+    assert_jwt(encode_jwt(payload_ok), expectation, algorithm=algorithm)
+
+
+@pytest.mark.parametrize(
+    "payload,expectation",
+    [
+        ({}, pytest.raises(JWTMissingRequiredClaim)),
+        ({"sub": sub_ok}, pytest.raises(JWTMissingRequiredClaim)),
+        ({"read": read_ok}, pytest.raises(JWTMissingRequiredClaim)),
+        ({"exp": exp_ok}, pytest.raises(JWTMissingRequiredClaim)),
+        ({"read": read_ok, "exp": exp_ok}, pytest.raises(JWTMissingRequiredClaim)),
+        ({"sub": sub_ok, "exp": exp_ok}, pytest.raises(JWTMissingRequiredClaim)),
+        ({"sub": sub_ok, "read": read_ok}, pytest.raises(JWTMissingRequiredClaim)),
+        ({"sub": sub_ok, "read": read_ok, "exp": exp_ok}, does_not_raise()),
+    ],
+)
+def test_validate_jwt_content_format(payload: Dict[str, str], expectation: Any) -> None:
+    assert_jwt(encode_jwt(payload), expectation)
+
+
+@pytest.mark.parametrize(
+    "read,expectation",
+    [
+        (read_wrong_1, pytest.raises(JWTInvalidClaimRead)),
+        (read_wrong_2, pytest.raises(JWTInvalidClaimRead)),
+        (read_ok, does_not_raise()),
+    ],
+)
+def test_validate_jwt_read(read: str, expectation: Any) -> None:
+    assert_jwt(encode_jwt({"sub": sub_ok, "read": read, "exp": exp_ok}), expectation)
+
+
+@pytest.mark.parametrize(
+    "sub,expectation",
+    [
+        (sub_wrong_1, pytest.raises(JWTInvalidClaimSub)),
+        (sub_wrong_2, pytest.raises(JWTInvalidClaimSub)),
+        (sub_wrong_3, pytest.raises(JWTInvalidClaimSub)),
+        (sub_wrong_4, pytest.raises(JWTInvalidClaimSub)),
+        (sub_ok, does_not_raise()),
+    ],
+)
+def test_validate_jwt_subject(sub: str, expectation: Any) -> None:
+    assert_jwt(encode_jwt({"sub": sub, "read": read_ok, "exp": exp_ok}), expectation)
+
+
+@pytest.mark.parametrize(
+    "expiration,expectation",
+    [
+        (wrong_exp_1, pytest.raises(JWTExpiredSignature)),
+        (wrong_exp_2, pytest.raises(JWTExpiredSignature)),
+        (exp_ok, does_not_raise()),
+    ],
+)
+def test_validate_jwt_expiration(expiration: str, expectation: Any) -> None:
+    assert_jwt(
+        encode_jwt({"sub": sub_ok, "read": read_ok, "exp": expiration}),
+        expectation,
+    )