Repository: CS-SI/eodag
Pull request #1138 (2024-05-15 13:19:58)

feat: add refresh token to `OIDCAuthorizationCodeFlowAuth` plugin
- Add support for the refresh token
- Use the expiration time of the access token and refresh token to determine if requesting a new token or using the already retrieved one

github-actions[bot]: ## Test Results
    4 files  ± 0      4 suites  ±0   4m 49s :stopwatch: -1s
  528 tests + 3    525 :white_check_mark: + 3   3 :zzz: ±0  0 :x: ±0 
2 112 runs  +12  2 018 :white_check_mark: +12  94 :zzz: ±0  0 :x: ±0 

Results for commit bda4939b. ± Comparison against base commit 0440f208.

<details>
  <summary>This pull request <b>removes</b> 3 and <b>adds</b> 6 tests. <i>Note that renamed tests count towards both.</i></summary>

```
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_authenticate_no_redirect
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_authenticate_user_consent_needed_ok
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_authenticate_user_no_login_forms
```

```
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_get_token_with_refresh_token_http_exception
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_get_token_with_refresh_token_ok
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_request_new_token_exchange_url_error_pattern
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_request_new_token_no_redirect
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_request_new_token_ok
tests.units.test_auth_plugins.TestAuthPluginOIDCAuthorizationCodeFlowAuth ‑ test_plugins_auth_codeflowauth_request_new_token_user_consent_needed_ok
```
</details>

[test-results]:data:application/gzip;base64,H4sIAH63RGYC/03Myw7CIBCF4VdpWLugAyj4Mma4JcReDIWV8d2d1ha7mMX/TXLeLKYhLOzeyUvHlppKC18zljRPlKANAb3K+lSgj3os1bmN1J+e6UUkGkRMAwFvEHKe8y65Tusm9D3sdWwC73Wj36aRB5w2tz5PunkcU6Fg1qM0wlhw3iKqW1Dh6qSPoLzVdFx44RAj+3wBgpZNkAgBAAA=

github-actions[bot]: ![badge](https://raw.githubusercontent.com/CS-SI/eodag/_xml_coverage_reports/data/1130-refresh-token-usage-in-oidcauthorizationcodeflowauth/./badge.svg)


## Code Coverage (Ubuntu)

<details>


```
Filename                                     Stmts    Miss  Cover    Missing
-----------------------------------------  -------  ------  -------  -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
__init__.py                                     11       0  100.00%
cli.py                                         303      51  83.17%   60, 646-692, 794-845, 849
config.py                                      341      27  92.08%   81-83, 92, 100, 104-106, 179, 191, 444-446, 510-513, 560-561, 570-571, 650, 719-724, 726
crunch.py                                        6       6  0.00%    18-24
api/__init__.py                                  1       0  100.00%
api/core.py                                    739      76  89.72%   88-97, 370, 597, 641-644, 682, 787, 791-796, 822, 892, 969, 1082, 1172-1184, 1224, 1226, 1230, 1253, 1257-1268, 1281-1287, 1377-1380, 1409-1429, 1486, 1503-1506, 1518-1521, 1889, 1922-1928, 2193, 2197-2200, 2214-2216, 2251
api/search_result.py                            44       6  86.36%   33-35, 70, 79, 86, 100
api/product/__init__.py                          6       0  100.00%
api/product/_assets.py                          44       5  88.64%   27-29, 79, 129
api/product/_product.py                        215      34  84.19%   53-60, 64-66, 170-177, 261-262, 352, 367-368, 393, 454, 468-471, 484, 508-511, 554-560
api/product/metadata_mapping.py                653      80  87.75%   67-69, 130-132, 233, 265-266, 312-313, 323-335, 337, 348, 354-366, 407-410, 447, 468-471, 494, 502-503, 579-580, 604-605, 611-614, 629-630, 779, 825, 1078, 1092-1112, 1132, 1137, 1247, 1269, 1283, 1296-1315, 1354, 1406, 1444-1448, 1467
api/product/drivers/__init__.py                  6       0  100.00%
api/product/drivers/base.py                      8       2  75.00%   23, 41
plugins/__init__.py                              1       0  100.00%
plugins/base.py                                 23       3  86.96%   25, 48, 55
plugins/manager.py                             132      14  89.39%   49-51, 105-110, 156, 195, 215, 241, 280-281
plugins/apis/__init__.py                         1       0  100.00%
plugins/apis/base.py                             4       0  100.00%
plugins/apis/ecmwf.py                           98      15  84.69%   47-55, 156-158, 205-206, 232-234
plugins/apis/usgs.py                           172      36  79.07%   59-64, 128, 201, 235, 270-272, 277, 303-304, 309, 339-346, 357-362, 384-390, 392-398, 421
plugins/authentication/__init__.py               7       1  85.71%   31
plugins/authentication/aws_auth.py              20       2  90.00%   25-27
plugins/authentication/base.py                  19       3  84.21%   26, 34, 47
plugins/authentication/generic.py               16       3  81.25%   28, 40, 50
plugins/authentication/header.py                17       1  94.12%   27
plugins/authentication/keycloak.py              88      17  80.68%   32-34, 159-160, 190-212, 238-243
plugins/authentication/oauth.py                 15       8  46.67%   25, 32-34, 38-41
plugins/authentication/openid_connect.py       161       8  95.03%   41-43, 250-255, 283
plugins/authentication/qsauth.py                36       2  94.44%   32, 83
plugins/authentication/sas_auth.py              49       2  95.92%   32, 76
plugins/authentication/token.py                 86      17  80.23%   35-37, 86, 99, 101, 116-128, 170-173
plugins/authentication/token_exchange.py        35      19  45.71%   74-80, 92-120
plugins/crunch/__init__.py                       1       0  100.00%
plugins/crunch/base.py                          10       2  80.00%   25, 38
plugins/crunch/filter_date.py                   62      15  75.81%   30, 53-58, 72, 81, 90, 93, 105-107, 116-118, 125
plugins/crunch/filter_latest_intersect.py       50      10  80.00%   32-34, 51-52, 71, 80-83, 85, 92-95
plugins/crunch/filter_latest_tpl_name.py        33       2  93.94%   28, 86
plugins/crunch/filter_overlap.py                68      17  75.00%   28-30, 33, 82-85, 91, 99, 110-126
plugins/crunch/filter_property.py               33       8  75.76%   29, 60-65, 68-69, 85-89
plugins/download/__init__.py                     1       0  100.00%
plugins/download/aws.py                        492     165  66.46%   77-83, 272, 285, 352-355, 369-373, 419-421, 425, 459-460, 466-470, 503, 538, 542, 549, 579-587, 591, 629-637, 644-646, 687-761, 779-840, 851-856, 872-885, 914, 929-931, 934, 944-952, 960-973, 983-1002, 1009-1021, 1062, 1088, 1133-1135, 1355
plugins/download/base.py                       260      57  78.08%   58-64, 145, 180, 319-320, 340-346, 377-381, 387-388, 432, 435-449, 461, 465, 538-542, 572-573, 581-598, 605-613, 615-619, 666, 688, 710, 718
plugins/download/creodias_s3.py                 17       9  47.06%   44-58
plugins/download/http.py                       529     132  75.05%   85-91, 124, 203-213, 215-216, 251-254, 318-321, 323-324, 331-336, 354-369, 386-388, 400, 448, 455-461, 479, 493, 507, 515-517, 533-538, 549, 567, 606-610, 632, 672, 717, 731-737, 773-834, 852, 885-894, 920-921, 948-953, 959, 975, 992-993, 1020-1021, 1028, 1090-1096, 1151-1152, 1158, 1168, 1234, 1238, 1252-1268
plugins/download/s3rest.py                     118      27  77.12%   55-58, 121, 162, 197, 227-234, 237-239, 243, 256-262, 270-271, 274-278, 301, 322-325
plugins/search/__init__.py                       1       0  100.00%
plugins/search/base.py                         127       9  92.91%   49-54, 108, 112, 275, 295, 378
plugins/search/build_search_result.py          179      24  86.59%   62, 97, 142-143, 149, 160, 296-299, 333, 390-407, 469, 472, 482, 499, 527, 529
plugins/search/creodias_s3.py                   54       3  94.44%   55, 73, 107
plugins/search/csw.py                          107      83  22.43%   43-45, 57-58, 62-63, 74-122, 128-141, 149-181, 199-240
plugins/search/data_request_search.py          197      65  67.01%   52, 89-92, 108, 120, 124-125, 139, 144, 149, 156, 169-172, 226-227, 231, 241-247, 252, 280-283, 291-302, 319, 321, 328-329, 331-332, 350-354, 387, 394, 405, 418, 424-436, 441
plugins/search/qssearch.py                     553      58  89.51%   96, 368-374, 382-383, 490-496, 551-554, 627-628, 669, 687, 702, 755, 776, 779-780, 789, 800, 809, 832, 892-897, 901-902, 930, 1001, 1048, 1124-1128, 1194-1195, 1216, 1241-1253, 1260, 1291-1292, 1302-1308, 1351, 1366, 1386, 1476
plugins/search/static_stac_search.py            47       3  93.62%   39-40, 82
rest/__init__.py                                 5       2  60.00%   21-22
rest/core.py                                   206      16  92.23%   66-72, 261, 345, 605, 607, 610-612, 684, 691-695
rest/server.py                                 299      54  81.94%   80-81, 107, 130-131, 244-246, 262, 302-303, 315-331, 423-428, 460, 627-634, 667, 711-712, 803-805, 822-827, 858, 860, 864-865, 869-870
rest/stac.py                                   436      86  80.28%   60-62, 272, 294, 366, 403-405, 428, 463-464, 550-595, 640, 660-661, 841, 906-908, 1127, 1137-1149, 1162-1184, 1198-1243, 1402-1403
rest/types/__init__.py                           1       0  100.00%
rest/types/eodag_search.py                     185       9  95.14%   52-55, 231-235, 288, 291, 359
rest/types/stac_queryables.py                   32       2  93.75%   28, 116
rest/types/stac_search.py                      122      10  91.80%   48-51, 167, 182-184, 192, 196
rest/utils/__init__.py                          96      13  86.46%   51, 108-109, 128-130, 182, 192-206
rest/utils/cql_evaluate.py                      48       5  89.58%   69, 76, 90, 97, 105
rest/utils/rfc3339.py                           31       3  90.32%   78, 90, 92
types/__init__.py                              111      12  89.19%   53, 70, 129-132, 199, 214-218, 245, 250, 258
types/bbox.py                                   43      19  55.81%   46-61, 72-74, 85-87, 99-101, 113-115, 123
types/download_args.py                           9       0  100.00%
types/queryables.py                             81       0  100.00%
types/search_args.py                            70      18  74.29%   60-64, 71-88, 104
types/whoosh.py                                 15       0  100.00%
utils/__init__.py                              502      40  92.03%   84, 89, 110-112, 193-194, 203-230, 233, 247, 329-333, 409-413, 434-436, 518, 533, 571-572, 968-971, 979-980, 1022, 1102, 1204, 1380
utils/constraints.py                           123      42  65.85%   31, 84-93, 134, 139, 143, 154, 177-178, 189-197, 206, 220-236, 245-256
utils/exceptions.py                             37       2  94.59%   23, 93
utils/import_system.py                          30      20  33.33%   27, 67-81, 93-103
utils/logging.py                                29       1  96.55%   123
utils/notebook.py                               44      23  47.73%   25-29, 36-41, 58-62, 72-78, 83-87
utils/rest.py                                   28       1  96.43%   49
utils/stac_reader.py                            91      28  69.23%   55-56, 63-86, 93-95, 99, 141, 155-158
TOTAL                                         8970    1533  82.91%
```

</details>


### Diff against develop

```
Filename                                    Stmts    Miss  Cover
----------------------------------------  -------  ------  -------
plugins/authentication/openid_connect.py      +44      -3  +4.43%
utils/__init__.py                               0      -1  +0.20%
TOTAL                                         +44      -4  +0.13%
```


Results for commit: bda4939b2cdbaa57e5e6c4df25db85db03d3caaf


_Minimum allowed coverage is `70%`_


:recycle: This comment has been updated with latest results


<!-- Sticky Pull Request Commentartifacts/unit-test-results-python3.12-ubuntu-latest/coverage.xml -->

Patch:
diff --git a/eodag/plugins/authentication/keycloak.py b/eodag/plugins/authentication/keycloak.py
index 8ed73f8b..6a1a8b95 100644
--- a/eodag/plugins/authentication/keycloak.py
+++ b/eodag/plugins/authentication/keycloak.py
@@ -18,15 +18,16 @@
 from __future__ import annotations
 
 import logging
-from datetime import datetime
-from typing import TYPE_CHECKING, Any, Dict, TypedDict
+from typing import TYPE_CHECKING, Any, Dict
 
 import requests
 
-from eodag.plugins.authentication import Authentication
-from eodag.plugins.authentication.openid_connect import CodeAuthorizedAuth
+from eodag.plugins.authentication.openid_connect import (
+    CodeAuthorizedAuth,
+    OIDCRefreshTokenBase,
+)
 from eodag.utils import HTTP_REQ_TIMEOUT, USER_AGENT
-from eodag.utils.exceptions import AuthenticationError, MisconfiguredError
+from eodag.utils.exceptions import MisconfiguredError, TimeOutError
 
 if TYPE_CHECKING:
     from requests.auth import AuthBase
@@ -37,7 +38,7 @@ if TYPE_CHECKING:
 logger = logging.getLogger("eodag.auth.keycloak")
 
 
-class KeycloakOIDCPasswordAuth(Authentication):
+class KeycloakOIDCPasswordAuth(OIDCRefreshTokenBase):
     """Authentication plugin using Keycloak and OpenId Connect.
 
     This plugin request a token and use it through a query-string or a header.
@@ -80,23 +81,9 @@ class KeycloakOIDCPasswordAuth(Authentication):
     GRANT_TYPE = "password"
     TOKEN_URL_TEMPLATE = "{auth_base_uri}/realms/{realm}/protocol/openid-connect/token"
     REQUIRED_PARAMS = ["auth_base_uri", "client_id", "client_secret", "token_provision"]
-    # already retrieved token store, to be used if authenticate() fails (OTP use-case)
-    retrieved_token: str = ""
-
-    class TokenInfo(TypedDict, total=False):
-        """Token infos"""
-
-        refresh_token: str
-        refresh_time: datetime
-        token_time: datetime
-        access_token_expiration: float
-        refresh_token_expiration: float
-
-    token_info: TokenInfo = {}
 
     def __init__(self, provider: str, config: PluginConfig) -> None:
         super(KeycloakOIDCPasswordAuth, self).__init__(provider, config)
-        self.session = requests.Session()
 
     def validate_config_credentials(self) -> None:
         """Validate configured credentials"""
@@ -115,50 +102,13 @@ class KeycloakOIDCPasswordAuth(Authentication):
         """
         self.validate_config_credentials()
         access_token = self._get_access_token()
-        self.retrieved_token = access_token
+        self.token_info["access_token"] = access_token
         return CodeAuthorizedAuth(
-            self.retrieved_token,
+            self.token_info["access_token"],
             self.config.token_provision,
             key=getattr(self.config, "token_qs_key", None),
         )
 
-    def _get_access_token(self) -> str:
-        current_time = datetime.now()
-        if (
-            not self.token_info
-            or (
-                "refresh_token" in self.token_info
-                and (current_time - self.token_info["token_time"]).seconds
-                >= self.token_info["refresh_token_expiration"]
-            )
-            or (
-                "refresh_token" not in self.token_info
-                and (current_time - self.token_info["token_time"]).seconds
-                >= self.token_info["access_token_expiration"]
-            )
-        ):
-            # Request new TOKEN on first attempt or if token expired
-            res = self._request_new_token()
-            self.token_info["token_time"] = current_time
-            self.token_info["access_token_expiration"] = res["expires_in"]
-            if "refresh_token" in res:
-                self.token_info["refresh_time"] = current_time
-                self.token_info["refresh_token_expiration"] = res["refresh_expires_in"]
-                self.token_info["refresh_token"] = res["refresh_token"]
-            return res["access_token"]
-        elif (
-            "refresh_token" in self.token_info
-            and (current_time - self.token_info["refresh_time"]).seconds
-            >= self.token_info["access_token_expiration"]
-        ):
-            # Use refresh token
-            res = self._get_token_with_refresh_token()
-            self.token_info["refresh_token"] = res["refresh_token"]
-            self.token_info["refresh_time"] = current_time
-            return res["access_token"]
-        logger.debug("using already retrieved access token")
-        return self.retrieved_token
-
     def _request_new_token(self) -> Dict[str, Any]:
         logger.debug("fetching new access token")
         req_data = {
@@ -178,42 +128,10 @@ class KeycloakOIDCPasswordAuth(Authentication):
                 timeout=HTTP_REQ_TIMEOUT,
             )
             response.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
         except requests.RequestException as e:
-            if self.retrieved_token:
-                # try using already retrieved token if authenticate() fails (OTP use-case)
-                if "access_token_expiration" in self.token_info:
-                    return {
-                        "access_token": self.retrieved_token,
-                        "expires_in": self.token_info["access_token_expiration"],
-                    }
-                else:
-                    return {"access_token": self.retrieved_token, "expires_in": 0}
-            response_text = getattr(e.response, "text", "").strip()
-            # check if error is identified as auth_error in provider conf
-            auth_errors = getattr(self.config, "auth_error_code", [None])
-            if not isinstance(auth_errors, list):
-                auth_errors = [auth_errors]
-            if (
-                e.response
-                and hasattr(e.response, "status_code")
-                and e.response.status_code in auth_errors
-            ):
-                raise AuthenticationError(
-                    "HTTP Error %s returned, %s\nPlease check your credentials for %s"
-                    % (e.response.status_code, response_text, self.provider)
-                )
-            # other error
-            else:
-                import traceback as tb
-
-                logger.error(
-                    f"Provider {self.provider} returned {getattr(e.response, 'status_code', '')}: {response_text}"
-                )
-                raise AuthenticationError(
-                    "Something went wrong while trying to get access token:\n{}".format(
-                        tb.format_exc()
-                    )
-                )
+            return self._request_new_token_error(e)
         return response.json()
 
     def _get_token_with_refresh_token(self) -> Dict[str, str]:
diff --git a/eodag/plugins/authentication/openid_connect.py b/eodag/plugins/authentication/openid_connect.py
index 1683c9e5..2ef54d41 100644
--- a/eodag/plugins/authentication/openid_connect.py
+++ b/eodag/plugins/authentication/openid_connect.py
@@ -20,8 +20,9 @@ from __future__ import annotations
 import logging
 import re
 import string
+from datetime import datetime
 from random import SystemRandom
-from typing import TYPE_CHECKING, Any, Dict, Optional
+from typing import TYPE_CHECKING, Any, Dict, Optional, TypedDict
 
 import requests
 from lxml import etree
@@ -45,7 +46,131 @@ if TYPE_CHECKING:
 logger = logging.getLogger("eodag.auth.openid_connect")
 
 
-class OIDCAuthorizationCodeFlowAuth(Authentication):
+class OIDCRefreshTokenBase(Authentication):
+    """OIDC refresh token base class, to be used through specific OIDC flows plugins.
+
+    Common mechanism to handle refresh token from all OIDC auth plugins.
+    """
+
+    class TokenInfo(TypedDict, total=False):
+        """Token infos"""
+
+        refresh_token: str
+        refresh_time: datetime
+        token_time: datetime
+        access_token: str
+        access_token_expiration: float
+        refresh_token_expiration: float
+
+    token_info: TokenInfo = {}
+    # already retrieved token store, to be used if authenticate() fails (OTP use-case)
+    retrieved_token: str = ""
+
+    def __init__(self, provider: str, config: PluginConfig) -> None:
+        super(OIDCRefreshTokenBase, self).__init__(provider, config)
+        self.session = requests.Session()
+
+    def _get_access_token(self) -> str:
+        current_time = datetime.now()
+        if (
+            # No info: first time
+            not self.token_info
+            # Refresh token available but expired
+            or (
+                "refresh_token" in self.token_info
+                and self.token_info["refresh_token_expiration"] > 0
+                and (current_time - self.token_info["token_time"]).seconds
+                >= self.token_info["refresh_token_expiration"]
+            )
+            # Refresh token *not* available and access token expired
+            or (
+                "refresh_token" not in self.token_info
+                and (current_time - self.token_info["token_time"]).seconds
+                >= self.token_info["access_token_expiration"]
+            )
+        ):
+            # Request *new* token on first attempt or if token expired
+            res = self._request_new_token()
+            self.token_info["token_time"] = current_time
+            self.token_info["access_token_expiration"] = float(res["expires_in"])
+            if "refresh_token" in res:
+                self.token_info["refresh_time"] = current_time
+                self.token_info["refresh_token_expiration"] = float(
+                    res["refresh_expires_in"]
+                )
+                self.token_info["refresh_token"] = str(res["refresh_token"])
+            return str(res["access_token"])
+
+        elif (
+            # Refresh token available and access token expired
+            "refresh_token" in self.token_info
+            and (current_time - self.token_info["refresh_time"]).seconds
+            >= self.token_info["access_token_expiration"]
+        ):
+            # Use refresh token
+            res = self._get_token_with_refresh_token()
+            self.token_info["refresh_token"] = res["refresh_token"]
+            self.token_info["refresh_time"] = current_time
+            return res["access_token"]
+
+        logger.debug("Using already retrieved access token")
+        return self.token_info["access_token"]
+
+    def _request_new_token(self) -> Dict[str, str]:
+        """Fetch the access token with a new authentcation"""
+        raise NotImplementedError(
+            "Incomplete OIDC refresh token retrieval mechanism implementation"
+        )
+
+    def _request_new_token_error(self, e: requests.RequestException) -> Dict[str, str]:
+        """Handle RequestException raised by `self._request_new_token()`"""
+        if self.token_info["access_token"]:
+            # try using already retrieved token if authenticate() fails (OTP use-case)
+            if "access_token_expiration" in self.token_info:
+                return {
+                    "access_token": self.token_info["access_token"],
+                    "expires_in": str(self.token_info["access_token_expiration"]),
+                }
+            else:
+                return {
+                    "access_token": self.token_info["access_token"],
+                    "expires_in": "0",
+                }
+        response_text = getattr(e.response, "text", "").strip()
+        # check if error is identified as auth_error in provider conf
+        auth_errors = getattr(self.config, "auth_error_code", [None])
+        if not isinstance(auth_errors, list):
+            auth_errors = [auth_errors]
+        if (
+            e.response
+            and hasattr(e.response, "status_code")
+            and e.response.status_code in auth_errors
+        ):
+            raise AuthenticationError(
+                "HTTP Error %s returned, %s\nPlease check your credentials for %s"
+                % (e.response.status_code, response_text, self.provider)
+            )
+        # other error
+        else:
+            import traceback as tb
+
+            logger.error(
+                f"Provider {self.provider} returned {getattr(e.response, 'status_code', '')}: {response_text}"
+            )
+            raise AuthenticationError(
+                "Something went wrong while trying to get access token:\n{}".format(
+                    tb.format_exc()
+                )
+            )
+
+    def _get_token_with_refresh_token(self) -> Dict[str, str]:
+        """Fetch the access token with the refresh token"""
+        raise NotImplementedError(
+            "Incomplete OIDC refresh token retrieval mechanism implementation"
+        )
+
+
+class OIDCAuthorizationCodeFlowAuth(OIDCRefreshTokenBase):
     """Implement the authorization code flow of the OpenIDConnect authorization specification.
 
     The `OpenID Connect <http://openid.net/specs/openid-connect-core-1_0.html>`_ specification
@@ -136,6 +261,8 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
         # used in the query request
         token_qs_key:
 
+        # (optional) The key pointing to the refresh_token in the json response to the POST request to the token server
+        refresh_token_key:
     """
 
     SCOPE = "openid"
@@ -144,7 +271,6 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
 
     def __init__(self, provider: str, config: PluginConfig) -> None:
         super(OIDCAuthorizationCodeFlowAuth, self).__init__(provider, config)
-        self.session = requests.Session()
 
     def validate_config_credentials(self) -> None:
         """Validate configured credentials"""
@@ -163,6 +289,17 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
 
     def authenticate(self) -> CodeAuthorizedAuth:
         """Authenticate"""
+        self.token_info["access_token"] = self._get_access_token()
+
+        return CodeAuthorizedAuth(
+            self.token_info["access_token"],
+            self.config.token_provision,
+            key=getattr(self.config, "token_qs_key", None),
+        )
+
+    def _request_new_token(self) -> Dict[str, str]:
+        """Fetch the access token with a new authentcation"""
+        logger.debug("Fetching access token from %s", self.config.token_uri)
         state = self.compute_state()
         authentication_response = self.authenticate_user(state)
         exchange_url = authentication_response.url
@@ -180,22 +317,43 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
             user_consent_response = self.grant_user_consent(authentication_response)
             exchange_url = user_consent_response.url
         try:
-            token = self.exchange_code_for_token(exchange_url, state)
+            token_response = self.exchange_code_for_token(exchange_url, state)
+            token_response.raise_for_status()
         except requests.exceptions.Timeout as exc:
             raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
-        except Exception:
-            import traceback as tb
+        except requests.RequestException as e:
+            return self._request_new_token_error(e)
+        return token_response.json()
 
-            raise AuthenticationError(
-                "Something went wrong while trying to get authorization token:\n{}".format(
-                    tb.format_exc()
-                )
-            )
-        return CodeAuthorizedAuth(
-            token,
-            self.config.token_provision,
-            key=getattr(self.config, "token_qs_key", None),
+    def _get_token_with_refresh_token(self) -> Dict[str, str]:
+        """Fetch the access token with the refresh token"""
+        logger.debug(
+            "Fetching access token with refresh token from %s", self.config.token_uri
         )
+        token_data: Dict[str, Any] = {
+            "refresh_token": self.token_info["refresh_token"],
+            "grant_type": "refresh_token",
+        }
+        token_data = self._prepare_token_post_data(token_data)
+        post_request_kwargs: Any = {
+            self.config.token_exchange_post_data_method: token_data
+        }
+        try:
+            token_response = self.session.post(
+                self.config.token_uri,
+                timeout=HTTP_REQ_TIMEOUT,
+                **post_request_kwargs,
+            )
+            token_response.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
+        except requests.RequestException as exc:
+            logger.error(
+                "Could not fetch access token with refresh token, executing new token request, error: %s",
+                getattr(exc.response, "text", ""),
+            )
+            return self._request_new_token()
+        return token_response.json()
 
     def authenticate_user(self, state: str) -> Response:
         """Authenticate user"""
@@ -216,11 +374,6 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
 
         login_document = etree.HTML(authorization_response.text)
         login_forms = login_document.xpath(self.config.login_form_xpath)
-
-        if not login_forms:
-            # we assume user is already logged in
-            return authorization_response
-
         login_form = login_forms[0]
 
         # Get the form data to pass to the login form from config or from the login form
@@ -271,7 +424,35 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
             timeout=HTTP_REQ_TIMEOUT,
         )
 
-    def exchange_code_for_token(self, authorized_url: str, state: str) -> str:
+    def _prepare_token_post_data(self, token_data: Dict[str, Any]) -> Dict[str, Any]:
+        """Prepare the common data to post to the token URI"""
+        token_data.update(
+            {
+                "redirect_uri": self.config.redirect_uri,
+                "client_id": self.config.client_id,
+            }
+        )
+        # If necessary, change the keys of the form data that will be passed to the token exchange POST request
+        custom_token_exchange_params = getattr(self.config, "token_exchange_params", {})
+        if custom_token_exchange_params:
+            token_data[custom_token_exchange_params["redirect_uri"]] = token_data.pop(
+                "redirect_uri"
+            )
+            token_data[custom_token_exchange_params["client_id"]] = token_data.pop(
+                "client_id"
+            )
+        # If the client_secret is known, the token exchange request must be authenticated with a BASIC Auth, using the
+        # client_id and client_secret as username and password respectively
+        if getattr(self.config, "client_secret", None):
+            token_data.update(
+                {
+                    "auth": (self.config.client_id, self.config.client_secret),
+                    "client_secret": self.config.client_secret,
+                }
+            )
+        return token_data
+
+    def exchange_code_for_token(self, authorized_url: str, state: str) -> Response:
         """Get exchange code for token"""
         qs = parse_qs(urlparse(authorized_url).query)
         if qs["state"][0] != state:
@@ -280,30 +461,11 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
             )
         code = qs["code"][0]
         token_exchange_data: Dict[str, Any] = {
-            "redirect_uri": self.config.redirect_uri,
-            "client_id": self.config.client_id,
             "code": code,
             "state": state,
             "grant_type": "authorization_code",
         }
-        # If necessary, change the keys of the form data that will be passed to the token exchange POST request
-        custom_token_exchange_params = getattr(self.config, "token_exchange_params", {})
-        if custom_token_exchange_params:
-            token_exchange_data[
-                custom_token_exchange_params["redirect_uri"]
-            ] = token_exchange_data.pop("redirect_uri")
-            token_exchange_data[
-                custom_token_exchange_params["client_id"]
-            ] = token_exchange_data.pop("client_id")
-        # If the client_secret is known, the token exchange request must be authenticated with a BASIC Auth, using the
-        # client_id and client_secret as username and password respectively
-        if getattr(self.config, "client_secret", None):
-            token_exchange_data.update(
-                {
-                    "auth": (self.config.client_id, self.config.client_secret),
-                    "client_secret": self.config.client_secret,
-                }
-            )
+        token_exchange_data = self._prepare_token_post_data(token_exchange_data)
         post_request_kwargs: Any = {
             self.config.token_exchange_post_data_method: token_exchange_data
         }
@@ -313,7 +475,7 @@ class OIDCAuthorizationCodeFlowAuth(Authentication):
             timeout=HTTP_REQ_TIMEOUT,
             **post_request_kwargs,
         )
-        return r.json()[self.config.token_key]
+        return r
 
     def _constant_or_xpath_extracted(
         self, value: str, form_element: Any
diff --git a/eodag/resources/providers.yml b/eodag/resources/providers.yml
index df81f8b6..044de6b3 100644
--- a/eodag/resources/providers.yml
+++ b/eodag/resources/providers.yml
@@ -6580,6 +6580,7 @@
     user_consent_needed: false
     token_exchange_post_data_method: data
     token_key: access_token
+    refresh_token_key: refresh_token
     token_provision: header
     login_form_xpath: //form[@id='kc-form-login']
     authentication_uri_source: login-form
diff --git a/eodag/rest/stac.py b/eodag/rest/stac.py
index b754f75e..37d17df0 100644
--- a/eodag/rest/stac.py
+++ b/eodag/rest/stac.py
@@ -35,7 +35,6 @@ from shapely.ops import unary_union
 
 from eodag.api.product.metadata_mapping import (
     DEFAULT_METADATA_MAPPING,
-    NOT_AVAILABLE,
     format_metadata,
     get_metadata_path,
 )
@@ -655,6 +654,80 @@ class StacCollection(StacCommon):
             root=root,
         )
 
+    def __list_product_type_providers(self, product_type: Dict[str, Any]) -> List[str]:
+        """Retrieve a list of providers for a given product type.
+
+        :param product_type: Dictionary containing information about the product type.
+        :type product_type: dict
+        :return: A list of provider names.
+        :rtype: list
+        """
+        if self.provider:
+            return [self.provider]
+
+        return [
+            plugin.provider
+            for plugin in self.eodag_api._plugins_manager.get_search_plugins(
+                product_type=product_type.get("_id", product_type["ID"])
+            )
+        ]
+
+    def __generate_stac_collection(
+        self, collection_model: Any, product_type: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """Generate a STAC collection dictionary for a given product type.
+
+        :param collection_model: The base model for the STAC collection.
+        :type collection_model: Any
+        :param product_type: Dictionary containing information about the product type.
+        :type product_type: dict
+        :return: A dictionary representing the STAC collection for the given product type.
+        :rtype: dict
+        """
+        providers = self.__list_product_type_providers(product_type)
+
+        # parse jsonpath
+        product_type_collection = jsonpath_parse_dict_items(
+            collection_model,
+            {
+                "product_type": product_type,
+                "providers": [self.get_provider_dict(p) for p in providers],
+            },
+        )
+        # override EODAG's collection with the external collection
+        ext_stac_collection = self.ext_stac_collections.get(product_type["ID"], {})
+
+        # update links
+        ext_stac_collection.setdefault("links", {})
+        for link in product_type_collection["links"]:
+            ext_stac_collection["links"] = [
+                x for x in ext_stac_collection["links"] if x["rel"] != link["rel"]
+            ]
+            ext_stac_collection["links"].append(link)
+
+        # merge "keywords" lists
+        if "keywords" in ext_stac_collection:
+            ext_stac_collection["keywords"] = list(
+                set(
+                    ext_stac_collection["keywords"]
+                    + product_type_collection["keywords"]
+                )
+            )
+
+        product_type_collection.update(ext_stac_collection)
+
+        # parse f-strings
+        format_args = deepcopy(self.stac_config)
+        format_args["collection"] = {
+            **product_type_collection,
+            **{"url": f"{self.url}/{product_type['ID']}", "root": self.root},
+        }
+        product_type_collection = format_dict_items(
+            product_type_collection, **format_args
+        )
+
+        return product_type_collection
+
     def __get_product_types(
         self, filters: Optional[Dict[str, Any]] = None
     ) -> List[Dict[str, Any]]:
@@ -716,53 +789,10 @@ class StacCollection(StacCommon):
 
         collection_list: List[Dict[str, Any]] = []
         for product_type in product_types:
-            # get available providers for each product_type
-            providers = (
-                [self.provider]
-                if self.provider
-                else [
-                    plugin.provider
-                    for plugin in self.eodag_api._plugins_manager.get_search_plugins(
-                        product_type=(
-                            product_type.get("_id", None) or product_type["ID"]
-                        )
-                    )
-                ]
-            )
-
-            # parse jsonpath
-            product_type_collection = jsonpath_parse_dict_items(
-                collection_model,
-                {
-                    "product_type": product_type,
-                    "providers": [self.get_provider_dict(p) for p in providers],
-                },
-            )
-            # override EODAG's collection with the external collection
-            ext_stac_collection = self.ext_stac_collections.get(product_type["ID"], {})
-
-            # merge "keywords" list
-            merged_keywords = product_type_collection.get("keywords", [])
-            if "keywords" in ext_stac_collection:
-                new_keywords = ext_stac_collection["keywords"]
-                for v in new_keywords:
-                    if v != NOT_AVAILABLE and v not in merged_keywords:
-                        merged_keywords.append(v)
-            product_type_collection.update(ext_stac_collection)
-            if merged_keywords:
-                product_type_collection["keywords"] = merged_keywords
-
-            # parse f-strings
-            format_args = deepcopy(self.stac_config)
-            format_args["collection"] = {
-                **product_type_collection,
-                **{"url": f"{self.url}/{product_type['ID']}", "root": self.root},
-            }
-            product_type_collection = format_dict_items(
-                product_type_collection, **format_args
+            stac_collection = self.__generate_stac_collection(
+                collection_model, product_type
             )
-
-            collection_list.append(product_type_collection)
+            collection_list.append(stac_collection)
 
         return collection_list
 


Test patch:
diff --git a/tests/units/test_auth_plugins.py b/tests/units/test_auth_plugins.py
index 1037ed59..9cab76cf 100644
--- a/tests/units/test_auth_plugins.py
+++ b/tests/units/test_auth_plugins.py
@@ -17,6 +17,7 @@
 # limitations under the License.
 
 import unittest
+from datetime import datetime, timedelta
 from unittest import mock
 
 import responses
@@ -26,6 +27,7 @@ from requests.exceptions import RequestException
 
 from eodag.config import override_config_from_mapping
 from eodag.plugins.authentication.openid_connect import CodeAuthorizedAuth
+from eodag.utils import MockResponse
 from eodag.utils.exceptions import RequestError
 from tests.context import (
     HTTP_REQ_TIMEOUT,
@@ -608,7 +610,7 @@ class TestAuthPluginKeycloakOIDCPasswordAuth(BaseAuthPluginTest):
             self.assertEqual(auth.where, "qs")
 
         # check that token has been stored
-        self.assertEqual(auth_plugin.retrieved_token, "obtained-token")
+        self.assertEqual(auth_plugin.token_info["access_token"], "obtained-token")
 
         # check that stored token is used if new auth request fails
         with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps:
@@ -737,7 +739,7 @@ class TestAuthPluginKeycloakOIDCPasswordAuth(BaseAuthPluginTest):
             self.assertEqual(auth.where, "qs")
 
         # check that token and refresh token have been stored
-        self.assertEqual(auth_plugin.retrieved_token, "obtained-token")
+        self.assertEqual(auth_plugin.token_info["access_token"], "obtained-token")
         assert auth_plugin.token_info
         self.assertEqual("abc", auth_plugin.token_info["refresh_token"])
 
@@ -899,32 +901,48 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
                             "const_key": "const_value",
                             "xpath_key": 'xpath(//input[@name="input_name"]/@value)',
                         },
+                        "exchange_url_error_pattern": {
+                            "TERMS_AND_CONDITIONS": "Terms and conditions are not accepted"
+                        },
                     },
                 },
             },
         )
         cls.plugins_manager = PluginManager(cls.providers_config)
 
+    def get_auth_plugin(self, provider):
+        auth_plugin = super(
+            TestAuthPluginOIDCAuthorizationCodeFlowAuth, self
+        ).get_auth_plugin(provider)
+        # reset token info
+        auth_plugin.token_info = {}
+        return auth_plugin
+
     def test_plugins_auth_codeflowauth_validate_credentials(self):
         """OIDCAuthorizationCodeFlowAuth.validate_credentials must raise an error if credentials are not valid"""
         # `token_provision` not valid
         auth_plugin = self.get_auth_plugin("provider_token_provision_invalid")
-        self.assertRaises(
-            MisconfiguredError,
-            auth_plugin.validate_config_credentials,
+        auth_plugin.config.credentials = {"foo": "bar"}
+        with self.assertRaises(MisconfiguredError) as context:
+            auth_plugin.validate_config_credentials()
+        self.assertTrue(
+            '"token_provision" must be one of "qs" or "header"'
+            in str(context.exception)
         )
         # `token_provision=="qs"` but `token_qs_key` is missing
         auth_plugin = self.get_auth_plugin("provider_token_qs_key_missing")
-        self.assertRaises(
-            MisconfiguredError,
-            auth_plugin.validate_config_credentials,
+        auth_plugin.config.credentials = {"foo": "bar"}
+        with self.assertRaises(MisconfiguredError) as context:
+            auth_plugin.validate_config_credentials()
+        self.assertTrue(
+            '"qs" must have "token_qs_key" config parameter as well'
+            in str(context.exception)
         )
         # Missing credentials
         auth_plugin = self.get_auth_plugin("provider_ok")
-        self.assertRaises(
-            MisconfiguredError,
-            auth_plugin.validate_config_credentials,
-        )
+        with self.assertRaises(MisconfiguredError) as context:
+            auth_plugin.validate_config_credentials()
+        self.assertTrue("Missing credentials" in str(context.exception))
 
     def test_plugins_auth_codeflowauth_validate_credentials_ok(self):
         """OIDCAuthorizationCodeFlowAuth.validate_credentials must be ok on non-empty credentials"""
@@ -933,21 +951,183 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         auth_plugin.config.credentials = {"foo": "bar"}
         auth_plugin.validate_config_credentials()
 
+    @mock.patch(
+        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth._get_token_with_refresh_token",
+        autospec=True,
+    )
+    @mock.patch(
+        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth._request_new_token",
+        autospec=True,
+    )
+    def test_plugins_auth_codeflowauth_authenticate_ok(
+        self,
+        mock_request_new_token,
+        mock_get_token_with_refresh_token,
+    ):
+        """OIDCAuthorizationCodeFlowAuth.authenticate must check if the token is expired, call the correct
+        function to get a new one and update the access token"""
+        auth_plugin = self.get_auth_plugin("provider_ok")
+        current_time = datetime.now()
+        json_response = {
+            "access_token": "obtained-access-token",
+            "expires_in": "3600",
+            "refresh_expires_in": "7200",
+            "refresh_token": "obtained-refresh-token",
+        }
+        mock_request_new_token.return_value = json_response
+        mock_get_token_with_refresh_token.return_value = json_response
+
+        def _authenticate(
+            called_once: mock.Mock, not_called: mock.Mock, access_token: str
+        ) -> None:
+            auth = auth_plugin.authenticate()
+            called_once.assert_called_once()
+            not_called.assert_not_called()
+            self.assertEqual(auth.token, access_token)
+            called_once.reset_mock()
+            not_called.reset_mock()
+
+        # No info: first time -> new auth
+        _authenticate(
+            mock_request_new_token,
+            mock_get_token_with_refresh_token,
+            json_response["access_token"],
+        )
+        # Check internal `token_info`` stores the new data
+        self.assertEqual(
+            auth_plugin.token_info["refresh_token"],
+            json_response["refresh_token"],
+        )
+        self.assertIsNotNone(auth_plugin.token_info["refresh_time"])
+        self.assertEqual(
+            auth_plugin.token_info["access_token"],
+            json_response["access_token"],
+        )
+        self.assertIsNotNone(auth_plugin.token_info["token_time"])
+        self.assertEqual(
+            auth_plugin.token_info["access_token_expiration"],
+            float(json_response["expires_in"]),
+        )
+        self.assertEqual(
+            auth_plugin.token_info["refresh_token_expiration"],
+            float(json_response["refresh_expires_in"]),
+        )
+
+        # Refresh token available but expired -> new auth
+        auth_plugin.token_info = {
+            "refresh_token": "old-refresh-token",
+            "refresh_time": current_time - timedelta(hours=3),
+            "access_token": "old-access-token",
+            "token_time": current_time - timedelta(hours=3),
+            "access_token_expiration": 3600,
+            "refresh_token_expiration": 7200,
+        }
+        _authenticate(
+            mock_request_new_token,
+            mock_get_token_with_refresh_token,
+            json_response["access_token"],
+        )
+
+        # Refresh token *not* available and access token expired - new auth
+        auth_plugin.token_info = {
+            "access_token": "old-access-token",
+            "token_time": current_time - timedelta(hours=3),
+            "access_token_expiration": 3600,
+        }
+        _authenticate(
+            mock_request_new_token,
+            mock_get_token_with_refresh_token,
+            json_response["access_token"],
+        )
+
+        # Refresh token available and access token expired -> refresh
+        auth_plugin.token_info = {
+            "refresh_token": "old-refresh-token",
+            "refresh_time": current_time - timedelta(seconds=4000),
+            "access_token": "old-access-token",
+            "token_time": current_time - timedelta(seconds=4000),
+            "access_token_expiration": 3600,
+            "refresh_token_expiration": 7200,
+        }
+        _authenticate(
+            mock_get_token_with_refresh_token,
+            mock_request_new_token,
+            json_response["access_token"],
+        )
+
+        # Access token not expired -> use already retrieved token
+        auth_plugin.token_info = {
+            "refresh_token": "old-refresh-token",
+            "refresh_time": current_time - timedelta(seconds=1800),
+            "access_token": "old-access-token",
+            "token_time": current_time - timedelta(seconds=1800),
+            "access_token_expiration": 3600,
+            "refresh_token_expiration": 7200,
+        }
+        auth = auth_plugin.authenticate()
+        mock_request_new_token.assert_not_called()
+        mock_get_token_with_refresh_token.assert_not_called()
+        self.assertEqual(auth.token, auth_plugin.token_info["access_token"])
+
+    @mock.patch(
+        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.compute_state",
+        autospec=True,
+    )
+    @mock.patch(
+        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.exchange_code_for_token",
+        autospec=True,
+    )
+    @mock.patch(
+        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.authenticate_user",
+        autospec=True,
+    )
+    def test_plugins_auth_codeflowauth_authenticate_token_qs_key_ok(
+        self,
+        mock_authenticate_user,
+        mock_exchange_code_for_token,
+        mock_compute_state,
+    ):
+        """OIDCAuthorizationCodeFlowAuth.authenticate must return a CodeAuthorizedAuth object with a `key`
+        if `token_provision=="qs"`"""
+        auth_plugin = self.get_auth_plugin("provider_token_qs_key")
+        state = "1234567890123456789012"
+        exchange_url = auth_plugin.config.redirect_uri
+        json_response = {
+            "access_token": "obtained-access-token",
+            "expires_in": "3600",
+            "refresh_expires_in": "0",
+            "refresh_token": "obtained-refresh-token",
+        }
+        mock_authenticate_user.return_value = mock.Mock(url=exchange_url)
+        mock_compute_state.return_value = state
+        mock_exchange_code_for_token.return_value = MockResponse(json_response, 200)
+
+        auth = auth_plugin.authenticate()
+
+        mock_authenticate_user.assert_called_once_with(auth_plugin, state)
+        mock_exchange_code_for_token.assert_called_once_with(
+            auth_plugin, exchange_url, state
+        )
+        self.assertIsInstance(auth, CodeAuthorizedAuth)
+        self.assertEqual(auth.token, json_response["access_token"])
+        self.assertEqual(auth.where, "qs")
+        self.assertEqual(auth.key, auth_plugin.config.token_qs_key)
+
     @mock.patch(
         "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.authenticate_user",
         autospec=True,
         return_value=mock.Mock(url="http://foo.bar"),
     )
-    def test_plugins_auth_codeflowauth_authenticate_no_redirect(
+    def test_plugins_auth_codeflowauth_request_new_token_no_redirect(
         self,
         mock_authenticate_user,
     ):
-        """OIDCAuthorizationCodeFlowAuth.authenticate must raise and error if the provider doesn't redirect
-        to the given URI"""
+        """OIDCAuthorizationCodeFlowAuth.request_new_token must raise and error if the provider doesn't redirect
+        to `redirect_uri`"""
         auth_plugin = self.get_auth_plugin("provider_ok")
         self.assertRaises(
             AuthenticationError,
-            auth_plugin.authenticate,
+            auth_plugin._request_new_token,
         )
 
     @mock.patch(
@@ -962,31 +1142,34 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.authenticate_user",
         autospec=True,
     )
-    def test_plugins_auth_codeflowauth_authenticate_ok(
+    def test_plugins_auth_codeflowauth_request_new_token_ok(
         self,
         mock_authenticate_user,
         mock_exchange_code_for_token,
         mock_compute_state,
     ):
-        """OIDCAuthorizationCodeFlowAuth.authenticate must return a CodeAuthorizedAuth object"""
+        """OIDCAuthorizationCodeFlowAuth.request_new_token must return the JSON response from the auth server"""
         auth_plugin = self.get_auth_plugin("provider_ok")
         state = "1234567890123456789012"
         exchange_url = auth_plugin.config.redirect_uri
-        token = "token123"
+        json_response = {
+            "access_token": "obtained-access-token",
+            "expires_in": "3600",
+            "refresh_expires_in": "0",
+            "refresh_token": "obtained-refresh-token",
+        }
         mock_authenticate_user.return_value = mock.Mock(url=exchange_url)
         mock_compute_state.return_value = state
-        mock_exchange_code_for_token.return_value = token
+        mock_exchange_code_for_token.return_value.json.return_value = json_response
 
-        auth = auth_plugin.authenticate()
+        resp = auth_plugin._request_new_token()
 
         mock_authenticate_user.assert_called_once_with(auth_plugin, state)
         mock_exchange_code_for_token.assert_called_once_with(
             auth_plugin, exchange_url, state
         )
-        self.assertIsInstance(auth, CodeAuthorizedAuth)
-        self.assertEqual(auth.token, token)
-        self.assertEqual(auth.where, "header")
-        self.assertIsNone(auth.key)
+        # Check returned value is the server's JSON response
+        self.assertEqual(resp, json_response)
 
     @mock.patch(
         "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.grant_user_consent",
@@ -1004,26 +1187,32 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.authenticate_user",
         autospec=True,
     )
-    def test_plugins_auth_codeflowauth_authenticate_user_consent_needed_ok(
+    def test_plugins_auth_codeflowauth_request_new_token_user_consent_needed_ok(
         self,
         mock_authenticate_user,
         mock_exchange_code_for_token,
         mock_compute_state,
         mock_grant_user_consent,
     ):
-        """OIDCAuthorizationCodeFlowAuth.authenticate must call `grant_user_consent` if `user_consent_needed==True`"""
+        """OIDCAuthorizationCodeFlowAuth.request_new_token must call `grant_user_consent`
+        if `user_consent_needed==True`"""
         auth_plugin = self.get_auth_plugin("provider_user_consent")
         state = "1234567890123456789012"
         exchange_url = auth_plugin.config.redirect_uri
-        token = "token123"
+        json_response = {
+            "access_token": "obtained-access-token",
+            "expires_in": "3600",
+            "refresh_expires_in": "0",
+            "refresh_token": "obtained-refresh-token",
+        }
         mock_authenticate_user.return_value = mock.Mock(
             url=auth_plugin.config.redirect_uri + "/user_consent"
         )
         mock_compute_state.return_value = state
-        mock_exchange_code_for_token.return_value = token
+        mock_exchange_code_for_token.return_value = MockResponse(json_response, 200)
         mock_grant_user_consent.return_value = mock.Mock(url=exchange_url)
 
-        auth = auth_plugin.authenticate()
+        resp = auth_plugin._request_new_token()
 
         mock_authenticate_user.assert_called_once_with(auth_plugin, state)
         mock_grant_user_consent.assert_called_once_with(
@@ -1033,49 +1222,93 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         mock_exchange_code_for_token.assert_called_once_with(
             auth_plugin, exchange_url, state
         )
-        self.assertIsInstance(auth, CodeAuthorizedAuth)
-        self.assertEqual(auth.token, token)
-        self.assertEqual(auth.where, "header")
-        self.assertIsNone(auth.key)
+        self.assertEqual(resp, json_response)
 
     @mock.patch(
-        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.compute_state",
+        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.authenticate_user",
         autospec=True,
+        return_value=mock.Mock(
+            url="http://auth.foo/error?err_code=TERMS_AND_CONDITIONS"
+        ),
     )
+    def test_plugins_auth_codeflowauth_request_new_token_exchange_url_error_pattern(
+        self,
+        mock_authenticate_user,
+    ):
+        """OIDCAuthorizationCodeFlowAuth.request_new_token must raise an error if the exchange URL matches the
+        patter `exchange_url_error_pattern`"""
+        auth_plugin = self.get_auth_plugin("provider_ok")
+        with self.assertRaises(AuthenticationError) as context:
+            auth_plugin._request_new_token()
+        self.assertEqual(
+            "Terms and conditions are not accepted", str(context.exception)
+        )
+
     @mock.patch(
-        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.exchange_code_for_token",
+        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth._request_new_token",
         autospec=True,
     )
     @mock.patch(
-        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth.authenticate_user",
+        "eodag.plugins.authentication.openid_connect.requests.Session.post",
         autospec=True,
     )
-    def test_plugins_auth_codeflowauth_authenticate_token_qs_key_ok(
+    def test_plugins_auth_codeflowauth_get_token_with_refresh_token_ok(
         self,
-        mock_authenticate_user,
-        mock_exchange_code_for_token,
-        mock_compute_state,
+        mock_requests_post,
+        mock_request_new_token,
     ):
-        """OIDCAuthorizationCodeFlowAuth.authenticate must return a CodeAuthorizedAuth object with a `key`
-        if `token_provision=="qs"`"""
-        auth_plugin = self.get_auth_plugin("provider_token_qs_key")
-        state = "1234567890123456789012"
-        exchange_url = auth_plugin.config.redirect_uri
-        token = "token123"
-        mock_authenticate_user.return_value = mock.Mock(url=exchange_url)
-        mock_compute_state.return_value = state
-        mock_exchange_code_for_token.return_value = token
-
-        auth = auth_plugin.authenticate()
+        """OIDCAuthorizationCodeFlowAuth.get_token_with_refresh_token must call the token URI with a token refresh
+        request and return the JSON response"""
+        auth_plugin = self.get_auth_plugin("provider_ok")
+        auth_plugin.token_info = {"refresh_token": "old-refresh-token"}
+        token_data = auth_plugin._prepare_token_post_data(
+            {
+                "refresh_token": auth_plugin.token_info["refresh_token"],
+                "grant_type": "refresh_token",
+            }
+        )
+        json_response = {
+            "access_token": "obtained-access-token",
+            "expires_in": "3600",
+            "refresh_expires_in": "0",
+            "refresh_token": "obtained-refresh-token",
+        }
+        mock_requests_post.return_value = MockResponse(json_response, 200)
 
-        mock_authenticate_user.assert_called_once_with(auth_plugin, state)
-        mock_exchange_code_for_token.assert_called_once_with(
-            auth_plugin, exchange_url, state
+        resp = auth_plugin._get_token_with_refresh_token()
+        post_request_kwargs = {
+            auth_plugin.config.token_exchange_post_data_method: token_data
+        }
+        mock_requests_post.assert_called_once_with(
+            mock.ANY,
+            auth_plugin.config.token_uri,
+            timeout=HTTP_REQ_TIMEOUT,
+            **post_request_kwargs,
         )
-        self.assertIsInstance(auth, CodeAuthorizedAuth)
-        self.assertEqual(auth.token, token)
-        self.assertEqual(auth.where, "qs")
-        self.assertEqual(auth.key, auth_plugin.config.token_qs_key)
+        mock_request_new_token.assert_not_called()
+        self.assertEqual(resp, json_response)
+
+    @mock.patch(
+        "eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth._request_new_token",
+        autospec=True,
+    )
+    @mock.patch(
+        "eodag.plugins.authentication.openid_connect.requests.Session.post",
+        autospec=True,
+    )
+    def test_plugins_auth_codeflowauth_get_token_with_refresh_token_http_exception(
+        self,
+        mock_requests_post,
+        mock_request_new_token,
+    ):
+        """OIDCAuthorizationCodeFlowAuth.get_token_with_refresh_token must call `request_new_token()` if the POST
+        request raise and exception other than time out"""
+        auth_plugin = self.get_auth_plugin("provider_ok")
+        auth_plugin.token_info = {"refresh_token": "old-refresh-token"}
+        mock_requests_post.return_value = MockResponse({"err": "message"}, 500)
+
+        auth_plugin._get_token_with_refresh_token()
+        mock_request_new_token.assert_called_once()
 
     @mock.patch(
         "eodag.plugins.authentication.token.requests.Session.post", autospec=True
@@ -1108,31 +1341,6 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
             timeout=HTTP_REQ_TIMEOUT,
         )
 
-    @mock.patch(
-        "eodag.plugins.authentication.openid_connect.requests.Session.get",
-        autospec=True,
-    )
-    def test_plugins_auth_codeflowauth_authenticate_user_no_login_forms(
-        self,
-        mock_requests_post,
-    ):
-        """OIDCAuthorizationCodeFlowAuth.authenticate_user must assume user is already logged in if there is
-        no form in the reply"""
-        auth_plugin = self.get_auth_plugin("provider_ok")
-        auth_plugin.config.credentials = {"foo": "bar"}
-
-        # mock token post request response
-        mock_requests_post.return_value = mock.Mock()
-        mock_requests_post.return_value.text = """
-            <html>
-                <head><title>No forms</title></head>
-                <body><p>Hello</p></body>
-            </html>
-        """
-        state = "1234567890123456789012"
-        auth = auth_plugin.authenticate_user(state)
-        self.assertEqual(auth, mock_requests_post.return_value)
-
     @mock.patch(
         "eodag.plugins.authentication.openid_connect.requests.Session.get",
         autospec=True,
@@ -1327,10 +1535,13 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         and the state"""
         auth_plugin = self.get_auth_plugin("provider_ok")
         mock_requests_post.return_value = mock.Mock()
-        access_token = "token_12345"
-        mock_requests_post.return_value.json.return_value = {
-            "access_token": access_token
+        json_response = {
+            "access_token": "obtained-access-token",
+            "expires_in": "3600",
+            "refresh_expires_in": "0",
+            "refresh_token": "obtained-refresh-token",
         }
+        mock_requests_post.return_value = MockResponse(json_response, 200)
         state = "1234567890123456789012"
         auth_code = "code_abcde"
         authorized_url = (
@@ -1338,10 +1549,8 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         )
         auth_plugin.config.credentials = {"foo": "bar"}
 
-        returned_access_token = auth_plugin.exchange_code_for_token(
-            authorized_url, state
-        )
-        self.assertEqual(returned_access_token, access_token)
+        response = auth_plugin.exchange_code_for_token(authorized_url, state)
+        self.assertEqual(response.json()["access_token"], json_response["access_token"])
         mock_requests_post.assert_called_once_with(
             mock.ANY,
             auth_plugin.config.token_uri,
@@ -1367,10 +1576,13 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         `client_secret` is known"""
         auth_plugin = self.get_auth_plugin("provider_client_sercret")
         mock_requests_post.return_value = mock.Mock()
-        access_token = "token_12345"
-        mock_requests_post.return_value.json.return_value = {
-            "access_token": access_token
+        json_response = {
+            "access_token": "obtained-access-token",
+            "expires_in": "3600",
+            "refresh_expires_in": "0",
+            "refresh_token": "obtained-refresh-token",
         }
+        mock_requests_post.return_value = MockResponse(json_response, 200)
         state = "1234567890123456789012"
         auth_code = "code_abcde"
         authorized_url = (
@@ -1378,10 +1590,8 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         )
         auth_plugin.config.credentials = {"foo": "bar"}
 
-        returned_access_token = auth_plugin.exchange_code_for_token(
-            authorized_url, state
-        )
-        self.assertEqual(returned_access_token, access_token)
+        response = auth_plugin.exchange_code_for_token(authorized_url, state)
+        self.assertEqual(response.json()["access_token"], json_response["access_token"])
         mock_requests_post.assert_called_once_with(
             mock.ANY,
             auth_plugin.config.token_uri,
@@ -1412,10 +1622,13 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         auth_plugin = self.get_auth_plugin("provider_token_exchange_params")
 
         mock_requests_post.return_value = mock.Mock()
-        access_token = "token_12345"
-        mock_requests_post.return_value.json.return_value = {
-            "access_token": access_token
+        json_response = {
+            "access_token": "obtained-access-token",
+            "expires_in": "3600",
+            "refresh_expires_in": "0",
+            "refresh_token": "obtained-refresh-token",
         }
+        mock_requests_post.return_value = MockResponse(json_response, 200)
         state = "1234567890123456789012"
         auth_code = "code_abcde"
         authorized_url = (
@@ -1423,10 +1636,8 @@ class TestAuthPluginOIDCAuthorizationCodeFlowAuth(BaseAuthPluginTest):
         )
         auth_plugin.config.credentials = {"foo": "bar"}
 
-        returned_access_token = auth_plugin.exchange_code_for_token(
-            authorized_url, state
-        )
-        self.assertEqual(returned_access_token, access_token)
+        response = auth_plugin.exchange_code_for_token(authorized_url, state)
+        self.assertEqual(response.json()["access_token"], json_response["access_token"])
         mock_requests_post.assert_called_once_with(
             mock.ANY,
             auth_plugin.config.token_uri,
diff --git a/tests/units/test_stac_utils.py b/tests/units/test_stac_utils.py
index 8945fcfd..57ad4f14 100644
--- a/tests/units/test_stac_utils.py
+++ b/tests/units/test_stac_utils.py
@@ -255,10 +255,15 @@ class TestStacUtils(unittest.TestCase):
 
     def test_fetch_external_stac_collections(self):
         """Load external STAC collections"""
+
         external_json = """{
             "new_field":"New Value",
             "title":"A different title for Sentinel 2 MSI Level 1C",
-            "keywords":["New Keyword"]
+            "keywords":["New Keyword"],
+            "links":[
+                {"rel":"self","href":"http://another.self"},
+                {"rel":"license","href":"http://foo.bar"}
+            ]
         }"""
         product_type_conf = self.rest_core.eodag_api.product_types_config["S2_MSI_L1C"]
         ext_stac_collection_path = "/path/to/external/stac/collections/S2_MSI_L1C.json"
@@ -275,14 +280,24 @@ class TestStacUtils(unittest.TestCase):
                 url="", root="", collection_id="S2_MSI_L1C"
             )
             mock_fetch_json.assert_called_with(ext_stac_collection_path)
+
             # New field
             self.assertIn("new_field", stac_coll)
-            # Merge keywords
+
+            # links
+            self.assertEqual(len(stac_coll["links"]), 4)
+            links_self = [x for x in stac_coll["links"] if x["rel"] == "self"]
+            self.assertEqual(len(links_self), 1)
+            self.assertNotEqual(links_self[0]["href"], "http://another.self")
+            links_license = [x for x in stac_coll["links"] if x["rel"] == "license"]
+            self.assertEqual(links_license[0]["href"], "http://foo.bar")
+
+            # Merged keywords
             self.assertListEqual(
-                ["MSI", "SENTINEL2", "S2A,S2B", "L1", "OPTICAL", "New Keyword"],
-                stac_coll["keywords"],
+                ["L1", "MSI", "New Keyword", "OPTICAL", "S2A,S2B", "SENTINEL2"],
+                sorted(stac_coll["keywords"]),
             )
-            # Override existing fields
+            # Overriden existing fields
             self.assertEqual(
                 "A different title for Sentinel 2 MSI Level 1C", stac_coll["title"]
             )