diff --git a/docs/source/contents/conf.rst b/docs/source/contents/conf.rst index 30ed142f..f788cddc 100644 --- a/docs/source/contents/conf.rst +++ b/docs/source/contents/conf.rst @@ -178,6 +178,7 @@ An example:: - implicit - urn:ietf:params:oauth:grant-type:jwt-bearer - refresh_token + - urn:ietf:params:oauth:grant-type:token-exchange claim_types_supported: - normal - aggregated @@ -486,7 +487,8 @@ An example:: "supports_minting": ["access_token", "refresh_token"] } }, - "expires_in": 43200 + "expires_in": 43200, + "audience": ['https://www.example.com'] } } }, @@ -661,6 +663,127 @@ the following:: } } +============== +Token exchange +============== +There are two possible ways to configure Token Exchange in OIDC-OP, globally and per-client. +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` 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.oauth2.token.TokenExchangeHelper", + "kwargs": { + "subject_token_types_supported": [ + "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:refresh_token", + "urn:ietf:params:oauth:token-type:id_token" + ], + "requested_token_types_supported": [ + "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:refresh_token", + "urn:ietf:params:oauth:token-type:id_token" + ], + "policy": { + "urn:ietf:params:oauth:token-type:access_token": { + "callable": "/path/to/callable", + "kwargs": { + "audience": ["https://example.com"], + "scopes": ["openid"] + } + }, + "urn:ietf:params:oauth:token-type:refresh_token": { + "callable": "/path/to/callable", + "kwargs": { + "resource": ["https://example.com"], + "scopes": ["openid"] + } + }, + "": { + "callable": "/path/to/callable", + "kwargs": { + "scopes": ["openid"] + } + } + } + } + } +} +``` + +For the per-client configuration a similar configuration scheme should be present in the client's +metadata under the `token_exchange` key. + +For example: + +``` +"token_exchange":{ + "urn:ietf:params:oauth:grant-type:token-exchange": { + "class": "oidcop.oidc.token.TokenExchangeHelper", + "kwargs": { + "subject_token_types_supported": [ + "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:refresh_token", + "urn:ietf:params:oauth:token-type:id_token" + ], + "requested_token_types_supported": [ + "urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:refresh_token", + "urn:ietf:params:oauth:token-type:id_token" + ], + "policy": { + "urn:ietf:params:oauth:token-type:access_token": { + "callable": "/path/to/callable", + "kwargs": { + "audience": ["https://example.com"], + "scopes": ["openid"] + } + }, + "urn:ietf:params:oauth:token-type:refresh_token": { + "callable": "/path/to/callable", + "kwargs": { + "resource": ["https://example.com"], + "scopes": ["openid"] + } + }, + "": { + "callable": "/path/to/callable", + "kwargs": { + "scopes": ["openid"] + } + } + } + } + } +} +``` + +The policy callable accepts a specific argument list and must return the altered token exchange +request or raise an exception. + +For example: + +``` +def custom_token_exchange_policy(request, context, subject_token, **kwargs): + if some_condition in request: + return TokenErrorResponse( + error="invalid_request", error_description="Some error occured" + ) + + return request +``` ======= Clients diff --git a/docs/source/contents/usage.md b/docs/source/contents/usage.md index 795cc0d3..c1d49a3a 100644 --- a/docs/source/contents/usage.md +++ b/docs/source/contents/usage.md @@ -125,3 +125,44 @@ oidc-op will return a json response like this:: "oLyRj7sJJ3XvAYjeDCe8rQ" ] } + +Token exchange +------------- + +Here an example about how to exchange an access token for a new access token. + + import requests + + CLIENT_ID="" + CLIENT_SECRET="" + SUBJECT_TOKEN="" + REQUESTED_TOKEN_TYPE="urn:ietf:params:oauth:token-type:access_token" + + data = { + "grant_type" : "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type" : f"{REQUESTED_TOKEN_TYPE}", + "client_id" : f"{CLIENT_ID}", + "client_secret" : f"{CLIENT_SECRET}", + "subject_token" : f"{SUBJECT_TOKEN}" + } + headers = {'Content-Type': "application/x-www-form-urlencoded" } + response = requests.post( + 'https://example.com/OIDC/token', verify=False, data=data, headers=headers + ) + +oidc-op will return a json response like this:: + + { + "access_token": "eyJhbGciOiJFUzI1NiIsI...Bo6aQcOKEN-1U88jjKxLb-9Q", + "scope": "openid email", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "expires_in": 86400 + } + +In order to request a refresh token the value of `requested_token_type` should be set to +`urn:ietf:params:oauth:token-type:refresh_token`. + +The [RFC-8693](https://datatracker.ietf.org/doc/html/rfc8693) describes the `audience` parameter that +defines the authorized targets of a token exchange request. +If `subject_token = urn:ietf:params:oauth:token-type:refresh_token` then `audience` should not be +included in the token exchange request. diff --git a/docs/source/index.rst b/docs/source/index.rst index 051a7b27..788272dc 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -21,6 +21,7 @@ Idpy OIDC-op implements the following standards: * `OpenID Connect Back-Channel Logout 1.0 `_ * `OpenID Connect Front-Channel Logout 1.0 `_ * `OAuth2 Token introspection `_ +* `OAuth2 Token exchange `_ It also comes with the following `add_on` modules. diff --git a/src/oidcop/configure.py b/src/oidcop/configure.py index 2b9cc6d9..542af83e 100755 --- a/src/oidcop/configure.py +++ b/src/oidcop/configure.py @@ -564,7 +564,7 @@ def __init__( "client_secret_basic", "client_secret_jwt", "private_key_jwt", - ] + ], }, }, "userinfo": { diff --git a/src/oidcop/oauth2/introspection.py b/src/oidcop/oauth2/introspection.py index c298c12d..f8ad9dce 100644 --- a/src/oidcop/oauth2/introspection.py +++ b/src/oidcop/oauth2/introspection.py @@ -115,7 +115,10 @@ def process_request(self, request=None, release: Optional[list] = None, **kwargs _resp.update(_info) _resp.weed() - _claims_restriction = grant.claims.get("introspection") + _claims_restriction = _context.claims_interface.get_claims( + _session_info["session_id"], scopes=_token.scope, claims_release_point="introspection" + ) + if _claims_restriction: user_info = _context.claims_interface.get_user_claims( _session_info["user_id"], _claims_restriction diff --git a/src/oidcop/oauth2/token.py b/src/oidcop/oauth2/token.py index bfee6dff..af62d4b3 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 @@ -68,7 +77,7 @@ def _mint_token( token_args = meth(_context, client_id, token_args) if token_args: - _args = {"token_args": token_args} + _args = token_args else: _args = {} @@ -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,270 @@ 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}}, + } + 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", + ) + + 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.get("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 + if token.expires_at: + response_args["expires_in"] = token.expires_at - utc_time_sans_frac() + 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 set(request["resource"]).issubset(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 set(request["audience"]).issubset(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 337588b8..9aedda49 100755 --- a/src/oidcop/oidc/token.py +++ b/src/oidcop/oidc/token.py @@ -1,22 +1,36 @@ import logging from typing import Optional from typing import Union +from urllib.parse import urlparse +from cryptojwt.exception import JWKESTException from cryptojwt.jwe.exception import JWEException 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.oauth2 import ResponseMessage +from oidcmsg.oauth2 import TokenExchangeRequest +from oidcmsg.oauth2 import TokenExchangeResponse from oidcmsg.oidc import RefreshAccessTokenRequest from oidcmsg.oidc import TokenErrorResponse 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.util import importer logger = logging.getLogger(__name__) @@ -209,6 +223,8 @@ def process_request(self, req: Union[Message, dict], **kwargs): token_value = req["refresh_token"] _session_info = _mngr.get_session_info_by_token(token_value, grant=True) + grant = _session_info["grant"] + audience = grant.authorization_request.get("audience", {}) if _session_info["client_id"] != req["client_id"]: logger.debug("{} owner of token".format(_session_info["client_id"])) logger.warning("{} using token it was not given".format(req["client_id"])) @@ -353,6 +369,14 @@ def post_parse_request( return request +class TokenExchangeHelper(OAuth2TokenExchangeHelper): + + 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", + } + class Token(oauth2.token.Token): request_cls = Message response_cls = oidc.AccessTokenResponse @@ -367,4 +391,5 @@ class Token(oauth2.token.Token): helper_by_grant_type = { "authorization_code": AccessTokenHelper, "refresh_token": RefreshTokenHelper, + "urn:ietf:params:oauth:grant-type:token-exchange": TokenExchangeHelper, } diff --git a/src/oidcop/oidc/userinfo.py b/src/oidcop/oidc/userinfo.py index 6e6ce978..7e7235be 100755 --- a/src/oidcop/oidc/userinfo.py +++ b/src/oidcop/oidc/userinfo.py @@ -141,10 +141,13 @@ def process_request(self, request=None, **kwargs): # if "offline_access" in session["authn_req"]["scope"]: # pass + _cntxt = self.server_get("endpoint_context") if allowed: - _claims = _grant.claims.get("userinfo") - info = self.server_get("endpoint_context").claims_interface.get_user_claims( - user_id=_session_info["user_id"], claims_restriction=_claims + _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["sub"] = _grant.sub if _grant.add_acr_value("userinfo"): diff --git a/src/oidcop/session/grant.py b/src/oidcop/session/grant.py index 16b4eb0a..c57d2a17 100644 --- a/src/oidcop/session/grant.py +++ b/src/oidcop/session/grant.py @@ -444,22 +444,22 @@ 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, usage_rules: Optional[dict] = None, + exchange_request: str = "", + original_session_id: str = "", issued_at: int = 0, expires_in: int = 0, expires_at: int = 0, revoked: bool = False, token_map: Optional[dict] = None, - users: list = None, + sub: Optional[str] = "", ): Grant.__init__( self, scope=scope, - claims=claims, resources=resources, authorization_details=authorization_details, issued_token=issued_token, @@ -469,9 +469,8 @@ def __init__( expires_at=expires_at, revoked=revoked, token_map=token_map, + sub=sub ) - 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 diff --git a/src/oidcop/session/manager.py b/src/oidcop/session/manager.py index 3bddebc4..06c2a8f0 100644 --- a/src/oidcop/session/manager.py +++ b/src/oidcop/session/manager.py @@ -1,26 +1,29 @@ 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 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 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__) @@ -198,6 +201,37 @@ def create_grant( return self.encrypted_session_id(user_id, client_id, grant.id) + def create_exchange_grant( + self, + exchange_request: TokenExchangeRequest, + original_session_id: str, + user_id: str, + client_id: Optional[str] = "", + sub_type: Optional[str] = "public", + token_usage_rules: Optional[dict] = None, + scopes: Optional[list] = None, + ) -> str: + """ + + :param scopes: Scopes + :param exchange_req: + :param user_id: + :param client_id: + :param sub_type: + :return: + """ + + grant = ExchangeGrant( + scope=scopes, original_session_id=original_session_id, exchange_request=exchange_request, + sub=self.sub_func[sub_type]( + user_id, salt=self.salt, sector_identifier="" + ), + usage_rules=token_usage_rules, + ) + self.set([user_id, client_id, grant.id], grant) + + return self.encrypted_session_id(user_id, client_id, grant.id) + def create_session( self, authn_event: AuthnEvent, @@ -247,6 +281,55 @@ def create_session( scopes=scopes, ) + def create_exchange_session( + self, + exchange_request: TokenExchangeRequest, + original_session_id: str, + user_id: str, + client_id: Optional[str] = "", + sub_type: Optional[str] = "public", + token_usage_rules: Optional[dict] = None, + scopes: Optional[list] = None, + ) -> str: + """ + Create part of a user session. The parts added are user- and client + information and a grant. + + :param scopes: + :param authn_event: Authentication Event information + :param auth_req: Authorization Request + :param client_id: Client ID + :param user_id: User ID + :param sub_type: What kind of subject will be assigned + :param token_usage_rules: Rules for how tokens can be used + :return: Session key + """ + + try: + _usi = self.get([user_id]) + except KeyError: + _usi = UserSessionInfo(user_id=user_id) + self.set([user_id], _usi) + + if not client_id: + client_id = exchange_request["client_id"] + + try: + self.get([user_id, client_id]) + except (NoSuchClientSession, ValueError): + client_info = ClientSessionInfo(client_id=client_id) + self.set([user_id, client_id], client_info) + + return self.create_exchange_grant( + exchange_request=exchange_request, + original_session_id=original_session_id, + user_id=user_id, + client_id=client_id, + sub_type=sub_type, + token_usage_rules=token_usage_rules, + scopes=scopes, + ) + def __getitem__(self, session_id: str): return self.get(self.decrypt_session_id(session_id)) diff --git a/tests/test_26_oidc_userinfo_endpoint.py b/tests/test_26_oidc_userinfo_endpoint.py index 5c6a6337..202c4b9b 100755 --- a/tests/test_26_oidc_userinfo_endpoint.py +++ b/tests/test_26_oidc_userinfo_endpoint.py @@ -322,6 +322,7 @@ def test_scopes_to_claims(self): session_id = self._create_session(_auth_req) grant = self.session_manager[session_id] + grant.scope = _auth_req["scope"] access_token = self._mint_token("access_token", grant, session_id) self.endpoint.kwargs["add_claims_by_scope"] = True @@ -366,6 +367,7 @@ def test_scopes_to_claims_per_client(self): session_id = self._create_session(_auth_req) grant = self.session_manager[session_id] + grant.scope = _auth_req["scope"] access_token = self._mint_token("access_token", grant, session_id) self.endpoint.kwargs["add_claims_by_scope"] = True diff --git a/tests/test_36_oauth2_token_exchange.py b/tests/test_36_oauth2_token_exchange.py index 11147e91..6731ee10 100644 --- a/tests/test_36_oauth2_token_exchange.py +++ b/tests/test_36_oauth2_token_exchange.py @@ -1,11 +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 -import pytest +from oidcmsg.oidc import RefreshAccessTokenRequest from oidcop.authn_event import create_authn_event from oidcop.authz import AuthzHandling @@ -68,6 +69,10 @@ client_secret="hemligt", ) +REFRESH_TOKEN_REQ = RefreshAccessTokenRequest( + grant_type="refresh_token", client_id="https://example.com/", client_secret="hemligt" +) + TOKEN_REQ_DICT = TOKEN_REQ.to_dict() BASEDIR = os.path.abspath(os.path.dirname(__file__)) @@ -100,14 +105,14 @@ def create_endpoint(self): }, "token": { "path": "token", - "class": 'oidcop.oauth2.token.Token', + "class": "oidcop.oidc.token.Token", "kwargs": { "client_authn_method": [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", - ] + ], }, }, "introspection": { @@ -132,12 +137,17 @@ def create_endpoint(self): "grant_config": { "usage_rules": { "authorization_code": { - "supports_minting": ["access_token", "refresh_token", "id_token", ], + "supports_minting": ["access_token", "refresh_token"], "max_usage": 1, }, - "access_token": {}, + "access_token": { + "supports_minting": ["access_token", "refresh_token"], + "expires_in": 600, + }, "refresh_token": { "supports_minting": ["access_token", "refresh_token"], + "audience": ["https://example.com", "https://example2.com"], + "expires_in": 43200, }, }, "expires_in": 43200, @@ -162,18 +172,27 @@ def create_endpoint(self): }, } server = Server(ASConfiguration(conf=conf, base_path=BASEDIR), cwd=BASEDIR) - endpoint_context = server.endpoint_context - endpoint_context.cdb["client_1"] = { + self.endpoint_context = server.endpoint_context + self.endpoint_context.cdb["client_1"] = { + "client_secret": "hemligt", + "redirect_uris": [("https://example.com/cb", None)], + "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"], + } + self.endpoint_context.cdb["client_2"] = { "client_secret": "hemligt", "redirect_uris": [("https://example.com/cb", None)], "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"], } - endpoint_context.keyjar.import_jwks(CLIENT_KEYJAR.export_jwks(), "client_1") + self.endpoint_context.keyjar.import_jwks(CLIENT_KEYJAR.export_jwks(), "client_1") self.endpoint = server.server_get("endpoint", "token") self.introspection_endpoint = server.server_get("endpoint", "introspection") - self.session_manager = endpoint_context.session_manager + self.session_manager = self.endpoint_context.session_manager self.user_id = "diana" def _create_session(self, auth_req, sub_type="public", sector_identifier=""): @@ -188,100 +207,584 @@ def _create_session(self, auth_req, sub_type="public", sector_identifier=""): ae, authz_req, self.user_id, client_id=client_id, sub_type=sub_type ) - def _mint_code(self, grant, session_id): - return grant.mint_token( + def _mint_code(self, grant, client_id): + session_id = self.session_manager.encrypted_session_id(self.user_id, client_id, grant.id) + usage_rules = grant.usage_rules.get("authorization_code", {}) + _exp_in = usage_rules.get("expires_in") + + # Constructing an authorization code is now done + _code = grant.mint_token( session_id=session_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, ) - def _mint_access_token(self, grant, session_id, token_ref=None, resources=None, scope=None): - return grant.mint_token( - session_id=session_id, - endpoint_context=self.endpoint.server_get("endpoint_context"), - token_class="access_token", - token_handler=self.session_manager.token_handler["access_token"], - based_on=token_ref, - resources=resources, - scope=scope + if _exp_in: + if isinstance(_exp_in, str): + _exp_in = int(_exp_in) + if _exp_in: + _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"}, + ], + ) + def test_token_exchange(self, token): + """ + Test that token exchange requests work correctly + """ + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + _token_value = _resp["response_args"][list(token.keys())[0]] + + token_exchange_req = TokenExchangeRequest( + 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]], ) - def exchange_grant(self, session_id, users, targets, scope): - session_info = self.session_manager.get_session_info(session_id) - exchange_grant = ExchangeGrant(scope=scope, resources=targets, users=users) + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"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", + } + + @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 + """ + self.endpoint_context.cdb["client_1"]["token_exchange"] = { + "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": "oidcop.oauth2.token.default_token_exchange_policy", + "kwargs": {"scope": ["openid"]}, + } + }, + } - # the grant is assigned to a session (user_id, client_id) - self.session_manager.set( - [self.user_id, session_info["client_id"], exchange_grant.id], exchange_grant + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + _token_value = _resp["response_args"][list(token.keys())[0]] + + token_exchange_req = TokenExchangeRequest( + 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]], + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) - return exchange_grant + _resp = self.endpoint.process_request(request=_req) + assert set(_resp["response_args"].keys()) == { + "access_token", + "token_type", + "scope", + "expires_in", + "issued_token_type", + } - def test_do_response(self): - session_id = self._create_session(AUTH_REQ) + def test_additional_parameters(self): + """ + Test that a token exchange with additional parameters including + scope, audience and subject_token_type works. + """ + conf = self.endpoint.helper["urn:ietf:params:oauth:grant-type:token-exchange"].config + conf["policy"][""]["kwargs"] = {} + conf["policy"][""]["kwargs"]["audience"] = ["https://example.com"] + conf["policy"][""]["kwargs"]["resource"] = ["https://example.com"] - grant = self.session_manager.get_grant(session_id) - grant.usage_rules["access_token"] = {"supports_minting": ["access_token"]} + areq = AUTH_REQ.copy() - grant_user_id = "https://frontend.example.com/resource" - backend = "https://backend.example.com" - _ = self.exchange_grant(session_id, [grant_user_id], [backend], scope=["api"]) - code = self._mint_code(grant, session_id) + session_id = self._create_session(areq) + grant = self.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq["client_id"]) _token_request = TOKEN_REQ_DICT.copy() _token_request["code"] = code.value _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + _token_value = _resp["response_args"]["access_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:access_token", + requested_token_type="urn:ietf:params:oauth:token-type:access_token", + audience=["https://example.com"], + resource=["https://example.com"], + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"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", + } msg = self.endpoint.do_response(request=_req, **_resp) assert isinstance(msg, dict) - token_response = json.loads(msg["response"]) - print(token_response["access_token"]) - # resource server sends a token exchange request with - # access token as subject_token + 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"] + + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + _token_value = _resp["response_args"]["access_token"] - ter = TokenExchangeRequest( - subject_token=token_response["access_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:access_token", + resource=["https://example.com/api"], + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, + ) + _resp = self.endpoint.process_request(request=_req) + assert _resp["error"] == "invalid_request" + assert ( + _resp["error_description"] + == "Unsupported grant_type: urn:ietf:params:oauth:grant-type:token-exchange" + ) + + def test_wrong_resource(self): + """ + Test that requesting a token for an unknown resource fails. + """ + conf = self.endpoint.helper["urn:ietf:params:oauth:grant-type:token-exchange"].config + conf["policy"][""]["kwargs"] = {} + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + _token_value = _resp["response_args"]["access_token"] + + token_exchange_req = TokenExchangeRequest( grant_type="urn:ietf:params:oauth:grant-type:token-exchange", - resource="https://backend.example.com/api", + subject_token=_token_value, + subject_token_type="urn:ietf:params:oauth:token-type:access_token", + resource=["https://unknown-resource.com/api"], + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) + _resp = self.endpoint.process_request(request=_req) + assert set(_resp.keys()) == {"error", "error_description"} + assert _resp["error"] == "invalid_target" + assert _resp["error_description"] == "Unknown resource" + + def test_refresh_token_audience(self): + """ + Test that requesting a refresh token with audience fails. + """ + AUTH_REQ["scope"] = ["openid", "offline_access"] + areq = AUTH_REQ.copy() - exch_grants = [] - for grant in self.session_manager.grants(session_id=session_id): - if isinstance(grant, ExchangeGrant): - if grant_user_id in grant.users: - exch_grants.append(grant) + session_id = self._create_session(areq) + grant = self.endpoint_context.authz(session_id, areq) + code = self._mint_code(grant, areq["client_id"]) - assert exch_grants - exch_grant = exch_grants[0] + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) - session_info = self.session_manager.get_session_info_by_token(ter["subject_token"], - grant=True) - _token = self.session_manager.find_token(session_info["session_id"], ter["subject_token"]) + _token_value = _resp["response_args"]["refresh_token"] - session_id = self.session_manager.encrypted_session_id( - session_info["user_id"], session_info["client_id"], exch_grant.id + 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", + audience=["https://example.com"], ) - _scope = session_info["grant"].find_scope(ter["subject_token"]) + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, + ) + _resp = self.endpoint.process_request(request=_req) + assert set(_resp.keys()) == {"error", "error_description"} + assert _resp["error"] == "invalid_target" + assert _resp["error_description"] == "Refresh token has single owner" + + def test_wrong_audience(self): + """ + Test that requesting a token for an unknown audience fails. + """ + conf = self.endpoint.helper["urn:ietf:params:oauth:grant-type:token-exchange"].config + conf["policy"][""]["kwargs"] = {} + conf["policy"][""]["kwargs"]["audience"] = ["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"]) - _token = self._mint_access_token( - exch_grant, session_id, token_ref=_token, resources=["https://backend.example.com"], - scope=_scope + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + _token_value = _resp["response_args"]["access_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:access_token", + audience=["https://unknown-audience.com/"], ) - print(_token.value) - _req = self.introspection_endpoint.parse_request( - { - "token": _token.value, - "client_id": "client_1", - "client_secret": self.introspection_endpoint.server_get("endpoint_context").cdb[ - "client_1"]["client_secret"], - } + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"headers": {"authorization": "Basic {}".format("Y2xpZW50XzE6aGVtbGlndA==")}}, ) - _resp = self.introspection_endpoint.process_request(_req) - msg_info = self.introspection_endpoint.do_response(request=_req, **_resp) - assert msg_info - print(json.loads(msg_info["response"])) + _resp = self.endpoint.process_request(request=_req) + assert set(_resp.keys()) == {"error", "error_description"} + assert _resp["error"] == "invalid_target" + assert _resp["error_description"] == "Unknown audience" + + def test_exchange_refresh_token_to_refresh_token(self): + """ + Test whether exchanging a refresh token to another refresh token works. + """ + 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"]) + _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", + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"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"], + ], + ) + 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"]) + + _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"]["access_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:access_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==")}}, + ) + _resp = self.endpoint.process_request(request=_req) + if "offline_access" in scopes: + assert set(_resp.keys()) != {"error", "error_description"} + else: + assert _resp["error"] == "invalid_request" + + @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. + """ + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + _token_value = _resp["response_args"]["access_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:access_token", + audience=["https://example.com"], + 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==")}}, + ) + _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", + ], + ) + def test_unsupported_requested_token_type(self, unsupported_type): + """ + Test that requesting a token type that is unknown or unsupported fails. + """ + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + _token_value = _resp["response_args"]["access_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:access_token", + requested_token_type=unsupported_type, + audience=["https://example.com"], + resource=["https://example.com/api"], + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"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", + ], + ) + def test_unsupported_subject_token_type(self, unsupported_type): + """ + Test that providing an unsupported subject token type fails. + """ + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + _token_value = _resp["response_args"]["access_token"] + + token_exchange_req = TokenExchangeRequest( + grant_type="urn:ietf:params:oauth:grant-type:token-exchange", + subject_token=_token_value, + subject_token_type=unsupported_type, + audience=["https://example.com"], + resource=["https://example.com/api"], + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"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" + + def test_unsupported_actor_token(self): + """ + Test that providing an actor token fails as it's unsupported. + """ + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + _token_value = _resp["response_args"]["access_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:access_token", + actor_token=_resp["response_args"]["access_token"], + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"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" + + def test_invalid_token(self): + """ + Test that providing an invalid token fails. + """ + 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"]) + + _token_request = TOKEN_REQ_DICT.copy() + _token_request["code"] = code.value + _req = self.endpoint.parse_request(_token_request) + _resp = self.endpoint.process_request(request=_req) + + token_exchange_req = TokenExchangeRequest( + grant_type="urn:ietf:params:oauth:grant-type:token-exchange", + subject_token="invalid_token", + subject_token_type="urn:ietf:params:oauth:token-type:access_token", + ) + + _req = self.endpoint.parse_request( + token_exchange_req.to_json(), + {"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"