diff --git a/CHANGELOG.md b/CHANGELOG.md index f92231a73..090d67b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Clarify UDF documentation on required function type annotations. ([#757](https://github.com/Open-EO/openeo-python-client/issues/757)) +- `OidcProviderInfo` no longer drops requested OIDC scopes (including the "offline_access" scope used for refresh tokens) that are not listed in the provider's `scopes_supported` discovery field, which made it impossible to authenticate against such providers. ([#930](https://github.com/Open-EO/openeo-python-client/issues/930)) ## [0.51.0] - 2026-07-16 diff --git a/openeo/rest/auth/oidc.py b/openeo/rest/auth/oidc.py index 5120d5f37..d65abdab1 100644 --- a/openeo/rest/auth/oidc.py +++ b/openeo/rest/auth/oidc.py @@ -279,10 +279,12 @@ def __init__( except Exception as e: raise OidcException(f"Failed to obtain OIDC discovery document from {self.discovery_url!r}: {e!r}") from e self.issuer = issuer or self.config["issuer"] - # Minimal set of scopes to request - self._supported_scopes = self.config.get("scopes_supported", ["openid"]) - self._scopes = {"openid"}.union(scopes or []).intersection(self._supported_scopes) - log.debug(f"Scopes: provider supported {self._supported_scopes} & backend desired {scopes} -> {self._scopes}") + # Note: we don't filter requested scopes against the discovery document's + # `scopes_supported`: it's only a RECOMMENDED discovery field (RFC 8414 section 2), + # and some providers (e.g. Microsoft Entra ID) report a fixed, incomplete list there + # regardless of which scopes they actually accept. + self._scopes = {"openid"}.union(scopes or []) + log.debug(f"Scopes: backend desired {scopes} -> {self._scopes}") self.default_clients = default_clients self.authorization_parameters = authorization_parameters or {} @@ -301,12 +303,12 @@ def get_scopes_string(self, request_refresh_token: bool = False) -> str: """ Build "scope" string for authentication request. - :param request_refresh_token: include "offline_access" scope (if supported), + :param request_refresh_token: include "offline_access" scope, which some OIDC providers require in order to return refresh token :return: space separated scope listing as single string """ scopes = self._scopes - if request_refresh_token and "offline_access" in self._supported_scopes: + if request_refresh_token: scopes = scopes | {"offline_access"} log.debug("Using scopes: {s}".format(s=scopes)) return " ".join(sorted(scopes)) diff --git a/tests/rest/auth/test_cli.py b/tests/rest/auth/test_cli.py index 7870ca09d..32950ea7a 100644 --- a/tests/rest/auth/test_cli.py +++ b/tests/rest/auth/test_cli.py @@ -362,7 +362,7 @@ def test_oidc_auth_device_flow(auth_config, refresh_token_store, requests_mock, expected_grant_type="urn:ietf:params:oauth:grant-type:device_code", expected_client_id=client_id, oidc_issuer="https://authit.test", - expected_fields={"scope": "openid", "client_secret": client_secret}, + expected_fields={"scope": "offline_access openid", "client_secret": client_secret}, state={"device_code_callback_timeline": ["authorization_pending", "great success"]}, scopes_supported=["openid"], ) @@ -421,7 +421,7 @@ def test_oidc_auth_device_flow_default_client( expected_grant_type="urn:ietf:params:oauth:grant-type:device_code", expected_client_id=default_client_id, oidc_issuer="https://authit.test", - expected_fields={"scope": "openid", "code_verifier": True, "code_challenge": True}, + expected_fields={"scope": "offline_access openid", "code_verifier": True, "code_challenge": True}, state={"device_code_callback_timeline": ["authorization_pending", "great success"]}, scopes_supported=["openid"], ) @@ -479,7 +479,7 @@ def test_oidc_auth_device_flow_no_config_all_defaults( expected_grant_type="urn:ietf:params:oauth:grant-type:device_code", expected_client_id=default_client_id, oidc_issuer="https://authit.test", - expected_fields={"scope": "openid", "code_verifier": True, "code_challenge": True}, + expected_fields={"scope": "offline_access openid", "code_verifier": True, "code_challenge": True}, state={"device_code_callback_timeline": ["authorization_pending", "great success"]}, scopes_supported=["openid"], ) @@ -529,7 +529,7 @@ def test_oidc_auth_auth_code_flow(auth_config, refresh_token_store, requests_moc requests_mock=requests_mock, expected_grant_type="authorization_code", expected_client_id=client_id, - expected_fields={"scope": "openid"}, + expected_fields={"scope": "offline_access openid"}, oidc_issuer="https://authit.test", scopes_supported=["openid"], ) diff --git a/tests/rest/auth/test_oidc.py b/tests/rest/auth/test_oidc.py index 3d0cc73dd..6d49c07e0 100644 --- a/tests/rest/auth/test_oidc.py +++ b/tests/rest/auth/test_oidc.py @@ -148,6 +148,23 @@ def test_provider_info_scopes(requests_mock): ).get_scopes_string() +def test_provider_info_scopes_not_in_scopes_supported(requests_mock): + """ + Requested scopes should be preserved even when the provider's `scopes_supported` + discovery field does not list them (e.g. Microsoft Entra ID, which reports a fixed + tenant-wide list there regardless of which custom scopes it actually accepts). + https://github.com/Open-EO/openeo-python-client/issues/930 + """ + requests_mock.get( + "https://authit.test/.well-known/openid-configuration", + json={"scopes_supported": ["openid", "profile", "email", "offline_access"]}, + ) + provider = OidcProviderInfo( + issuer="https://authit.test", scopes=["openid", "profile", "email", "api://client-id/openeo"] + ) + assert provider.get_scopes_string() == "api://client-id/openeo email openid profile" + + def test_provider_info_default_client_none(requests_mock): requests_mock.get("https://authit.test/.well-known/openid-configuration", json={}) info = OidcProviderInfo(issuer="https://authit.test") @@ -221,18 +238,23 @@ def test_provider_info_default_client_invalid_grants(requests_mock, caplog): @pytest.mark.parametrize( - ["scopes_supported", "expected"], [ - (["openid", "email"], "openid"), - (["openid", "email", "offline_access"], "offline_access openid"), + "scopes_supported", + [ + ["openid", "email"], + ["openid", "email", "offline_access"], ]) -def test_provider_info_get_scopes_string_refresh_token_offline_access(requests_mock, scopes_supported, expected): +def test_provider_info_get_scopes_string_refresh_token_offline_access(requests_mock, scopes_supported): + """ + "offline_access" should be requested when a refresh token is desired, + regardless of whether the provider's `scopes_supported` discovery field lists it. + """ requests_mock.get( "https://authit.test/.well-known/openid-configuration", json={"scopes_supported": scopes_supported} ) p = OidcProviderInfo(issuer="https://authit.test") assert p.get_scopes_string() == "openid" - assert p.get_scopes_string(request_refresh_token=True) == expected + assert p.get_scopes_string(request_refresh_token=True) == "offline_access openid" assert p.get_scopes_string() == "openid" diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index e59d88176..2e1bb10be 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -988,7 +988,7 @@ def test_authenticate_oidc_authorization_code_100_multiple_success(requests_mock [ (False, ["openid", "email"], "openid"), (False, ["openid", "email", "offline_access"], "openid"), - (True, ["openid", "email"], "openid"), + (True, ["openid", "email"], "offline_access openid"), (True, ["openid", "email", "offline_access"], "offline_access openid"), ] ) @@ -1364,7 +1364,7 @@ def test_authenticate_oidc_resource_owner_password_credentials_client_from_confi [ (False, ["openid", "email"], "openid"), (False, ["openid", "email", "offline_access"], "openid"), - (True, ["openid", "email"], "openid"), + (True, ["openid", "email"], "offline_access openid"), (True, ["openid", "email", "offline_access"], "offline_access openid"), ] ) @@ -1774,9 +1774,7 @@ def test_authenticate_oidc_device_flow_pkce_store_refresh_token(requests_mock, o ] }) - expected_fields = { - "scope": "openid", "code_verifier": True, "code_challenge": True - } + expected_fields = {"scope": "offline_access openid", "code_verifier": True, "code_challenge": True} oidc_issuer = "https://auth.test" oidc_mock = OidcMock( requests_mock=requests_mock, @@ -1971,7 +1969,7 @@ def test_authenticate_oidc_auto_no_existing_refresh_token( oidc_issuer=issuer, expected_fields={ "refresh_token": "unkn0wn", - "scope": "openid", + "scope": "offline_access openid", "code_verifier": True if expect_pkce else ABSENT, "code_challenge": True if expect_pkce else ABSENT, } @@ -2016,7 +2014,7 @@ def test_authenticate_oidc_auto_expired_refresh_token( oidc_issuer=issuer, expected_fields={ "refresh_token": "unkn0wn", - "scope": "openid", + "scope": "offline_access openid", "code_verifier": True if expect_pkce else ABSENT, "code_challenge": True if expect_pkce else ABSENT, } @@ -2225,7 +2223,7 @@ def test_authenticate_oidc_auto_renew_expired_access_token_initial_device_code( expected_client_id=client_id, oidc_issuer=oidc_issuer, expected_fields={ - "scope": "openid", + "scope": "offline_access openid", "code_verifier": True, "code_challenge": True, }, @@ -2323,7 +2321,7 @@ def test_authenticate_oidc_auto_renew_expired_access_token_invalid_refresh_token expected_client_id=client_id, oidc_issuer=oidc_issuer, expected_fields={ - "scope": "openid", + "scope": "offline_access openid", "code_verifier": True, "code_challenge": True, }, @@ -2664,7 +2662,7 @@ def test_try_access_token_refresh_initial_device_code( expected_client_id=client_id, oidc_issuer=oidc_issuer, expected_fields={ - "scope": "openid", + "scope": "offline_access openid", "code_verifier": True, "code_challenge": True, }, @@ -4931,11 +4929,7 @@ def test_connect_auto_auth_from_config_oidc_device_code( }, ) - expected_fields = { - "scope": "openid", - "code_verifier": True, - "code_challenge": True - } + expected_fields = {"scope": "offline_access openid", "code_verifier": True, "code_challenge": True} oidc_mock = OidcMock( requests_mock=requests_mock, expected_grant_type="urn:ietf:params:oauth:grant-type:device_code",