Repository: ckan/ckan

Issue:
Authentication header name
As of CKAN 2.10, you can provide auth information in an API call:

> To provide your API token in an HTTP request, include it in either an `Authorization` or `X-CKAN-API-Key` header. (The name of the HTTP header can be configured with the `apikey_header_name` option in your CKAN configuration file.)

[Source](https://docs.ckan.org/en/2.10/api/index.html#authentication-and-api-tokens)

This was a bit of a surprise to me, as I've always assumed that we only checked for the `Authorization` header, which you could change to `X-CKAN-API-Key`  or whatever using the `apikey_header_name` conf. Except for one exception, the docs always show `Authorization` being used.

So my first question is, do we want to keep this default behaviour (ie support both headers)?

Regardless, I think that the [code](https://github.com/ckan/ckan/blob/e8b57b94aa083e0de922714091632e14ce29ccc0/ckan/views/__init__.py#L122-L135) that reads the headers needs a bit of cleanup (`##` Comments are mine):

```python
 def _get_user_for_apitoken() -> Optional[model.User]:  # type: ignore 
     
     ## This defaults to `X-CKAN-API-Key`, not `Authorization` (see [1])
     apitoken_header_name = config.get("apikey_header_name") 

     ## First we check if there's a `X-CKAN-API-Key` header in the request, 99.9% of the time it ain't there
     apitoken: str = request.headers.get(apitoken_header_name, u'') 

     if not apitoken: 
         ## We will never get here, headers are not added directly to the WSGI environ
         apitoken = request.environ.get(apitoken_header_name, u'') 
     if not apitoken:
         ## Cryptic ancient comment below, but actually that's the condition that is met 99.9% of the time
         ## when a request with the `Authorization` header is sent (see [2])
         # For misunderstanding old documentation (now fixed). 
         apitoken = request.environ.get(u'HTTP_AUTHORIZATION', u'') 
     if not apitoken: 
         ## We will never get here, headers are not added directly to the WSGI environ
         apitoken = request.environ.get(u'Authorization', u'') 
         # Forget HTTP Auth credentials (they have spaces). 
         if u' ' in apitoken:
             apitoken = u''
```
[1] [config declaration](https://github.com/ckan/ckan/blob/e8b57b94aa083e0de922714091632e14ce29ccc0/ckan/config/config_declaration.yaml#L371) for  `apikey_header_name`
[2] HTTP Request Headers are added to the [WSGI environ](https://wsgi.readthedocs.io/en/latest/definitions.html#standard-environ-keys) as `HTTP_<header_name_in_all_caps>`

The `request.environ.get(u'HTTP_AUTHORIZATION', u'')` seems too brittle to rely on considering it is the one that will catch most of the requests. I would suggest refactor ad cleanup the function above to specifically check the request headers for `Authorization` and fall back to `X-CKAN-API-Key` / custom key if not found.

In addition on a newly created ini file, you get the default value of `X-CKAN-API-Key`, which I think is confusing considering that we are using `Authorization` everywhere else:

```ini 
## Site Settings ###############################################################                                                                                                                                                                                                     
ckan.site_url = http://localhost:5000                                                                                                                                                                                                                                                
apikey_header_name = X-CKAN-API-Key                                                                                                                                                                                                                                                  
ckan.cache_expires = 0                                                                                                                                                                                                                                                               
ckan.cache_enabled = false   
#...
```

So we could

- Change the default header name to `Authorization`
- Support `X-CKAN-API-Key`  as special case to support current behaviour (or not, and just drop it on 2.11)
- Rename `apikey_header_name` to `apitoken_header_name` using the legacy option to support the old key




Discussion:
Decisions from the dev meeting:

* Keep support for both `Authorization` and `X-CKAN-API-Key`
* Default value for `apikey_header_name`  should be `Authorization`
* Refactor `_get_user_for_apitoken()` clean it up and reflect the above
* Rename `apikey_header_name` to `apitoken_header_name` using the legacy option to support the old key

Resolving patch:
diff --git a/ckan/lib/base.py b/ckan/lib/base.py
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -24,9 +24,6 @@
 
 log = logging.getLogger(__name__)
 
-APIKEY_HEADER_NAME_KEY = 'apikey_header_name'
-APIKEY_HEADER_NAME_DEFAULT = 'X-CKAN-API-Key'
-
 
 def abort(status_code: int,
           detail: str = '',
diff --git a/ckan/views/__init__.py b/ckan/views/__init__.py
--- a/ckan/views/__init__.py
+++ b/ckan/views/__init__.py
@@ -38,7 +38,7 @@ def set_cors_headers_for_response(response: Response) -> Response:
             response.headers['Access-Control-Allow-Methods'] = \
                 'POST, PUT, GET, DELETE, OPTIONS'
             response.headers['Access-Control-Allow-Headers'] = \
-                'X-CKAN-API-KEY, Authorization, Content-Type'
+                f'{config.get("apitoken_header_name")}, Content-Type'
 
     return response
 
@@ -118,23 +118,13 @@ def identify_user() -> Optional[Response]:
 
 
 def _get_user_for_apitoken() -> Optional[model.User]:  # type: ignore
-    apitoken_header_name = config.get("apikey_header_name")
-
+    apitoken_header_name = config.get("apitoken_header_name")
     apitoken: str = request.headers.get(apitoken_header_name, u'')
-    if not apitoken:
-        apitoken = request.environ.get(apitoken_header_name, u'')
-    if not apitoken:
-        # For misunderstanding old documentation (now fixed).
-        apitoken = request.environ.get(u'HTTP_AUTHORIZATION', u'')
-    if not apitoken:
-        apitoken = request.environ.get(u'Authorization', u'')
-        # Forget HTTP Auth credentials (they have spaces).
-        if u' ' in apitoken:
-            apitoken = u''
+
     if not apitoken:
         return None
     apitoken = str(apitoken)
-    log.debug(u'Received API Token: %s' % apitoken)
+    log.debug(f'Received API Token: {apitoken[:10]}[...]')
 
     user = api_token.get_user_from_token(apitoken)
 


Test patch:
diff --git a/ckan/tests/controllers/test_package.py b/ckan/tests/controllers/test_package.py
--- a/ckan/tests/controllers/test_package.py
+++ b/ckan/tests/controllers/test_package.py
@@ -88,11 +88,11 @@ def test_new_indexerror(self, app, user):
 
     def test_change_locale(self, app, user):
         url = url_for("dataset.new")
-        env = {"Authorization": user["token"]}
-        res = app.get(url, extra_environ=env)
+        headers = {"Authorization": user["token"]}
+        res = app.get(url, headers=headers)
         # See https://github.com/python-babel/flask-babel/issues/214
         refresh_babel()
-        res = app.get("/de/dataset/new", extra_environ=env)
+        res = app.get("/de/dataset/new", headers=headers)
         assert helpers.body_contains(res, "Datensatz")
 
     @pytest.mark.ckan_config("ckan.auth.create_unowned_dataset", "false")
diff --git a/ckan/tests/lib/test_base.py b/ckan/tests/lib/test_base.py
--- a/ckan/tests/lib/test_base.py
+++ b/ckan/tests/lib/test_base.py
@@ -129,7 +129,7 @@ def test_cors_config_origin_allow_all_true_with_origin(app):
     )
     assert (
         response_headers["Access-Control-Allow-Headers"]
-        == "X-CKAN-API-KEY, Authorization, Content-Type"
+        == "Authorization, Content-Type"
     )
 
 
@@ -176,7 +176,7 @@ def test_cors_config_origin_allow_all_false_with_whitelisted_origin(app):
     )
     assert (
         response_headers["Access-Control-Allow-Headers"]
-        == "X-CKAN-API-KEY, Authorization, Content-Type"
+        == "Authorization, Content-Type"
     )
 
 
@@ -210,7 +210,7 @@ def test_cors_config_origin_allow_all_false_with_multiple_whitelisted_origins(
     )
     assert (
         response_headers["Access-Control-Allow-Headers"]
-        == "X-CKAN-API-KEY, Authorization, Content-Type"
+        == "Authorization, Content-Type"
     )
 
 
@@ -314,7 +314,7 @@ def test_cors_config_origin_allow_all_true_with_origin_2(app):
     )
     assert (
         response_headers["Access-Control-Allow-Headers"]
-        == "X-CKAN-API-KEY, Authorization, Content-Type"
+        == "Authorization, Content-Type"
     )
 
 
@@ -367,7 +367,7 @@ def test_cors_config_origin_allow_all_false_with_whitelisted_origin_2(app):
     )
     assert (
         response_headers["Access-Control-Allow-Headers"]
-        == "X-CKAN-API-KEY, Authorization, Content-Type"
+        == "Authorization, Content-Type"
     )
 
 
@@ -403,7 +403,27 @@ def test_cors_config_origin_allow_all_false_with_multiple_whitelisted_origins_2(
     )
     assert (
         response_headers["Access-Control-Allow-Headers"]
-        == "X-CKAN-API-KEY, Authorization, Content-Type"
+        == "Authorization, Content-Type"
+    )
+
+
+@pytest.mark.ckan_config("ckan.cors.origin_allow_all", "true")
+@pytest.mark.ckan_config("ckan.site_url", "http://test.ckan.org")
+@pytest.mark.ckan_config("apitoken_header_name", "X-CKAN-API-TOKEN")
+@pytest.mark.ckan_config("ckan.plugins", "test_blueprint_plugin")
+@pytest.mark.usefixtures("with_plugins")
+def test_cors_config_custom_auth_header(app):
+    """
+    When using a custom value for the auth header, this should be returned
+    in the Access-Control-Allow-Headers header in the response.
+    """
+    request_headers = {"Origin": "http://thirdpartyrequests.org"}
+    response = app.get("/simple_url", headers=request_headers)
+    response_headers = dict(response.headers)
+
+    assert (
+        response_headers["Access-Control-Allow-Headers"]
+        == "X-CKAN-API-TOKEN, Content-Type"
     )