From c900af6be43e0c0aa022230755ce7fc2faa32960 Mon Sep 17 00:00:00 2001 From: Guillaume Pujol Date: Fri, 2 May 2025 15:48:09 +0200 Subject: [PATCH 1/2] feature: support for RFC9728, #121 --- README.md | 56 +++++++++ requests_oauth2client/__init__.py | 8 ++ requests_oauth2client/api_client.py | 139 ++++++++++++++++++++++ requests_oauth2client/discovery.py | 45 +++++-- requests_oauth2client/exceptions.py | 42 ++++++- requests_oauth2client/utils.py | 2 +- tests/test_protected_resource_metadata.py | 130 ++++++++++++++++++++ 7 files changed, 411 insertions(+), 11 deletions(-) create mode 100644 tests/test_protected_resource_metadata.py diff --git a/README.md b/README.md index 024c61e7..70196b68 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ It also supports [OpenID Connect 1.0](https://openid.net/specs/openid-connect-co [Pushed Authorization Requests](https://datatracker.ietf.org/doc/rfc9126/), [Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html), [Demonstrating Proof of Possession](https://www.rfc-editor.org/rfc/rfc9449.html), +[Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html), as well as using custom params to any endpoint, and other important features that are often overlooked or needlessly complex in other client libraries. @@ -1149,6 +1150,61 @@ api = ApiClient("https://myapi.local/resource", session=session) assert api.session == session ``` +### OAuth2.0 Protected Resource Metadata + +`ApiClient` supports OAuth2.0 Protected Resource Metadata, as defined in RFC9728. You can initialize your `ApiClient` +this way: + +```python +from requests_oauth2client import ApiClient, OAuth2Client, OAuth2ClientCredentialsAuth + +# you need to initialize your client first, either with a discovery endpoint, or with any other method +oauth2client = OAuth2Client.from_discovery_endpoint( + issuer="https://youras.local", + client_id="your_client_id", + client_secret="your_client_secret", +) + +api = ApiClient.from_metadata_endpoint( + resource="https://yourapi.local", + auth=OAuth2ClientCredentialsAuth(oauth2client, scope="my_scope"), # use any other grant type as required +) +``` + +Compared to the initializing an ApiClient directly, this will: +- fetch the protected resource metadata from the RFC9728 metadata endpoint, and check that the mentionned `resource` +matches the one you passed as parameter. +- check that the client `issuer` from the auth handler is mentioned in the resource metadata. You can skip this check +by passing `check_issuer=False` as parameter to `ApiClient.from_metadata_endpoint()`. +- if the resource metadata enforces the use of `DPoP` tokens, it will automatically enable `DPoP` for the client and +auth handler. It also checks that the client defined +- initialize the API `base_url` to the same value as its resource identifier. You may override this value (typically, to +include additional path segments) by passing a `base_url` as parameter to `ApiClient.from_metadata_endpoint()`. +- any other parameter passed to `ApiClient.from_metadata_endpoint()` will be passed to the `ApiClient` constructor. + +You may also initialize an `ApiClient` based on a metadata document that you already fetched and decoded. In that case, +you can use `ApiClient.from_metadata_document()`: + +```python +from requests_oauth2client import ApiClient, OAuth2Client, OAuth2ClientCredentialsAuth + +# you need to initialize your client first, either with a discovery endpoint, or with any other method +oauth2client = OAuth2Client.from_discovery_endpoint( + issuer="https://youras.local", + client_id="your_client_id", + client_secret="your_client_secret", +) +protected_resource_metadata = { + "resource": "https://yourapi.local", + "authorization_servers": ["https://youras.local"], +} + +api = ApiClient.from_metadata_document( + protected_resource_metadata, + auth=OAuth2ClientCredentialsAuth(oauth2client, scope="my_scope"), # use any other grant type as required +) +``` + ## Vendor-Specific clients `requests_oauth2client` is flexible enough to handle most use cases, so you should be able to use any AS by any vendor diff --git a/requests_oauth2client/__init__.py b/requests_oauth2client/__init__.py index 030fc191..74144ec6 100644 --- a/requests_oauth2client/__init__.py +++ b/requests_oauth2client/__init__.py @@ -72,7 +72,9 @@ DeviceAuthorizationResponse, ) from .discovery import ( + WellKnownDocument, oauth2_discovery_document_url, + oauth2_protected_resource_metadata_url, oidc_discovery_document_url, well_known_uri, ) @@ -111,7 +113,9 @@ InvalidTarget, InvalidTokenResponse, LoginRequired, + MismatchingAuthorizationServerIdentifier, MismatchingIssuer, + MismatchingResourceIdentifier, MismatchingState, MissingAuthCode, MissingIssuer, @@ -220,6 +224,7 @@ "InvalidUseDPoPNonceResponse", "KeyManagementAlgs", "LoginRequired", + "MismatchingAuthorizationServerIdentifier", "MismatchingIdTokenAcr", "MismatchingIdTokenAlg", "MismatchingIdTokenAudience", @@ -227,6 +232,7 @@ "MismatchingIdTokenIssuer", "MismatchingIdTokenNonce", "MismatchingIssuer", + "MismatchingResourceIdentifier", "MismatchingState", "MissingAuthCode", "MissingAuthRequestId", @@ -270,7 +276,9 @@ "UnsupportedResponseTypeParam", "UnsupportedTokenType", "UseDPoPNonce", + "WellKnownDocument", "oauth2_discovery_document_url", + "oauth2_protected_resource_metadata_url", "oidc_discovery_document_url", "requests", "validate_dpop_proof", diff --git a/requests_oauth2client/api_client.py b/requests_oauth2client/api_client.py index 84c04ece..dbb9737c 100644 --- a/requests_oauth2client/api_client.py +++ b/requests_oauth2client/api_client.py @@ -11,11 +11,22 @@ from attrs import frozen from typing_extensions import Literal, Self +from requests_oauth2client.discovery import WellKnownDocument, well_known_uri +from requests_oauth2client.exceptions import ( + FailedDiscoveryError, + InvalidDiscoveryDocument, + MismatchingAuthorizationServerIdentifier, + MismatchingResourceIdentifier, +) +from requests_oauth2client.utils import InvalidUri, validate_endpoint_uri + if TYPE_CHECKING: from types import TracebackType from requests.cookies import RequestsCookieJar + from requests_oauth2client import OAuth2AccessTokenAuth + class InvalidBoolFieldsParam(ValueError): """Raised when an invalid value is passed as 'bool_fields' parameter.""" @@ -330,6 +341,134 @@ def request( # noqa: C901, PLR0913, D417 response.raise_for_status() return response + @classmethod + def from_metadata_document( + cls, + document: Mapping[str, Any], + auth: OAuth2AccessTokenAuth, + *, + document_url: str | None = None, + base_url: str | None = None, + check_issuer: bool = True, + **kwargs: Any, + ) -> Self: + """Create an `ApiClient` from a protected resource metadata document (RFC9728). + + This will create an `ApiClient` instance with the resource url as base_url, and + the client as auth handler. + + This method will check that the `resource` key in the document matches the `resource` parameter. + If `check_issuer` is `True`, it will also check that the `authorization_servers` key in the document + matches the issuer of the client passed as parameter. An exception will be raised if any of those checks fails. + + Args: + resource: the resource identifier + auth: the OAuth2AccessTokenAuth to use as auth handler + document: the metadata document + document_url: the url of the metadata document + base_url: the base url to use for the API client (if different from the resource identifier) + check_issuer: if `True`, check that the client issuer is in the `authorization_servers` from this document + **kwargs: additional kwargs for the ApiClient + + Raises: + InvalidDiscoveryDocument: if the document is not a valid JSON object + MismatchingResource: if the `resource` key in the document does not match the `resource` parameter + FailedDiscoveryError: if the `authorization_servers` key in the document does not match the client issuer + """ + if "resource" not in document or not isinstance(document["resource"], str): + msg = "missing `resource` key in document" + raise InvalidDiscoveryDocument(msg, metadata=document, url=document_url) + + resource = document["resource"] + try: + validate_endpoint_uri(resource, path=False) + except InvalidUri as exc: + msg = "invalid `resource` identifier in document." + raise InvalidDiscoveryDocument( + msg, + metadata=document, + url=document_url, + ) from exc + + if check_issuer and ( + auth.client.issuer is None or auth.client.issuer not in document.get("authorization_servers", []) + ): + raise MismatchingAuthorizationServerIdentifier( + expected=auth.client.issuer, + metadata=document, + url=document_url, + ) + + if document.get("dpop_bound_access_tokens_required", False): + auth.token_kwargs["dpop"] = True + if auth.client.dpop_alg not in document.get("dpop_signing_alg_values_supported", []): + FailedDiscoveryError( + "mismatching `dpop_signing_alg_values_supported` key in document", + metadata=document, + url=document_url, + ) + + if base_url is None: + base_url = resource + + return cls(base_url=base_url, auth=auth, **kwargs) + + @classmethod + def from_metadata_endpoint( + cls, + resource: str, + auth: OAuth2AccessTokenAuth, + *, + session: requests.Session | None = None, + document: str = WellKnownDocument.OAUTH_PROTECTED_RESOURCE, + at_root: bool = True, + **kwargs: Any, + ) -> Self: + """Create an `ApiClient` from a protected resource metadata (RFC9728). + + This will create an `ApiClient` instance with the resource url as base_url, and + the client as auth handler. + + Args: + resource: the resource url + auth: the OAuth2AccessTokenAuth subclass instance to use as auth handler + session: a requests.Session to use for the request + document: the metadata document to fetch. + at_root: if `True`, the document will be fetched from the root of the resource url + **kwargs: additional kwargs for the ApiClient + + Returns: + an `ApiClient` instance + + Raises: + InvalidDiscoveryDocument: if the document is not a valid JSON object + MismatchingResourceIdentifier: if the `resource` key in the document does not match the `resource` parameter + + """ + session = session or requests.Session() + document_url = well_known_uri(resource, document, at_root=at_root) + metadata = session.get(document_url, headers={"Accept": "application/json"}).json() + + if not isinstance(metadata, dict): + msg = "Invalid document: must be a JSON object" + raise InvalidDiscoveryDocument( + msg, + metadata=metadata, + url=document_url, + ) + + if metadata["resource"] != resource: + raise MismatchingResourceIdentifier(expected=resource, url=document_url, metadata=metadata) + + return cls.from_metadata_document( + resource=resource, + auth=auth, + document=metadata, + document_url=document_url, + session=session, + **kwargs, + ) + def to_absolute_url(self, path: None | str | bytes | Iterable[str | bytes | int] = None) -> str: """Convert a relative url to an absolute url. diff --git a/requests_oauth2client/discovery.py b/requests_oauth2client/discovery.py index ba650664..961957b9 100644 --- a/requests_oauth2client/discovery.py +++ b/requests_oauth2client/discovery.py @@ -8,6 +8,14 @@ from furl import Path, furl # type: ignore[import-untyped] +class WellKnownDocument: + """Some well-known documents for use with RFC5785.""" + + OPENID_CONFIGURATION = "openid-configuration" + OAUTH_AUTHORIZATION_SERVER = "oauth-authorization-server" + OAUTH_PROTECTED_RESOURCE = "oauth-protected-resource" + + def well_known_uri(origin: str, name: str, *, at_root: bool = True) -> str: """Return the location of a well-known document on an origin url. @@ -17,19 +25,16 @@ def well_known_uri(origin: str, name: str, *, at_root: bool = True) -> str: Args: origin: origin to use to build the well-known uri. name: document name to use to build the well-known uri. - at_root: if `True`, assume the well-known document is at root level (as defined in [RFC8615](https://datatracker.ietf.org/doc/html/rfc8615)). - If `False`, assume the well-known location is per-directory, as defined in [OpenID - Connect Discovery - 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). + at_root: if `True`, assume the well-known document is at root level, as defined in [RFC8615](https://datatracker.ietf.org/doc/html/rfc8615). + If `False`, assume the well-known location is per-directory, as defined in [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). Returns: - the well-know uri, relative to origin, where the well-known document named `name` should be - found. + the well-know uri, relative to origin, where the well-known document `name` should be found. """ url = furl(origin) if at_root: - url.path = Path(".well-known") / url.path / name + url.path.set(Path(".well-known") / url.path / name) else: url.path.add(Path(".well-known") / name) return str(url) @@ -52,7 +57,7 @@ def oidc_discovery_document_url(issuer: str) -> str: made. """ - return well_known_uri(issuer, "openid-configuration", at_root=False) + return well_known_uri(issuer, WellKnownDocument.OPENID_CONFIGURATION, at_root=False) def oauth2_discovery_document_url(issuer: str) -> str: @@ -72,4 +77,26 @@ def oauth2_discovery_document_url(issuer: str) -> str: made. """ - return well_known_uri(issuer, "oauth-authorization-server", at_root=True) + return well_known_uri(issuer, WellKnownDocument.OAUTH_AUTHORIZATION_SERVER, at_root=True) + + +def oauth2_protected_resource_metadata_url( + resource: str, +) -> str: + """Construct the standardised OAuth 2.0 protected resource metadata url for a given `resource`. + + Based on a `resource` identifier, returns the standardised URL where the OAuth20 server metadata can + be retrieved. + + The returned URL is built as specified in + [RFC9728](https://datatracker.ietf.org/doc/html/rfc9728). + + Args: + resource: an OAuth20 protected resource `resource` + + Returns: + the standardised discovery document URL. Note that no attempt to fetch this document is + made. + + """ + return well_known_uri(resource, WellKnownDocument.OAUTH_PROTECTED_RESOURCE, at_root=True) diff --git a/requests_oauth2client/exceptions.py b/requests_oauth2client/exceptions.py index b7d4180e..88e7db10 100644 --- a/requests_oauth2client/exceptions.py +++ b/requests_oauth2client/exceptions.py @@ -2,9 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from collections.abc import Mapping + import requests from requests_oauth2client.authorization_request import AuthorizationRequest @@ -264,3 +266,41 @@ class InvalidBackChannelAuthenticationResponse(OAuth2Error): class InvalidPushedAuthorizationResponse(OAuth2Error): """Raised when the Pushed Authorization Endpoint returns an error.""" + + +class FailedDiscoveryError(Exception): + """Raised when a metadata discovery fails.""" + + def __init__(self, message: str, metadata: Mapping[str, Any] | None = None, url: str | None = None) -> None: + super().__init__(message) + self.message = message + self.url = url + self.metadata = metadata + + +class InvalidDiscoveryDocument(FailedDiscoveryError): + """Raised when the discovery document is invalid.""" + + +class MismatchingResourceIdentifier(FailedDiscoveryError): + """Raised when the resource in the discovery document does not match the expected value.""" + + def __init__(self, expected: str, metadata: Mapping[str, Any], url: str | None = None) -> None: + super().__init__( + message=f"mismatching `resource` identifier: expected '{expected}', got '{metadata['resource']}", + metadata=metadata, + url=url, + ) + self.expected = expected + + +class MismatchingAuthorizationServerIdentifier(FailedDiscoveryError): + """Raised when the issuer in the discovery document does not match the expected value.""" + + def __init__(self, expected: str | None, metadata: Mapping[str, Any], url: str | None = None) -> None: + super().__init__( + message=f"mismatching `issuer` identifier: expected '{expected}', got '{metadata['authorization_servers']}", + metadata=metadata, + url=url, + ) + self.expected = expected diff --git a/requests_oauth2client/utils.py b/requests_oauth2client/utils.py index ce22abef..7a853105 100644 --- a/requests_oauth2client/utils.py +++ b/requests_oauth2client/utils.py @@ -79,7 +79,7 @@ def validate_endpoint_uri( path: if `True`, check that the uri contains a path component Raises: - ValueError: if the supplied url is not suitable + InvalidUri: if the supplied url is not suitable Returns: the endpoint URI, if all checks passed diff --git a/tests/test_protected_resource_metadata.py b/tests/test_protected_resource_metadata.py new file mode 100644 index 00000000..2bf1c18b --- /dev/null +++ b/tests/test_protected_resource_metadata.py @@ -0,0 +1,130 @@ +import pytest + +from requests_oauth2client import ( + ApiClient, + MismatchingAuthorizationServerIdentifier, + MismatchingResourceIdentifier, + OAuth2Client, + OAuth2ClientCredentialsAuth, +) +from tests.conftest import RequestsMocker + + +def test_rfc9728_offline() -> None: + """Test the RFC 9728 example.""" + client = OAuth2Client.from_discovery_document( + {"issuer": "https://as.local", "token_endpoint": "https://as.local/token"}, + client_id="myclientid", + client_secret="myclientsecret", + ) + auth = OAuth2ClientCredentialsAuth(client=client) + + api = ApiClient.from_metadata_document( + resource="https://api.local", + document={ + "resource": "https://api.local", + "authorization_servers": ["https://as.local"], + }, + auth=auth, + ) + + assert api.base_url == "https://api.local" + assert api.auth is auth + + +def test_rfc9728_get(requests_mock: RequestsMocker) -> None: + requests_mock.get( + "https://api.local/.well-known/oauth-protected-resource", + json={ + "resource": "https://api.local", + "authorization_servers": ["https://as.local"], + }, + ) + client = OAuth2Client.from_discovery_document( + {"issuer": "https://as.local", "token_endpoint": "https://as.local/token"}, + client_id="myclientid", + client_secret="myclientsecret", + ) + auth = OAuth2ClientCredentialsAuth(client=client) + api = ApiClient.from_metadata_endpoint( + resource="https://api.local", + auth=auth, + ) + prm_request = requests_mock.last_request + assert prm_request is not None + assert prm_request.method == "GET" + assert prm_request.url == "https://api.local/.well-known/oauth-protected-resource" + assert prm_request.headers["Accept"] == "application/json" + assert "Authorization" not in prm_request.headers + + assert api.base_url == "https://api.local" + assert api.auth is auth + + +def test_enable_dpop() -> None: + """If the resource metadata enforces DPoP, auto-enable it for the auth handler.""" + client = OAuth2Client.from_discovery_document( + {"issuer": "https://as.local", "token_endpoint": "https://as.local/token"}, + client_id="myclientid", + client_secret="myclientsecret", + ) + auth = OAuth2ClientCredentialsAuth(client=client, scope="foo") + assert "dpop" not in auth.token_kwargs + + api = ApiClient.from_metadata_document( + resource="https://api.local", + document={ + "resource": "https://api.local", + "authorization_servers": ["https://as.local"], + "dpop_bound_access_tokens_required": True, + }, + auth=auth, + ) + + assert api.base_url == "https://api.local" + assert api.auth is auth + assert isinstance(api.auth, OAuth2ClientCredentialsAuth) + assert api.auth.token_kwargs["dpop"] is True + + +def test_mismatching_resource(requests_mock: RequestsMocker) -> None: + """Test that a mismatching resource raises an error.""" + client = OAuth2Client.from_discovery_document( + {"issuer": "https://as.local", "token_endpoint": "https://as.local/token"}, + client_id="myclientid", + client_secret="myclientsecret", + ) + auth = OAuth2ClientCredentialsAuth(client=client, scope="foo") + + requests_mock.get( + "https://api.local/.well-known/oauth-protected-resource", + json={ + "resource": "https://other.local", + "authorization_servers": ["https://as.local"], + }, + ) + with pytest.raises(MismatchingResourceIdentifier): + ApiClient.from_metadata_endpoint( + resource="https://api.local", + auth=auth, + ) + + +def test_mismatching_auth_server() -> None: + """Test that a mismatching auth server raises an error.""" + client = OAuth2Client.from_discovery_document( + {"issuer": "https://as.local", "token_endpoint": "https://as.local/token"}, + client_id="myclientid", + client_secret="myclientsecret", + ) + auth = OAuth2ClientCredentialsAuth(client=client, scope="foo") + + with pytest.raises(MismatchingAuthorizationServerIdentifier): + ApiClient.from_metadata_document( + resource="https://api.local", + document={ + "resource": "https://api.local", + "authorization_servers": ["https://other.local"], + }, + auth=auth, + ) From c8b1f41104ca491d32804a7414e9160f07ba4cf5 Mon Sep 17 00:00:00 2001 From: Guillaume Pujol Date: Fri, 2 May 2025 16:04:06 +0200 Subject: [PATCH 2/2] fix leftover arg in docstring --- requests_oauth2client/api_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/requests_oauth2client/api_client.py b/requests_oauth2client/api_client.py index dbb9737c..8980df4d 100644 --- a/requests_oauth2client/api_client.py +++ b/requests_oauth2client/api_client.py @@ -362,7 +362,6 @@ def from_metadata_document( matches the issuer of the client passed as parameter. An exception will be raised if any of those checks fails. Args: - resource: the resource identifier auth: the OAuth2AccessTokenAuth to use as auth handler document: the metadata document document_url: the url of the metadata document