Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 8 additions & 6 deletions openeo/rest/auth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand All @@ -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))
Expand Down
8 changes: 4 additions & 4 deletions tests/rest/auth/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)
Expand Down Expand Up @@ -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"],
)
Expand Down Expand Up @@ -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"],
)
Expand Down Expand Up @@ -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"],
)
Expand Down
32 changes: 27 additions & 5 deletions tests/rest/auth/test_oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"


Expand Down
24 changes: 9 additions & 15 deletions tests/rest/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
)
Expand Down Expand Up @@ -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"),
]
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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",
Expand Down