Skip to content

feature: support for RFC9728, #121 #145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions requests_oauth2client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -111,7 +113,9 @@
InvalidTarget,
InvalidTokenResponse,
LoginRequired,
MismatchingAuthorizationServerIdentifier,
MismatchingIssuer,
MismatchingResourceIdentifier,
MismatchingState,
MissingAuthCode,
MissingIssuer,
Expand Down Expand Up @@ -220,13 +224,15 @@
"InvalidUseDPoPNonceResponse",
"KeyManagementAlgs",
"LoginRequired",
"MismatchingAuthorizationServerIdentifier",
"MismatchingIdTokenAcr",
"MismatchingIdTokenAlg",
"MismatchingIdTokenAudience",
"MismatchingIdTokenAzp",
"MismatchingIdTokenIssuer",
"MismatchingIdTokenNonce",
"MismatchingIssuer",
"MismatchingResourceIdentifier",
"MismatchingState",
"MissingAuthCode",
"MissingAuthRequestId",
Expand Down Expand Up @@ -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",
Expand Down
138 changes: 138 additions & 0 deletions requests_oauth2client/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -330,6 +341,133 @@
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:
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)

Check warning on line 379 in requests_oauth2client/api_client.py

View check run for this annotation

Codecov / codecov/patch

requests_oauth2client/api_client.py#L378-L379

Added lines #L378 - L379 were not covered by tests

resource = document["resource"]
try:
validate_endpoint_uri(resource, path=False)
except InvalidUri as exc:
msg = "invalid `resource` identifier in document."
raise InvalidDiscoveryDocument(

Check warning on line 386 in requests_oauth2client/api_client.py

View check run for this annotation

Codecov / codecov/patch

requests_oauth2client/api_client.py#L384-L386

Added lines #L384 - L386 were not covered by tests
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(

Check warning on line 453 in requests_oauth2client/api_client.py

View check run for this annotation

Codecov / codecov/patch

requests_oauth2client/api_client.py#L452-L453

Added lines #L452 - L453 were not covered by tests
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.

Expand Down
45 changes: 36 additions & 9 deletions requests_oauth2client/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -17,19 +25,16 @@
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)
Expand All @@ -52,7 +57,7 @@
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:
Expand All @@ -72,4 +77,26 @@
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)

Check warning on line 102 in requests_oauth2client/discovery.py

View check run for this annotation

Codecov / codecov/patch

requests_oauth2client/discovery.py#L102

Added line #L102 was not covered by tests
Loading
Loading