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..c6c7ba49 100755 --- a/src/oidcop/oauth2/token.py +++ b/src/oidcop/oauth2/token.py @@ -2,10 +2,15 @@ from typing import Optional from typing import Union +from cryptojwt.exception import JWKESTException from cryptojwt.jwe.exception import JWEException +from oidcmsg.exception import MissingRequiredAttribute +from oidcmsg.exception import MissingRequiredValue from oidcmsg.message import Message from oidcmsg.oauth2 import AccessTokenResponse from oidcmsg.oauth2 import ResponseMessage +from oidcmsg.oauth2 import TokenExchangeRequest +from oidcmsg.oauth2 import TokenExchangeResponse from oidcmsg.oidc import RefreshAccessTokenRequest from oidcmsg.oidc import TokenErrorResponse from oidcmsg.time_util import utc_time_sans_frac @@ -13,7 +18,11 @@ from oidcop import sanitize from oidcop.constant import DEFAULT_TOKEN_LIFETIME from oidcop.endpoint import Endpoint +from oidcop.exception import ImproperlyConfigured from oidcop.exception import ProcessError +from oidcop.exception import ToOld +from oidcop.exception import UnAuthorizedClientScope +from oidcop.oauth2.authorization import check_unknown_scopes_policy from oidcop.session.grant import AuthorizationCode from oidcop.session.grant import Grant from oidcop.session.grant import RefreshToken @@ -248,7 +257,6 @@ 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") @@ -359,6 +367,272 @@ def post_parse_request( return request +class TokenExchangeHelper(TokenEndpointHelper): + """Implements Token Exchange a.k.a. RFC8693""" + + token_types_mapping = { + "urn:ietf:params:oauth:token-type:access_token": "access_token", + "urn:ietf:params:oauth:token-type:refresh_token": "refresh_token", + } + + 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 + + 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.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.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.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 response_cls = AccessTokenResponse diff --git a/src/oidcop/oidc/token.py b/src/oidcop/oidc/token.py index 86e1a130..46ca150f 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -8,23 +8,28 @@ from cryptojwt.jws.exception import NoSuitableSigningKeys from cryptojwt.jwt import utc_time_sans_frac from oidcmsg import oidc +from oidcmsg.exception import MissingRequiredAttribute +from oidcmsg.exception import MissingRequiredValue from oidcmsg.message import Message -from oidcmsg.exception import MissingRequiredValue, MissingRequiredAttribute +from oidcmsg.oauth2 import ResponseMessage +from oidcmsg.oauth2 import TokenExchangeRequest +from oidcmsg.oauth2 import TokenExchangeResponse from oidcmsg.oidc import RefreshAccessTokenRequest from oidcmsg.oidc import TokenErrorResponse -from oidcmsg.oauth2 import (TokenExchangeRequest, ResponseMessage, - TokenExchangeResponse) + from oidcop import oauth2 from oidcop import sanitize +from oidcop.authn_event import create_authn_event +from oidcop.exception import ToOld +from oidcop.exception import UnAuthorizedClientScope from oidcop.oauth2.authorization import check_unknown_scopes_policy from oidcop.oauth2.token import TokenEndpointHelper +from oidcop.oauth2.token import TokenExchangeHelper as OAuth2TokenExchangeHelper from oidcop.session.grant import AuthorizationCode from oidcop.session.grant import RefreshToken +from oidcop.session.token import AccessToken from oidcop.session.token import MintingNotAllowed from oidcop.token.exception import UnknownToken -from oidcop.exception import UnAuthorizedClientScope, ToOld -from oidcop.session.token import AccessToken -from oidcop.authn_event import create_authn_event from oidcop.util import importer logger = logging.getLogger(__name__) @@ -209,7 +214,7 @@ def post_parse_request( class RefreshTokenHelper(TokenEndpointHelper): - def process_request(self, req: Union[Message, dict], **kwargs): + def process_request(self, req: Union[Message, dict], **kwargs): _context = self.endpoint.server_get("endpoint_context") _mngr = _context.session_manager @@ -363,284 +368,17 @@ 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) +class TokenExchangeHelper(OAuth2TokenExchangeHelper): - 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", - ) + token_types_mapping = { + "urn:ietf:params:oauth:token-type:access_token": "access_token", + "urn:ietf:params:oauth:token-type:refresh_token": "refresh_token", + "urn:ietf:params:oauth:token-type:id_token": "id_token", + } - 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 + def __init__(self, endpoint, config=None): + super().__init__(endpoint, config) class Token(oauth2.token.Token): request_cls = Message diff --git a/src/oidcop/oidc/userinfo.py b/src/oidcop/oidc/userinfo.py index 37d54d25..7e7235be 100755 --- a/src/oidcop/oidc/userinfo.py +++ b/src/oidcop/oidc/userinfo.py @@ -146,8 +146,9 @@ def process_request(self, request=None, **kwargs): _claims_restriction = _cntxt.claims_interface.get_claims( _session_info["session_id"], scopes=token.scope, claims_release_point="userinfo" ) - info = _cntxt.claims_interface.get_user_claims(_session_info["user_id"], - claims_restriction=_claims_restriction) + info = _cntxt.claims_interface.get_user_claims( + _session_info["user_id"], claims_restriction=_claims_restriction + ) info["sub"] = _grant.sub if _grant.add_acr_value("userinfo"): info["acr"] = _grant.authentication_event["authn_info"] diff --git a/src/oidcop/session/grant.py b/src/oidcop/session/grant.py index 155affcb..59470afc 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, @@ -456,12 +455,10 @@ def __init__( expires_at: int = 0, revoked: bool = False, token_map: Optional[dict] = None, - users: list = None ): Grant.__init__( self, scope=scope, - claims=claims, resources=resources, authorization_details=authorization_details, issued_token=issued_token, @@ -473,9 +470,8 @@ def __init__( token_map=token_map, ) - self.users = users or [] self.usage_rules = { "access_token": {"supports_minting": ["access_token"], "expires_in": 60} } - self.exchange_request=exchange_request - self.original_session_id=original_session_id + self.exchange_request = exchange_request + self.original_session_id = original_session_id diff --git a/src/oidcop/session/manager.py b/src/oidcop/session/manager.py index 2b17aad7..ed96b4b2 100644 --- a/src/oidcop/session/manager.py +++ b/src/oidcop/session/manager.py @@ -1,9 +1,9 @@ import hashlib import logging import os +import uuid from typing import List from typing import Optional -import uuid from oidcmsg.oauth2 import AuthorizationRequest from oidcmsg.oauth2 import TokenExchangeRequest @@ -11,18 +11,19 @@ from oidcop import rndstr from oidcop.authn_event import AuthnEvent from oidcop.exception import ConfigurationError +from oidcop.session.database import NoSuchClientSession from oidcop.token import handler from oidcop.util import Crypt -from oidcop.session.database import NoSuchClientSession + +from ..token import UnknownToken +from ..token import WrongTokenClass +from ..token.handler import TokenHandler from .database import Database -from .grant import Grant from .grant import ExchangeGrant +from .grant import Grant from .grant import SessionToken from .info import ClientSessionInfo from .info import UserSessionInfo -from ..token import UnknownToken -from ..token import WrongTokenClass -from ..token.handler import TokenHandler logger = logging.getLogger(__name__) @@ -217,19 +218,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 + scope=scopes, original_session_id=original_session_id, exchange_request=exchange_request ) self.set([user_id, client_id, grant.id], grant) @@ -325,7 +318,7 @@ def create_exchange_session( return self.create_exchange_grant( exchange_request=exchange_request, - original_session_id = original_session_id, + original_session_id=original_session_id, user_id=user_id, client_id=client_id, sub_type=sub_type, diff --git a/tests/test_36_oauth2_token_exchange.py b/tests/test_36_oauth2_token_exchange.py index 570c95af..a580a4a7 100644 --- a/tests/test_36_oauth2_token_exchange.py +++ b/tests/test_36_oauth2_token_exchange.py @@ -1,13 +1,12 @@ import json import os +import pytest from cryptojwt.key_jar import build_keyjar from oidcmsg.oauth2 import TokenExchangeRequest from oidcmsg.oidc import AccessTokenRequest from oidcmsg.oidc import AuthorizationRequest from oidcmsg.oidc import RefreshAccessTokenRequest -import pytest - from oidcop.authn_event import create_authn_event from oidcop.authz import AuthzHandling @@ -106,14 +105,14 @@ def create_endpoint(self): }, "token": { "path": "token", - "class": 'oidcop.oidc.token.Token', + "class": "oidcop.oidc.token.Token", "kwargs": { "client_authn_method": [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", - ] + ], }, }, "introspection": { @@ -138,7 +137,7 @@ def create_endpoint(self): "grant_config": { "usage_rules": { "authorization_code": { - "supports_minting": ["access_token", "refresh_token" ], + "supports_minting": ["access_token", "refresh_token"], "max_usage": 1, }, "access_token": { @@ -148,7 +147,7 @@ def create_endpoint(self): "refresh_token": { "supports_minting": ["access_token", "refresh_token"], "audience": ["https://example.com", "https://example2.com"], - "expires_in": 43200 + "expires_in": 43200, }, }, "expires_in": 43200, @@ -180,7 +179,7 @@ def create_endpoint(self): "client_salt": "salted", "token_endpoint_auth_method": "client_secret_post", "response_types": ["code", "token", "code id_token", "id_token"], - "allowed_scopes": ["openid", "profile", "offline_access"] + "allowed_scopes": ["openid", "profile", "offline_access"], } self.endpoint_context.cdb["client_2"] = { "client_secret": "hemligt", @@ -188,7 +187,7 @@ def create_endpoint(self): "client_salt": "salted", "token_endpoint_auth_method": "client_secret_post", "response_types": ["code", "token", "code id_token", "id_token"], - "allowed_scopes": ["openid", "profile", "offline_access"] + "allowed_scopes": ["openid", "profile", "offline_access"], } self.endpoint_context.keyjar.import_jwks(CLIENT_KEYJAR.export_jwks(), "client_1") self.endpoint = server.server_get("endpoint", "token") @@ -219,7 +218,7 @@ def _mint_code(self, grant, client_id): endpoint_context=self.endpoint.server_get("endpoint_context"), token_class="authorization_code", token_handler=self.session_manager.token_handler["authorization_code"], - usage_rules=usage_rules + usage_rules=usage_rules, ) if _exp_in: @@ -229,21 +228,24 @@ def _mint_code(self, grant, client_id): _code.expires_at = utc_time_sans_frac() + _exp_in return _code - @pytest.mark.parametrize("token", [ - {"access_token":"urn:ietf:params:oauth:token-type:access_token"}, - {"refresh_token" :"urn:ietf:params:oauth:token-type:refresh_token"} - ]) + @pytest.mark.parametrize( + "token", + [ + {"access_token": "urn:ietf:params:oauth:token-type:access_token"}, + {"refresh_token": "urn:ietf:params:oauth:token-type:refresh_token"}, + ], + ) def test_token_exchange(self, token): """ Test that token exchange requests work correctly """ - if (list(token.keys())[0] == "refresh_token"): + if list(token.keys())[0] == "refresh_token": AUTH_REQ["scope"] = ["openid", "offline_access"] areq = AUTH_REQ.copy() session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -255,27 +257,29 @@ def test_token_exchange(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]], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzI6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzI6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp["response_args"].keys()) == { - 'access_token', 'token_type', 'scope', 'expires_in', 'issued_token_type' + "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"}, - {"refresh_token" :"urn:ietf:params:oauth:token-type:refresh_token"} - ]) + @pytest.mark.parametrize( + "token", + [ + {"access_token": "urn:ietf:params:oauth:token-type:access_token"}, + {"refresh_token": "urn:ietf:params:oauth:token-type:refresh_token"}, + ], + ) def test_token_exchange_per_client(self, token): """ Test that per-client token exchange configuration works correctly @@ -291,21 +295,19 @@ def test_token_exchange_per_client(self, token): ], "policy": { "": { - "callable": "oidcop.oidc.token.default_token_exchange_policy", - "kwargs": { - "scope": ["custom"] - } + "callable": "oidcop.oauth2.token.default_token_exchange_policy", + "kwargs": {"scope": ["openid"]}, } - } + }, } - if (list(token.keys())[0] == "refresh_token"): + if list(token.keys())[0] == "refresh_token": AUTH_REQ["scope"] = ["openid", "offline_access"] areq = AUTH_REQ.copy() session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -317,22 +319,21 @@ 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]], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _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,13 +342,13 @@ 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() session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -363,20 +364,19 @@ def test_additional_parameters(self): requested_token_type="urn:ietf:params:oauth:token-type:access_token", audience=["https://example.com"], resource=["https://example.com"], - scope=["openid"] ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp["response_args"].keys()) == { - 'access_token', 'token_type', 'expires_in', 'issued_token_type', 'scope' + "access_token", + "token_type", + "expires_in", + "issued_token_type", + "scope", } msg = self.endpoint.do_response(request=_req, **_resp) assert isinstance(msg, dict) @@ -386,15 +386,13 @@ def test_token_exchange_fails_if_disabled(self): Test that token exchange fails if it's not included in Token's grant_types_supported (that are set in its helper attribute). """ - del self.endpoint.helper[ - "urn:ietf:params:oauth:grant-type:token-exchange" - ] + del self.endpoint.helper["urn:ietf:params:oauth:grant-type:token-exchange"] areq = AUTH_REQ.copy() session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -407,20 +405,16 @@ def test_token_exchange_fails_if_disabled(self): grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=_token_value, subject_token_type="urn:ietf:params:oauth:token-type:access_token", - resource=["https://example.com/api"] + resource=["https://example.com/api"], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert _resp["error"] == "invalid_request" - assert( + assert ( _resp["error_description"] == "Unsupported grant_type: urn:ietf:params:oauth:grant-type:token-exchange" ) @@ -435,7 +429,7 @@ def test_wrong_resource(self): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -448,16 +442,12 @@ def test_wrong_resource(self): grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=_token_value, subject_token_type="urn:ietf:params:oauth:token-type:access_token", - resource=["https://unknown-resource.com/api"] + resource=["https://unknown-resource.com/api"], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -473,7 +463,7 @@ def test_refresh_token_audience(self): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -486,16 +476,12 @@ def test_refresh_token_audience(self): grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=_token_value, subject_token_type="urn:ietf:params:oauth:token-type:refresh_token", - audience=["https://example.com"] + audience=["https://example.com"], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -512,7 +498,7 @@ def test_wrong_audience(self): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -525,16 +511,12 @@ def test_wrong_audience(self): grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=_token_value, subject_token_type="urn:ietf:params:oauth:token-type:access_token", - audience=["https://unknown-audience.com/"] + audience=["https://unknown-audience.com/"], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} @@ -550,44 +532,43 @@ def test_exchange_refresh_token_to_refresh_token(self): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) - + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["scope"] = "openid" _token_request["code"] = code.value _req = self.endpoint.parse_request(_token_request) _resp = self.endpoint.process_request(request=_req) + _token_value = _resp["response_args"]["refresh_token"] token_exchange_req = TokenExchangeRequest( grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=_token_value, subject_token_type="urn:ietf:params:oauth:token-type:refresh_token", - requested_token_type="urn:ietf:params:oauth:token-type:refresh_token" + requested_token_type="urn:ietf:params:oauth:token-type:refresh_token", ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) != {"error", "error_description"} - @pytest.mark.parametrize("scopes", [ - ["openid", "offline_access"], - ["openid"], - ]) + @pytest.mark.parametrize( + "scopes", + [ + ["openid", "offline_access"], + ["openid"], + ], + ) def test_exchange_access_token_to_refresh_token(self, scopes): AUTH_REQ["scope"] = scopes areq = AUTH_REQ.copy() session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["scope"] = ["openid"] @@ -599,16 +580,12 @@ def test_exchange_access_token_to_refresh_token(self, scopes): grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=_token_value, subject_token_type="urn:ietf:params:oauth:token-type:access_token", - requested_token_type="urn:ietf:params:oauth:token-type:refresh_token" + requested_token_type="urn:ietf:params:oauth:token-type:refresh_token", ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) if "offline_access" in scopes: @@ -616,10 +593,13 @@ def test_exchange_access_token_to_refresh_token(self, scopes): else: assert _resp["error"] == "invalid_request" - @pytest.mark.parametrize("missing_attribute", [ - "subject_token_type", - "subject_token", - ]) + @pytest.mark.parametrize( + "missing_attribute", + [ + "subject_token_type", + "subject_token", + ], + ) def test_missing_parameters(self, missing_attribute): """ Test that omitting the subject_token_type fails. @@ -628,7 +608,7 @@ def test_missing_parameters(self, missing_attribute): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -642,33 +622,29 @@ def test_missing_parameters(self, missing_attribute): subject_token=_token_value, subject_token_type="urn:ietf:params:oauth:token-type:access_token", audience=["https://example.com"], - resource=["https://example.com/api"] + resource=["https://example.com/api"], ) del token_exchange_req[missing_attribute] _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} assert _resp["error"] == "invalid_request" - assert ( - _resp["error_description"] - == f"Missing required attribute '{missing_attribute}'" - ) - - @pytest.mark.parametrize("unsupported_type", [ - "unknown", - "urn:ietf:params:oauth:token-type:id_token", - "urn:ietf:params:oauth:token-type:saml2", - "urn:ietf:params:oauth:token-type:saml1", - ]) + assert _resp["error_description"] == f"Missing required attribute '{missing_attribute}'" + + @pytest.mark.parametrize( + "unsupported_type", + [ + "unknown", + "urn:ietf:params:oauth:token-type:id_token", + "urn:ietf:params:oauth:token-type:saml2", + "urn:ietf:params:oauth:token-type:saml1", + ], + ) def test_unsupported_requested_token_type(self, unsupported_type): """ Test that requesting a token type that is unknown or unsupported fails. @@ -677,7 +653,7 @@ def test_unsupported_requested_token_type(self, unsupported_type): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -692,31 +668,27 @@ def test_unsupported_requested_token_type(self, unsupported_type): subject_token_type="urn:ietf:params:oauth:token-type:access_token", requested_token_type=unsupported_type, audience=["https://example.com"], - resource=["https://example.com/api"] + resource=["https://example.com/api"], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} assert _resp["error"] == "invalid_request" - assert ( - _resp["error_description"] - == "Unsupported requested token type" - ) - - @pytest.mark.parametrize("unsupported_type", [ - "unknown", - "urn:ietf:params:oauth:token-type:id_token", - "urn:ietf:params:oauth:token-type:saml2", - "urn:ietf:params:oauth:token-type:saml1", - ]) + assert _resp["error_description"] == "Unsupported requested token type" + + @pytest.mark.parametrize( + "unsupported_type", + [ + "unknown", + "urn:ietf:params:oauth:token-type:id_token", + "urn:ietf:params:oauth:token-type:saml2", + "urn:ietf:params:oauth:token-type:saml1", + ], + ) def test_unsupported_subject_token_type(self, unsupported_type): """ Test that providing an unsupported subject token type fails. @@ -725,7 +697,7 @@ def test_unsupported_subject_token_type(self, unsupported_type): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -739,24 +711,17 @@ def test_unsupported_subject_token_type(self, unsupported_type): subject_token=_token_value, subject_token_type=unsupported_type, audience=["https://example.com"], - resource=["https://example.com/api"] + resource=["https://example.com/api"], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} assert _resp["error"] == "invalid_request" - assert ( - _resp["error_description"] - == "Unsupported subject token type" - ) + assert _resp["error_description"] == "Unsupported subject token type" def test_unsupported_actor_token(self): """ @@ -766,7 +731,7 @@ def test_unsupported_actor_token(self): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -779,24 +744,17 @@ def test_unsupported_actor_token(self): grant_type="urn:ietf:params:oauth:grant-type:token-exchange", subject_token=_token_value, subject_token_type="urn:ietf:params:oauth:token-type:access_token", - actor_token=_resp['response_args']['access_token'] + actor_token=_resp["response_args"]["access_token"], ) _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} assert _resp["error"] == "invalid_request" - assert ( - _resp["error_description"] - == "Actor token not supported" - ) + assert _resp["error_description"] == "Actor token not supported" def test_invalid_token(self): """ @@ -806,7 +764,7 @@ def test_invalid_token(self): session_id = self._create_session(areq) grant = self.endpoint_context.authz(session_id, areq) - code = self._mint_code(grant, areq['client_id']) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value @@ -821,17 +779,9 @@ def test_invalid_token(self): _req = self.endpoint.parse_request( token_exchange_req.to_json(), - { - "headers": { - "authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==") - } - }, + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) _resp = self.endpoint.process_request(request=_req) assert set(_resp.keys()) == {"error", "error_description"} assert _resp["error"] == "invalid_request" - assert ( - _resp["error_description"] - == "Subject token invalid" - ) - + assert _resp["error_description"] == "Subject token invalid"