From 30f269e3591d0370366f7b1601c0c0495d80ad46 Mon Sep 17 00:00:00 2001 From: Kostis Triantafyllakis Date: Wed, 19 Jan 2022 11:05:57 +0200 Subject: [PATCH] Move token exchange to oauth2 --- docs/source/contents/conf.rst | 15 +- src/oidcop/oauth2/token.py | 299 ++++++++++++++++++++++++- src/oidcop/oidc/token.py | 280 +---------------------- src/oidcop/session/grant.py | 2 - src/oidcop/session/manager.py | 6 - tests/test_36_oauth2_token_exchange.py | 18 +- 6 files changed, 317 insertions(+), 303 deletions(-) diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst index 3f18a93d..f788cddc 100644 --- a/docs/source/contents/conf.rst +++ b/docs/source/contents/conf.rst @@ -670,14 +670,21 @@ There are two possible ways to configure Token Exchange in OIDC-OP, globally and For the first case the configuration is passed in the Token Exchange handler throught the `urn:ietf:params:oauth:grant-type:token-exchange` dictionary in token's `grant_types_supported`. -If present, the token exchange configuration may contain a `policy` object that describes a default -policy `callable` and its `kwargs` through the `""` key. Different callables can be optionally -defined for each token type supported. +If present, the token exchange configuration may contain a `policy` dictionary +that defines the behaviour for each subject token type. Each subject token type +is mapped to a dictionary with the keys `callable` (mandatory), which must be a +python callable or a string that represents the path to a python callable, and +`kwargs` (optional), which must be a dict of key-value arguments that will be +passed to the callable. + +The key `""` represents a fallback policy that will be used if the subject token +type can't be found. If a subject token type is defined in the `policy` but is +not in the `subject_token_types_supported` list then it is ignored. ``` "grant_types_supported":{ "urn:ietf:params:oauth:grant-type:token-exchange": { - "class": "oidcop.oidc.token.TokenExchangeHelper", + "class": "oidcop.oauth2.token.TokenExchangeHelper", "kwargs": { "subject_token_types_supported": [ "urn:ietf:params:oauth:token-type:access_token", diff --git a/src/oidcop/oauth2/token.py b/src/oidcop/oauth2/token.py index d08d07b9..dd669425 100755 --- a/src/oidcop/oauth2/token.py +++ b/src/oidcop/oauth2/token.py @@ -3,9 +3,12 @@ from typing import Union from cryptojwt.jwe.exception import JWEException +from cryptojwt.exception import JWKESTException from oidcmsg.message import Message from oidcmsg.oauth2 import AccessTokenResponse from oidcmsg.oauth2 import ResponseMessage +from oidcmsg.oauth2 import (TokenExchangeRequest, TokenExchangeResponse) +from oidcmsg.exception import MissingRequiredValue, MissingRequiredAttribute from oidcmsg.oidc import RefreshAccessTokenRequest from oidcmsg.oidc import TokenErrorResponse from oidcmsg.time_util import utc_time_sans_frac @@ -13,13 +16,15 @@ from oidcop import sanitize from oidcop.constant import DEFAULT_TOKEN_LIFETIME from oidcop.endpoint import Endpoint -from oidcop.exception import ProcessError +from oidcop.exception import ProcessError, UnAuthorizedClientScope, ToOld from oidcop.session.grant import AuthorizationCode from oidcop.session.grant import Grant from oidcop.session.grant import RefreshToken from oidcop.session.token import MintingNotAllowed from oidcop.session.token import SessionToken from oidcop.token.exception import UnknownToken +from oidcop.exception import ImproperlyConfigured +from oidcop.oauth2.authorization import check_unknown_scopes_policy from oidcop.util import importer logger = logging.getLogger(__name__) @@ -248,11 +253,11 @@ def process_request(self, req: Union[Message, dict], **kwargs): _grant = _session_info["grant"] token_type = "Bearer" - # Is DPOP supported if "dpop_signing_alg_values_supported" in _context.provider_info: _dpop_jkt = req.get("dpop_jkt") if _dpop_jkt: + _grant["extra"] = {} _grant.extra["dpop_jkt"] = _dpop_jkt token_type = "DPoP" @@ -358,6 +363,296 @@ def post_parse_request( return request +class TokenExchangeHelper(TokenEndpointHelper): + """Implements Token Exchange a.k.a. RFC8693""" + + def __init__(self, endpoint, config=None): + TokenEndpointHelper.__init__(self, endpoint=endpoint, config=config) + + if config is None: + self.config = { + "subject_token_types_supported": [ + "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:refresh_token", + ], + "requested_token_types_supported": [ + "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:refresh_token", + ], + "policy": { + "": { + "callable": default_token_exchange_policy, + "kwargs": { + "scope": ["openid"] + } + } + } + } + else: + self.config = config + + self.subject_token_types_mapping = { + "urn:ietf:params:oauth:token-type:access_token": "access_token", + "urn:ietf:params:oauth:token-type:refresh_token": "refresh_token" + } + + def post_parse_request(self, request, client_id="", **kwargs): + request = TokenExchangeRequest(**request.to_dict()) + + _context = self.endpoint.server_get("endpoint_context") + if "token_exchange" in _context.cdb[request["client_id"]]: + config = _context.cdb[request["client_id"]]["token_exchange"] + else: + config = self.config + + try: + keyjar = _context.keyjar + except AttributeError: + keyjar = "" + + try: + request.verify(keyjar=keyjar, opponent_id=client_id) + except ( + MissingRequiredAttribute, + ValueError, + MissingRequiredValue, + JWKESTException, + ) as err: + return self.endpoint.error_cls( + error="invalid_request", error_description="%s" % err + ) + + _mngr = _context.session_manager + try: + _session_info = _mngr.get_session_info_by_token( + request["subject_token"], grant=True + ) + except (KeyError, UnknownToken): + logger.error("Subject token invalid.") + return self.error_cls( + error="invalid_request", + error_description="Subject token invalid" + ) + + token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) + if token.is_active() is False: + return self.error_cls( + error="invalid_request", error_description="Subject token inactive" + ) + + resp = self._enforce_policy(request, token, config) + + return resp + + def _enforce_policy(self, request, token, config): + _context = self.endpoint.server_get("endpoint_context") + + subject_token_types_supported = config.get( + "subject_token_types_supported", self.subject_token_types_mapping.keys() + ) + subject_token_type = request["subject_token_type"] + if subject_token_type not in subject_token_types_supported: + return TokenErrorResponse( + error="invalid_request", + error_description="Unsupported subject token type", + ) + if self.subject_token_types_mapping[subject_token_type] != token.token_class: + return TokenErrorResponse( + error="invalid_request", + error_description="Wrong token type", + ) + + if ( + "requested_token_type" in request + and request["requested_token_type"] not in config["requested_token_types_supported"] + ): + return TokenErrorResponse( + error="invalid_request", + error_description="Unsupported requested token type", + ) + + request_info = dict(scope=request.get("scope", [])) + try: + check_unknown_scopes_policy(request_info, request["client_id"], _context) + except UnAuthorizedClientScope: + return self.error_cls( + error="invalid_grant", + error_description="Unauthorized scope requested", + ) + + subject_token_type = request["subject_token_type"] + if subject_token_type not in config["policy"]: + if "" not in config["policy"]: + raise ImproperlyConfigured( + "subject_token_type {subject_token_type} missing from " + "policy and no default is defined" + ) + subject_token_type = "" + + policy = config["policy"][subject_token_type] + callable = policy["callable"] + kwargs = policy["kwargs"] + + if isinstance(callable, str): + try: + fn = importer(callable) + except Exception: + raise ImproperlyConfigured( + f"Error importing {callable} policy callable" + ) + else: + fn = callable + + try: + return fn(request, context=_context, subject_token=token, **kwargs) + except Exception as e: + logger.error(f"Error while executing the {fn} policy callable: {e}") + return self.error_cls( + error="server_error", + error_description="Internal server error" + ) + + def token_exchange_response(self, token): + response_args = {} + response_args["access_token"] = token.value + response_args["scope"] = token.scope + response_args["issued_token_type"] = token.token_class + response_args["expires_in"] = token.usage_rules.get("expires_in", 0) + if hasattr(token, "token_type"): + response_args["token_type"] = token.token_type + else: + response_args["token_type"] = "N_A" + + return TokenExchangeResponse(**response_args) + + def process_request(self, request, **kwargs): + _context = self.endpoint.server_get("endpoint_context") + _mngr = _context.session_manager + try: + _session_info = _mngr.get_session_info_by_token( + request["subject_token"], grant=True + ) + except ToOld: + logger.error("Subject token has expired.") + return self.error_cls( + error="invalid_request", + error_description="Subject token has expired" + ) + except (KeyError, UnknownToken): + logger.error("Subject token invalid.") + return self.error_cls( + error="invalid_request", + error_description="Subject token invalid" + ) + + token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) + _requested_token_type = request.get("requested_token_type", + "urn:ietf:params:oauth:token-type:access_token") + + _token_class = self.subject_token_types_mapping[_requested_token_type] + + sid = _session_info["session_id"] + + _token_type = "Bearer" + # Is DPOP supported + if "dpop_signing_alg_values_supported" in _context.provider_info: + if request.get("dpop_jkt"): + _token_type = "DPoP" + + if request["client_id"] != _session_info["client_id"]: + _token_usage_rules = _context.authz.usage_rules(request["client_id"]) + + sid = _mngr.create_exchange_session( + exchange_request=request, + original_session_id=sid, + user_id=_session_info["user_id"], + client_id=request["client_id"], + token_usage_rules=_token_usage_rules, + ) + + try: + _session_info = _mngr.get_session_info( + session_id=sid, grant=True) + except Exception: + logger.error("Error retrieving token exchange session information") + return self.error_cls( + error="server_error", + error_description="Internal server error" + ) + + resources = request.get("resource") + if resources and request.get("audience"): + resources = list(set(resources + request.get("audience"))) + else: + resources = request.get("audience") + + try: + new_token = self._mint_token( + token_class=_token_class, + grant=_session_info["grant"], + session_id=sid, + client_id=request["client_id"], + based_on=token, + scope=request.get("scope"), + token_args={ + "resources":resources, + }, + token_type=_token_type + ) + except MintingNotAllowed: + logger.error(f"Minting not allowed for {_token_class}") + return self.error_cls( + error="invalid_grant", + error_description="Token Exchange not allowed with that token", + ) + + return self.token_exchange_response(token=new_token) + +def default_token_exchange_policy(request, context, subject_token, **kwargs): + if "resource" in request: + resource = kwargs.get("resource", []) + if (not len(set(request["resource"]).intersection(set(resource)))): + return TokenErrorResponse( + error="invalid_target", error_description="Unknown resource" + ) + + if "audience" in request: + if request["subject_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token": + return TokenErrorResponse( + error="invalid_target", error_description="Refresh token has single owner" + ) + audience = kwargs.get("audience", []) + if (audience and not len(set(request["audience"]).intersection(set(audience)))): + return TokenErrorResponse( + error="invalid_target", error_description="Unknown audience" + ) + + if "actor_token" in request or "actor_token_type" in request: + return TokenErrorResponse( + error="invalid_request", error_description="Actor token not supported" + ) + + if ( + "requested_token_type" in request + and request["requested_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token" + ): + if "offline_access" not in subject_token.scope: + return TokenErrorResponse( + error="invalid_request", + error_description=f"Exchange {request['subject_token_type']} to refresh token forbbiden", + ) + + if "scope" in request: + scopes = list(set(request.get("scope")).intersection(kwargs.get("scope"))) + if scopes: + request["scope"] = scopes + else: + return TokenErrorResponse( + error="invalid_request", + error_description="No supported scope requested", + ) + + return request class Token(Endpoint): request_cls = Message diff --git a/src/oidcop/oidc/token.py b/src/oidcop/oidc/token.py index 86e1a130..ee128296 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -18,6 +18,7 @@ from oidcop import sanitize from oidcop.oauth2.authorization import check_unknown_scopes_policy from oidcop.oauth2.token import TokenEndpointHelper +from oidcop.oauth2.token import TokenExchangeHelper from oidcop.session.grant import AuthorizationCode from oidcop.session.grant import RefreshToken from oidcop.session.token import MintingNotAllowed @@ -363,285 +364,6 @@ def post_parse_request( return request -class TokenExchangeHelper(TokenEndpointHelper): - """Implements Token Exchange a.k.a. RFC8693""" - - def __init__(self, endpoint, config=None): - TokenEndpointHelper.__init__(self, endpoint=endpoint, config=config) - - if config is None: - self.config = { - "subject_token_types_supported": [ - "urn:ietf:params:oauth:token-type:access_token", - "urn:ietf:params:oauth:token-type:refresh_token", - ], - "requested_token_types_supported": [ - "urn:ietf:params:oauth:token-type:access_token", - "urn:ietf:params:oauth:token-type:refresh_token", - ], - "policy": { - "": { - "callable": default_token_exchange_policy, - "kwargs": { - "scope": ["openid"] - } - } - } - } - else: - self.config = config - - self.total_subject_token_types_supported = { - "urn:ietf:params:oauth:token-type:access_token": "access_token", - "urn:ietf:params:oauth:token-type:refresh_token": "refresh_token" - } - - def post_parse_request(self, request, client_id="", **kwargs): - request = TokenExchangeRequest(**request.to_dict()) - - _context = self.endpoint.server_get("endpoint_context") - if "token_exchange" in _context.cdb[request["client_id"]]: - config = _context.cdb[request["client_id"]]["token_exchange"] - else: - config = self.config - - try: - keyjar = _context.keyjar - except AttributeError: - keyjar = "" - - try: - request.verify(keyjar=keyjar, opponent_id=client_id) - except ( - MissingRequiredAttribute, - ValueError, - MissingRequiredValue, - JWKESTException, - ) as err: - return self.endpoint.error_cls( - error="invalid_request", error_description="%s" % err - ) - - _mngr = _context.session_manager - try: - _session_info = _mngr.get_session_info_by_token( - request["subject_token"], grant=True - ) - except (KeyError, UnknownToken): - logger.error("Subject token invalid.") - return self.error_cls( - error="invalid_request", - error_description="Subject token invalid" - ) - - token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) - if token.is_active() is False: - return self.error_cls( - error="invalid_request", error_description="Subject token inactive" - ) - - resp = self._enforce_policy(request, token, config) - - return resp - - def _enforce_policy(self, request, token, config): - _context = self.endpoint.server_get("endpoint_context") - - subject_token_types_supported = ( - self.total_subject_token_types_supported.keys() - & config.get("subject_token_types_supported", "urn:ietf:params:oauth:token-type:access_token") - ) - subject_token_types_supported = { - k:self.total_subject_token_types_supported[k] for k in subject_token_types_supported - } - - if ( - request["subject_token_type"] in subject_token_types_supported - and ( - subject_token_types_supported[request["subject_token_type"]] != token.token_class - ) - ): - return self.error_cls( - error="invalid_request", error_description="Wrong token type" - ) - - if request["subject_token_type"] not in subject_token_types_supported: - return TokenErrorResponse( - error="invalid_request", - error_description="Unsupported subject token type", - ) - - if ( - "requested_token_type" in request - and request["requested_token_type"] not in config["requested_token_types_supported"] - ): - return TokenErrorResponse( - error="invalid_request", - error_description="Unsupported requested token type", - ) - - request_info = dict(scope=request.get("scope", [])) - try: - check_unknown_scopes_policy(request_info, request["client_id"], _context) - except UnAuthorizedClientScope: - logger.error("Unauthorized scope requested.") - return self.error_cls( - error="invalid_grant", - error_description="Unauthorized scope requested", - ) - - try: - subject_token_type = request.get("subject_token_type", "") - if subject_token_type not in config["policy"]: - subject_token_type = "" - callable = config["policy"][subject_token_type]["callable"] - kwargs = config["policy"][subject_token_type]["kwargs"] - - if isinstance(callable, str): - fn = importer(callable) - else: - fn = callable - return fn(request, context=_context, subject_token=token, **kwargs) - - except Exception: - return self.error_cls( - error="server_error", - error_description="Internal server error" - ) - - def token_exchange_response(self, token): - response_args = {} - response_args["access_token"] = token.value - response_args["scope"] = token.scope - response_args["issued_token_type"] = token.token_class - response_args["expires_in"] = token.usage_rules.get("expires_in", 0) - response_args["token_type"] = "bearer" - - return TokenExchangeResponse(**response_args) - - def process_request(self, request, **kwargs): - _context = self.endpoint.server_get("endpoint_context") - _mngr = _context.session_manager - try: - _session_info = _mngr.get_session_info_by_token( - request["subject_token"], grant=True - ) - except ToOld: - logger.error("Subject token has expired.") - return self.error_cls( - error="invalid_request", - error_description="Subject token has expired" - ) - except (KeyError, UnknownToken): - logger.error("Subject token invalid.") - return self.error_cls( - error="invalid_request", - error_description="Subject token invalid" - ) - - token = _mngr.find_token(_session_info["session_id"], request["subject_token"]) - grant = _session_info["grant"] - _requested_token_type = request.get("requested_token_type", - "urn:ietf:params:oauth:token-type:access_token") - - _token_class = _requested_token_type.split(":")[-1] - if _token_class == "access_token": - _token_type = _token_class - else: - _token_type = None - - sid = _session_info["session_id"] - if request["client_id"] != _session_info["client_id"]: - _token_usage_rules = _context.authz.usage_rules(request["client_id"]) - - sid = _mngr.create_exchange_session( - exchange_request=request, - original_session_id=sid, - user_id=_session_info["user_id"], - client_id=request["client_id"], - token_usage_rules=_token_usage_rules, - ) - - try: - _session_info = _mngr.get_session_info( - session_id=sid, grant=True) - except Exception: - logger.error("Error retrieving token exchabge session information") - return self.error_cls( - error="server_error", - error_description="Internal server error" - ) - - try: - new_token = self._mint_token( - token_class=_token_class, - grant=_session_info["grant"], - session_id=sid, - client_id=request["client_id"], - based_on=token, - scope=request.get("scope"), - token_args={ - "resources":request.get("resource"), - }, - token_type=_token_type - ) - except MintingNotAllowed: - logger.error(f"Minting not allowed for {_token_class}") - return self.error_cls( - error="invalid_grant", - error_description="Token Exchange not allowed with that token", - ) - - return self.token_exchange_response(token=new_token) - -def default_token_exchange_policy(request, context, subject_token, **kwargs): - if "resource" in request: - resource = kwargs.get("resource", []) - if not resource: - pass - elif (not len(set(request["resource"]).intersection(set(resource)))): - return TokenErrorResponse( - error="invalid_target", error_description="Unknown resource" - ) - - if "audience" in request: - if request["subject_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token": - return TokenErrorResponse( - error="invalid_target", error_description="Refresh token has single owner" - ) - audience = kwargs.get("audience", []) - if not audience: - pass - elif (audience and not len(set(request["audience"]).intersection(set(audience)))): - return TokenErrorResponse( - error="invalid_target", error_description="Unknown audience" - ) - - if "actor_token" in request or "actor_token_type" in request: - return TokenErrorResponse( - error="invalid_request", error_description="Actor token not supported" - ) - - if ( - "requested_token_type" in request - and request["requested_token_type"] == "urn:ietf:params:oauth:token-type:refresh_token" - ): - if "offline_access" not in subject_token.scope: - return TokenErrorResponse( - error="invalid_request", - error_description=f"Exchange {request['subject_token_type']} to refresh token forbbiden", - ) - - scopes = list(set(request.get("scope", ["openid"])).intersection(kwargs.get("scope", ["openid"]))) - if scopes: - request["scope"] = scopes - else: - return TokenErrorResponse( - error="invalid_request", - error_description="No supported scope requested", - ) - return request - class Token(oauth2.token.Token): request_cls = Message response_cls = oidc.AccessTokenResponse diff --git a/src/oidcop/session/grant.py b/src/oidcop/session/grant.py index 155affcb..c914f2e2 100644 --- a/src/oidcop/session/grant.py +++ b/src/oidcop/session/grant.py @@ -444,7 +444,6 @@ class ExchangeGrant(Grant): def __init__( self, scope: Optional[list] = None, - claims: Optional[dict] = None, resources: Optional[list] = None, authorization_details: Optional[dict] = None, issued_token: Optional[list] = None, @@ -461,7 +460,6 @@ def __init__( Grant.__init__( self, scope=scope, - claims=claims, resources=resources, authorization_details=authorization_details, issued_token=issued_token, diff --git a/src/oidcop/session/manager.py b/src/oidcop/session/manager.py index 2b17aad7..93c94478 100644 --- a/src/oidcop/session/manager.py +++ b/src/oidcop/session/manager.py @@ -217,17 +217,11 @@ def create_exchange_grant( :param user_id: :param client_id: :param sub_type: - :param token_usage_rules: :return: """ - sector_identifier = exchange_request.get("sector_identifier_uri", "") - - _claims = exchange_request.get("claims", {}) grant = ExchangeGrant( - usage_rules=token_usage_rules, scope=scopes, - claims=_claims, original_session_id=original_session_id, exchange_request=exchange_request ) diff --git a/tests/test_36_oauth2_token_exchange.py b/tests/test_36_oauth2_token_exchange.py index 570c95af..0314d094 100644 --- a/tests/test_36_oauth2_token_exchange.py +++ b/tests/test_36_oauth2_token_exchange.py @@ -270,7 +270,6 @@ def test_token_exchange(self, token): assert set(_resp["response_args"].keys()) == { 'access_token', 'token_type', 'scope', 'expires_in', 'issued_token_type' } - assert _resp["response_args"]["scope"] == ["openid"] @pytest.mark.parametrize("token", [ {"access_token":"urn:ietf:params:oauth:token-type:access_token"}, @@ -291,7 +290,7 @@ def test_token_exchange_per_client(self, token): ], "policy": { "": { - "callable": "oidcop.oidc.token.default_token_exchange_policy", + "callable": "oidcop.oauth2.token.default_token_exchange_policy", "kwargs": { "scope": ["custom"] } @@ -317,7 +316,8 @@ def test_token_exchange_per_client(self, token): grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=_token_value, subject_token_type=token[list(token.keys())[0]], - requested_token_type=token[list(token.keys())[0]] + requested_token_type=token[list(token.keys())[0]], + scope="custom" ) _req = self.endpoint.parse_request( @@ -329,10 +329,9 @@ def test_token_exchange_per_client(self, token): }, ) _resp = self.endpoint.process_request(request=_req) - assert _resp["error"] == "invalid_request" - assert( - _resp["error_description"] == "No supported scope requested" - ) + assert set(_resp["response_args"].keys()) == { + 'access_token', 'token_type', 'scope', 'expires_in', 'issued_token_type' + } def test_additional_parameters(self): """ @@ -341,7 +340,7 @@ def test_additional_parameters(self): """ conf = self.endpoint.helper["urn:ietf:params:oauth:grant-type:token-exchange"].config conf["policy"][""]["kwargs"]["audience"] = ["https://example.com"] - conf["policy"][""]["kwargs"]["resource"] = [] + conf["policy"][""]["kwargs"]["resource"] = ["https://example.com"] areq = AUTH_REQ.copy() @@ -362,8 +361,7 @@ def test_additional_parameters(self): subject_token_type="urn:ietf:params:oauth:token-type:access_token", requested_token_type="urn:ietf:params:oauth:token-type:access_token", audience=["https://example.com"], - resource=["https://example.com"], - scope=["openid"] + resource=["https://example.com"] ) _req = self.endpoint.parse_request(