From 49091c97f765c20a645fa450a3fc824267e45613 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Thu, 25 Oct 2018 12:03:40 +0200 Subject: Initial OAuth Authorization Server Metadata RFC8414 --- docs/feature_matrix.rst | 1 + oauthlib/oauth2/__init__.py | 1 + oauthlib/oauth2/rfc6749/endpoints/__init__.py | 1 + oauthlib/oauth2/rfc6749/endpoints/metadata.py | 191 ++++++++++++++++++++++++ tests/oauth2/rfc6749/endpoints/test_metadata.py | 28 ++++ 5 files changed, 222 insertions(+) create mode 100644 oauthlib/oauth2/rfc6749/endpoints/metadata.py create mode 100644 tests/oauth2/rfc6749/endpoints/test_metadata.py diff --git a/docs/feature_matrix.rst b/docs/feature_matrix.rst index 59f3f3a..672cc27 100644 --- a/docs/feature_matrix.rst +++ b/docs/feature_matrix.rst @@ -18,6 +18,7 @@ OAuth 2 client and provider support for - Draft MAC tokens - Token Revocation - Token Introspection +- OAuth Authorization Server Metadata - OpenID Connect Authentication with support for SAML2 and JWT tokens, dynamic client registration and more to diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py index 303c6a1..3f43755 100644 --- a/oauthlib/oauth2/__init__.py +++ b/oauthlib/oauth2/__init__.py @@ -16,6 +16,7 @@ from .rfc6749.clients import BackendApplicationClient from .rfc6749.clients import ServiceApplicationClient from .rfc6749.endpoints import AuthorizationEndpoint from .rfc6749.endpoints import IntrospectEndpoint +from .rfc6749.endpoints import MetadataEndpoint from .rfc6749.endpoints import TokenEndpoint from .rfc6749.endpoints import ResourceEndpoint from .rfc6749.endpoints import RevocationEndpoint diff --git a/oauthlib/oauth2/rfc6749/endpoints/__init__.py b/oauthlib/oauth2/rfc6749/endpoints/__init__.py index 9557f92..51e173d 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/__init__.py +++ b/oauthlib/oauth2/rfc6749/endpoints/__init__.py @@ -10,6 +10,7 @@ from __future__ import absolute_import, unicode_literals from .authorization import AuthorizationEndpoint from .introspect import IntrospectEndpoint +from .metadata import MetadataEndpoint from .token import TokenEndpoint from .resource import ResourceEndpoint from .revocation import RevocationEndpoint diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py new file mode 100644 index 0000000..8c7699b --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.endpoint.metadata +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An implementation of the `OAuth 2.0 Authorization Server Metadata`. + +.. _`OAuth 2.0 Authorization Server Metadata`: https://tools.ietf.org/html/rfc8414 +""" +from __future__ import absolute_import, unicode_literals + +import copy +import json +import logging + +from ....common import unicode_type +from .base import BaseEndpoint, catch_errors_and_unavailability +from .authorization import AuthorizationEndpoint +from .introspect import IntrospectEndpoint +from .token import TokenEndpoint +from .revocation import RevocationEndpoint + + +log = logging.getLogger(__name__) + + +class MetadataEndpoint(BaseEndpoint): + + """OAuth2.0 Authorization Server Metadata endpoint. + + This specification generalizes the metadata format defined by + `OpenID Connect Discovery 1.0` in a way that is compatible + with OpenID Connect Discovery while being applicable to a wider set + of OAuth 2.0 use cases. This is intentionally parallel to the way + that `OAuth 2.0 Dynamic Client Registration Protocol` [RFC7591] + generalized the dynamic client registration mechanisms defined by + `OpenID Connect Dynamic Client Registration 1.0` + in a way that is compatible with it. + + .. _`OpenID Connect Discovery 1.0`: http://openid.net/specs/openid-connect-discovery-1_0.html + .. _`OAuth 2.0 Dynamic Client Registration Protocol`: https://tools.ietf.org/html/rfc7591 + .. _`OpenID Connect Dynamic Client Registration 1.0`: https://openid.net/specs/openid-connect-registration-1_0.html + """ + + def __init__(self, endpoints, claims={}, raise_errors=True): + assert isinstance(claims, dict) + for endpoint in endpoints: + assert isinstance(endpoint, BaseEndpoint) + + BaseEndpoint.__init__(self) + self.raise_errors = raise_errors + self.endpoints = endpoints + self.initial_claims = claims + self.claims = self.validate_metadata_server() + + @catch_errors_and_unavailability + def create_metadata_response(self, uri, http_method='GET', body=None, + headers=None): + """Create metadata response + """ + headers = { + 'Content-Type': 'application/json' + } + return headers, json.dumps(self.claims), 200 + + def validate_metadata(self, array, key, is_required=False, is_list=False, is_url=False, is_issuer=False): + if not self.raise_errors: + return + + if key not in array: + if is_required: + raise ValueError("key {} is a mandatory metadata.".format(key)) + + elif is_issuer: + if not array[key].startswith("https"): + raise ValueError("key {}: {} must be an HTTPS URL".format(key, array[key])) + if "?" in array[key] or "&" in array[key] or "#" in array[key]: + raise ValueError("key {}: {} must not contain query or fragment components".format(key, array[key])) + + elif is_url: + if not array[key].startswith("http"): + raise ValueError("key {}: {} must be an URL".format(key, array[key])) + + elif is_list: + if not isinstance(array[key], list): + raise ValueError("key {}: {} must be an Array".format(key, array[key])) + for elem in array[key]: + if not isinstance(elem, unicode_type): + raise ValueError("array {}: {} must contains only string (not {})".format(key, array[key], elem)) + + def validate_metadata_token(self, claims, endpoint): + claims["grant_types_supported"] = list(endpoint._grant_types.keys()) + claims["token_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] + + self.validate_metadata(claims, "grant_types_supported", is_list=True) + self.validate_metadata(claims, "token_endpoint_auth_methods_supported", is_list=True) + self.validate_metadata(claims, "token_endpoint_auth_signing_alg_values_supported", is_list=True) + self.validate_metadata(claims, "token_endpoint", is_required=True, is_url=True) + + def validate_metadata_authorization(self, claims, endpoint): + claims["response_types_supported"] = list(self._response_types.keys()) + claims["response_modes_supported"] = ["query", "fragment"] + + self.validate_metadata(claims, "response_types_supported", is_required=True, is_list=True) + self.validate_metadata(claims, "response_modes_supported", is_list=True) + if "code" in claims["response_types_supported"]: + self.validate_metadata(claims, "code_challenge_methods_supported", is_list=True) + self.validate_metadata(claims, "authorization_endpoint", is_required=True, is_url=True) + + def validate_metadata_revocation(self, claims, endpoint): + claims["revocation_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] + + self.validate_metadata(claims, "revocation_endpoint_auth_methods_supported", is_list=True) + self.validate_metadata(claims, "revocation_endpoint_auth_signing_alg_values_supported", is_list=True) + self.validate_metadata(claims, "revocation_endpoint", is_required=True, is_url=True) + + def validate_metadata_introspection(self, claims, endpoint): + claims["introspection_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] + + self.validate_metadata(claims, "introspection_endpoint_auth_methods_supported", is_list=True) + self.validate_metadata(claims, "introspection_endpoint_auth_signing_alg_values_supported", is_list=True) + self.validate_metadata(claims, "introspection_endpoint", is_required=True, is_url=True) + + def validate_metadata_server(self): + """ + Authorization servers can have metadata describing their + configuration. The following authorization server metadata values + are used by this specification. More details can be found in `RFC8414` : + + issuer + REQUIRED + + authorization_endpoint + URL of the authorization server's authorization endpoint + [RFC6749]. This is REQUIRED unless no grant types are supported + that use the authorization endpoint. + + token_endpoint + URL of the authorization server's token endpoint [RFC6749]. This + is REQUIRED unless only the implicit grant type is supported. + + scopes_supported + RECOMMENDED. + + response_types_supported + REQUIRED. + + * Other OPTIONAL fields: + jwks_uri + registration_endpoint + response_modes_supported + grant_types_supported + token_endpoint_auth_methods_supported + token_endpoint_auth_signing_alg_values_supported + service_documentation + ui_locales_supported + op_policy_uri + op_tos_uri + revocation_endpoint + revocation_endpoint_auth_methods_supported + revocation_endpoint_auth_signing_alg_values_supported + introspection_endpoint + introspection_endpoint_auth_methods_supported + introspection_endpoint_auth_signing_alg_values_supported + code_challenge_methods_supported + + Additional authorization server metadata parameters MAY also be used. + Some are defined by other specifications, such as OpenID Connect + Discovery 1.0 [OpenID.Discovery]. + + .. _`RFC8414 section 2`: https://tools.ietf.org/html/rfc8414#section-2 + """ + claims = copy.deepcopy(self.initial_claims) + self.validate_metadata(claims, "issuer", is_required=True, is_issuer=True) + self.validate_metadata(claims, "jwks_uri", is_url=True) + self.validate_metadata(claims, "scopes_supported", is_list=True) + self.validate_metadata(claims, "service_documentation", is_url=True) + self.validate_metadata(claims, "ui_locales_supported", is_list=True) + self.validate_metadata(claims, "op_policy_uri", is_url=True) + self.validate_metadata(claims, "op_tos_uri", is_url=True) + + for endpoint in self.endpoints: + if isinstance(endpoint, TokenEndpoint): + self.validate_metadata_token(claims, endpoint) + if isinstance(endpoint, AuthorizationEndpoint): + self.validate_metadata_authorization(claims, endpoint) + if isinstance(endpoint, RevocationEndpoint): + self.validate_metadata_revocation(claims, endpoint) + if isinstance(endpoint, IntrospectEndpoint): + self.validate_metadata_introspection(claims, endpoint) + return claims diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py new file mode 100644 index 0000000..a07ba63 --- /dev/null +++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from oauthlib.oauth2 import MetadataEndpoint +from oauthlib.oauth2 import TokenEndpoint + +from ....unittest import TestCase + + +class MetadataEndpointTest(TestCase): + def setUp(self): + self.metadata = { + "issuer": 'https://foo.bar' + } + + def test_token_endpoint(self): + endpoint = TokenEndpoint(None, None, grant_types={"password": None}) + metadata = MetadataEndpoint([endpoint], { + "issuer": 'https://foo.bar', + "token_endpoint": "https://foo.bar/token" + }) + self.assertIn("grant_types_supported", metadata.claims) + self.assertEqual(metadata.claims["grant_types_supported"], ["password"]) + + def test_mandatory_fields(self): + metadata = MetadataEndpoint([], self.metadata) + self.assertIn("issuer", metadata.claims) + self.assertEqual(metadata.claims["issuer"], 'https://foo.bar') -- cgit v1.2.1 From e77023ca5db633c5200322cc5ab8f75b22aa7832 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Wed, 21 Nov 2018 15:17:38 +0100 Subject: Allow custom provider to override oauthlib values See https://github.com/oauthlib/oauthlib/pull/605#discussion_r234438151 --- oauthlib/oauth2/rfc6749/endpoints/metadata.py | 14 ++++++++------ tests/oauth2/rfc6749/endpoints/test_metadata.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index 8c7699b..6d77b9f 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -89,8 +89,8 @@ class MetadataEndpoint(BaseEndpoint): raise ValueError("array {}: {} must contains only string (not {})".format(key, array[key], elem)) def validate_metadata_token(self, claims, endpoint): - claims["grant_types_supported"] = list(endpoint._grant_types.keys()) - claims["token_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] + claims.setdefault("grant_types_supported", list(endpoint._grant_types.keys())) + claims.setdefault("token_endpoint_auth_methods_supported", ["client_secret_post", "client_secret_basic"]) self.validate_metadata(claims, "grant_types_supported", is_list=True) self.validate_metadata(claims, "token_endpoint_auth_methods_supported", is_list=True) @@ -98,8 +98,8 @@ class MetadataEndpoint(BaseEndpoint): self.validate_metadata(claims, "token_endpoint", is_required=True, is_url=True) def validate_metadata_authorization(self, claims, endpoint): - claims["response_types_supported"] = list(self._response_types.keys()) - claims["response_modes_supported"] = ["query", "fragment"] + claims.setdefault("response_types_supported", list(self._response_types.keys())) + claims.setdefault("response_modes_supported", ["query", "fragment"]) self.validate_metadata(claims, "response_types_supported", is_required=True, is_list=True) self.validate_metadata(claims, "response_modes_supported", is_list=True) @@ -108,14 +108,16 @@ class MetadataEndpoint(BaseEndpoint): self.validate_metadata(claims, "authorization_endpoint", is_required=True, is_url=True) def validate_metadata_revocation(self, claims, endpoint): - claims["revocation_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] + claims.setdefault("revocation_endpoint_auth_methods_supported", + ["client_secret_post", "client_secret_basic"]) self.validate_metadata(claims, "revocation_endpoint_auth_methods_supported", is_list=True) self.validate_metadata(claims, "revocation_endpoint_auth_signing_alg_values_supported", is_list=True) self.validate_metadata(claims, "revocation_endpoint", is_required=True, is_url=True) def validate_metadata_introspection(self, claims, endpoint): - claims["introspection_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] + claims.setdefault("introspection_endpoint_auth_methods_supported", + ["client_secret_post", "client_secret_basic"]) self.validate_metadata(claims, "introspection_endpoint_auth_methods_supported", is_list=True) self.validate_metadata(claims, "introspection_endpoint_auth_signing_alg_values_supported", is_list=True) diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py index a07ba63..301e846 100644 --- a/tests/oauth2/rfc6749/endpoints/test_metadata.py +++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -22,6 +22,16 @@ class MetadataEndpointTest(TestCase): self.assertIn("grant_types_supported", metadata.claims) self.assertEqual(metadata.claims["grant_types_supported"], ["password"]) + def test_token_endpoint_overridden(self): + endpoint = TokenEndpoint(None, None, grant_types={"password": None}) + metadata = MetadataEndpoint([endpoint], { + "issuer": 'https://foo.bar', + "token_endpoint": "https://foo.bar/token", + "grant_types_supported": ["pass_word_special_provider"] + }) + self.assertIn("grant_types_supported", metadata.claims) + self.assertEqual(metadata.claims["grant_types_supported"], ["pass_word_special_provider"]) + def test_mandatory_fields(self): metadata = MetadataEndpoint([], self.metadata) self.assertIn("issuer", metadata.claims) -- cgit v1.2.1