diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst index 3f18a93d..83457d59 100644 --- a/docs/source/contents/conf.rst +++ b/docs/source/contents/conf.rst @@ -677,7 +677,7 @@ defined for each token type supported. ``` "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..d303f484 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__) @@ -358,6 +363,289 @@ 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"] + + try: + if isinstance(callable, str): + fn = importer(callable) + else: + fn = callable + except Exception: + raise ImproperlyConfigured( + "Error importing {callable} policy callable" + ) + + try: + return fn(request, context=_context, subject_token=token, **kwargs) + except Exception: + logger.error("Error while executing the {fn} policy callable") + 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 = _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(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..3ce8ae1a 100644 --- a/tests/test_36_oauth2_token_exchange.py +++ b/tests/test_36_oauth2_token_exchange.py @@ -291,7 +291,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"] }